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

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