Add support for Prometheus integration

This commit is contained in:
Ken Hibino
2021-12-19 07:30:16 -08:00
committed by GitHub
parent 711ca8b5c8
commit 1b8d46a35e
30 changed files with 2113 additions and 105 deletions

File diff suppressed because one or more lines are too long

View 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.
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -51,6 +51,7 @@
/>
<script>
window.ROOT_PATH = "%PUBLIC_URL%";
window.PROMETHEUS_SERVER_ADDRESS = "/[[.PrometheusAddr]]";
</script>
<title>Asynq - Monitoring</title>
</head>

View File

@@ -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>

View 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),
});
}
};
}

View File

@@ -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;
}

View File

@@ -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

View 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);

View File

@@ -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

View 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;

View File

@@ -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
View File

@@ -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;
}

View File

@@ -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]);
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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");
}
}

View 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}
/>
</>
);
}

View File

@@ -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",