Initial commit

This commit is contained in:
Ken Hibino
2020-11-24 06:54:00 -08:00
commit 7bd35a88e5
51 changed files with 16522 additions and 0 deletions

9
ui/src/App.test.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from "react";
import { render } from "@testing-library/react";
import App from "./App";
test("renders learn react link", () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

194
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,194 @@
import React, { useState } from "react";
import clsx from "clsx";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import { makeStyles } from "@material-ui/core/styles";
import AppBar from "@material-ui/core/AppBar";
import Drawer from "@material-ui/core/Drawer";
import Toolbar from "@material-ui/core/Toolbar";
import List from "@material-ui/core/List";
import Typography from "@material-ui/core/Typography";
import IconButton from "@material-ui/core/IconButton";
import MenuIcon from "@material-ui/icons/Menu";
import BarChartIcon from "@material-ui/icons/BarChart";
import LayersIcon from "@material-ui/icons/Layers";
import SettingsIcon from "@material-ui/icons/Settings";
import { paths } from "./paths";
import ListItemLink from "./components/ListItemLink";
import CronView from "./views/CronView";
import DashboardView from "./views/DashboardView";
import TasksView from "./views/TasksView";
import SettingsView from "./views/SettingsView";
const drawerWidth = 220;
const useStyles = makeStyles((theme) => ({
root: {
display: "flex",
},
toolbar: {
paddingRight: 24, // keep right padding when drawer closed
},
toolbarIcon: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: "0 8px",
...theme.mixins.toolbar,
},
appBar: {
backgroundColor: theme.palette.background.paper,
zIndex: theme.zIndex.drawer + 1,
},
menuButton: {
marginRight: theme.spacing(2),
color: theme.palette.grey[700],
},
menuButtonHidden: {
display: "none",
},
title: {
flexGrow: 1,
color: theme.palette.grey[800],
},
drawerPaper: {
position: "relative",
whiteSpace: "nowrap",
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
border: "none",
},
drawerPaperClose: {
overflowX: "hidden",
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
width: theme.spacing(7),
[theme.breakpoints.up("sm")]: {
width: theme.spacing(9),
},
},
appBarSpacer: theme.mixins.toolbar,
mainContainer: {
display: "flex",
width: "100vw",
},
content: {
flex: 1,
height: "100vh",
overflow: "hidden",
background: "#ffffff",
},
contentWrapper: {
height: "100%",
display: "flex",
paddingTop: "64px", // app-bar height
overflow: "scroll",
},
sidebarContainer: {
display: "flex",
justifyContent: "space-between",
height: "100%",
flexDirection: "column",
},
}));
function App() {
const classes = useStyles();
const [open, setOpen] = useState(true);
const toggleDrawer = () => {
setOpen(!open);
};
return (
<Router>
<div className={classes.root}>
<AppBar
position="absolute"
className={classes.appBar}
variant="outlined"
>
<Toolbar className={classes.toolbar}>
<IconButton
edge="start"
color="inherit"
aria-label="open drawer"
onClick={toggleDrawer}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography
component="h1"
variant="h6"
color="inherit"
noWrap
className={classes.title}
>
Asynq Monitoring
</Typography>
</Toolbar>
</AppBar>
<div className={classes.mainContainer}>
<Drawer
variant="permanent"
classes={{
paper: clsx(
classes.drawerPaper,
!open && classes.drawerPaperClose
),
}}
open={open}
>
<div className={classes.appBarSpacer} />
<div className={classes.sidebarContainer}>
<List>
<div>
<ListItemLink
to="/"
primary="Queues"
icon={<BarChartIcon />}
/>
<ListItemLink
to="/cron"
primary="Cron"
icon={<LayersIcon />}
/>
</div>
</List>
<List>
<ListItemLink
to="/settings"
primary="Settings"
icon={<SettingsIcon />}
/>
</List>
</div>
</Drawer>
<main className={classes.content}>
<div className={classes.contentWrapper}>
<Switch>
<Route exact path={paths.QUEUE_DETAILS}>
<TasksView />
</Route>
<Route exact path={paths.CRON}>
<CronView />
</Route>
<Route exact path={paths.SETTINGS}>
<SettingsView />
</Route>
<Route path={paths.HOME}>
<DashboardView />
</Route>
</Switch>
</div>
</main>
</div>
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,158 @@
import {
getQueue,
GetQueueResponse,
listQueues,
ListQueuesResponse,
pauseQueue,
resumeQueue,
} from "../api";
import { Dispatch } from "redux";
// List of queue related action types.
export const LIST_QUEUES_BEGIN = "LIST_QUEUES_BEGIN";
export const LIST_QUEUES_SUCCESS = "LIST_QUEUES_SUCCESS";
export const GET_QUEUE_BEGIN = "GET_QUEUE_BEGIN";
export const GET_QUEUE_SUCCESS = "GET_QUEUE_SUCCESS";
export const GET_QUEUE_ERROR = "GET_QUEUE_ERROR";
export const PAUSE_QUEUE_BEGIN = "PAUSE_QUEUE_BEGIN";
export const PAUSE_QUEUE_SUCCESS = "PAUSE_QUEUE_SUCCESS";
export const PAUSE_QUEUE_ERROR = "PAUSE_QUEUE_ERROR";
export const RESUME_QUEUE_BEGIN = "RESUME_QUEUE_BEGIN";
export const RESUME_QUEUE_SUCCESS = "RESUME_QUEUE_SUCCESS";
export const RESUME_QUEUE_ERROR = "RESUME_QUEUE_ERROR";
interface ListQueuesBeginAction {
type: typeof LIST_QUEUES_BEGIN;
}
interface ListQueuesSuccessAction {
type: typeof LIST_QUEUES_SUCCESS;
payload: ListQueuesResponse;
}
interface GetQueueBeginAction {
type: typeof GET_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface GetQueueSuccessAction {
type: typeof GET_QUEUE_SUCCESS;
queue: string; // name of the queue
payload: GetQueueResponse;
}
interface GetQueueErrorAction {
type: typeof GET_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
interface PauseQueueBeginAction {
type: typeof PAUSE_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface PauseQueueSuccessAction {
type: typeof PAUSE_QUEUE_SUCCESS;
queue: string; // name of the queue
}
interface PauseQueueErrorAction {
type: typeof PAUSE_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
interface ResumeQueueBeginAction {
type: typeof RESUME_QUEUE_BEGIN;
queue: string; // name of the queue
}
interface ResumeQueueSuccessAction {
type: typeof RESUME_QUEUE_SUCCESS;
queue: string; // name of the queue
}
interface ResumeQueueErrorAction {
type: typeof RESUME_QUEUE_ERROR;
queue: string; // name of the queue
error: string; // error description
}
// Union of all queues related action types.
export type QueuesActionTypes =
| ListQueuesBeginAction
| ListQueuesSuccessAction
| GetQueueBeginAction
| GetQueueSuccessAction
| GetQueueErrorAction
| PauseQueueBeginAction
| PauseQueueSuccessAction
| PauseQueueErrorAction
| ResumeQueueBeginAction
| ResumeQueueSuccessAction
| ResumeQueueErrorAction;
export function listQueuesAsync() {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: LIST_QUEUES_BEGIN });
// TODO: try/catch and dispatch error action on failure
const response = await listQueues();
dispatch({
type: LIST_QUEUES_SUCCESS,
payload: response,
});
};
}
export function getQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: GET_QUEUE_BEGIN, queue: qname });
try {
const response = await getQueue(qname);
dispatch({
type: GET_QUEUE_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: GET_QUEUE_ERROR,
queue: qname,
error: `Could not retrieve queue data for queue: ${qname}`,
});
}
};
}
export function pauseQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: PAUSE_QUEUE_BEGIN, queue: qname });
try {
await pauseQueue(qname);
dispatch({ type: PAUSE_QUEUE_SUCCESS, queue: qname });
} catch {
dispatch({
type: PAUSE_QUEUE_ERROR,
queue: qname,
error: `Could not pause queue: ${qname}`,
});
}
};
}
export function resumeQueueAsync(qname: string) {
return async (dispatch: Dispatch<QueuesActionTypes>) => {
dispatch({ type: RESUME_QUEUE_BEGIN, queue: qname });
try {
await resumeQueue(qname);
dispatch({ type: RESUME_QUEUE_SUCCESS, queue: qname });
} catch {
dispatch({
type: RESUME_QUEUE_ERROR,
queue: qname,
error: `Could not resume queue: ${qname}`,
});
}
};
}

View File

@@ -0,0 +1,17 @@
// List of settings related action types.
export const POLL_INTERVAL_CHANGE = "POLL_INTERVAL_CHANGE";
interface PollIntervalChangeAction {
type: typeof POLL_INTERVAL_CHANGE;
value: number; // new poll interval value in seconds
}
// Union of all settings related action types.
export type SettingsActionTypes = PollIntervalChangeAction;
export function pollIntervalChange(value: number) {
return {
type: POLL_INTERVAL_CHANGE,
value,
};
}

View File

@@ -0,0 +1,246 @@
import {
listActiveTasks,
ListActiveTasksResponse,
listDeadTasks,
ListDeadTasksResponse,
listPendingTasks,
ListPendingTasksResponse,
listRetryTasks,
ListRetryTasksResponse,
listScheduledTasks,
ListScheduledTasksResponse,
PaginationOptions,
} from "../api";
import { Dispatch } from "redux";
// List of tasks related action types.
export const LIST_ACTIVE_TASKS_BEGIN = "LIST_ACTIVE_TASKS_BEGIN";
export const LIST_ACTIVE_TASKS_SUCCESS = "LIST_ACTIVE_TASKS_SUCCESS";
export const LIST_ACTIVE_TASKS_ERROR = "LIST_ACTIVE_TASKS_ERROR";
export const LIST_PENDING_TASKS_BEGIN = "LIST_PENDING_TASKS_BEGIN";
export const LIST_PENDING_TASKS_SUCCESS = "LIST_PENDING_TASKS_SUCCESS";
export const LIST_PENDING_TASKS_ERROR = "LIST_PENDING_TASKS_ERROR";
export const LIST_SCHEDULED_TASKS_BEGIN = "LIST_SCHEDULED_TASKS_BEGIN";
export const LIST_SCHEDULED_TASKS_SUCCESS = "LIST_SCHEDULED_TASKS_SUCCESS";
export const LIST_SCHEDULED_TASKS_ERROR = "LIST_SCHEDULED_TASKS_ERROR";
export const LIST_RETRY_TASKS_BEGIN = "LIST_RETRY_TASKS_BEGIN";
export const LIST_RETRY_TASKS_SUCCESS = "LIST_RETRY_TASKS_SUCCESS";
export const LIST_RETRY_TASKS_ERROR = "LIST_RETRY_TASKS_ERROR";
export const LIST_DEAD_TASKS_BEGIN = "LIST_DEAD_TASKS_BEGIN";
export const LIST_DEAD_TASKS_SUCCESS = "LIST_DEAD_TASKS_SUCCESS";
export const LIST_DEAD_TASKS_ERROR = "LIST_DEAD_TASKS_ERROR";
interface ListActiveTasksBeginAction {
type: typeof LIST_ACTIVE_TASKS_BEGIN;
queue: string;
}
interface ListActiveTasksSuccessAction {
type: typeof LIST_ACTIVE_TASKS_SUCCESS;
queue: string;
payload: ListActiveTasksResponse;
}
interface ListActiveTasksErrorAction {
type: typeof LIST_ACTIVE_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListPendingTasksBeginAction {
type: typeof LIST_PENDING_TASKS_BEGIN;
queue: string;
}
interface ListPendingTasksSuccessAction {
type: typeof LIST_PENDING_TASKS_SUCCESS;
queue: string;
payload: ListPendingTasksResponse;
}
interface ListPendingTasksErrorAction {
type: typeof LIST_PENDING_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListScheduledTasksBeginAction {
type: typeof LIST_SCHEDULED_TASKS_BEGIN;
queue: string;
}
interface ListScheduledTasksSuccessAction {
type: typeof LIST_SCHEDULED_TASKS_SUCCESS;
queue: string;
payload: ListScheduledTasksResponse;
}
interface ListScheduledTasksErrorAction {
type: typeof LIST_SCHEDULED_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListRetryTasksBeginAction {
type: typeof LIST_RETRY_TASKS_BEGIN;
queue: string;
}
interface ListRetryTasksSuccessAction {
type: typeof LIST_RETRY_TASKS_SUCCESS;
queue: string;
payload: ListRetryTasksResponse;
}
interface ListRetryTasksErrorAction {
type: typeof LIST_RETRY_TASKS_ERROR;
queue: string;
error: string; // error description
}
interface ListDeadTasksBeginAction {
type: typeof LIST_DEAD_TASKS_BEGIN;
queue: string;
}
interface ListDeadTasksSuccessAction {
type: typeof LIST_DEAD_TASKS_SUCCESS;
queue: string;
payload: ListDeadTasksResponse;
}
interface ListDeadTasksErrorAction {
type: typeof LIST_DEAD_TASKS_ERROR;
queue: string;
error: string; // error description
}
// Union of all tasks related action types.
export type TasksActionTypes =
| ListActiveTasksBeginAction
| ListActiveTasksSuccessAction
| ListActiveTasksErrorAction
| ListPendingTasksBeginAction
| ListPendingTasksSuccessAction
| ListPendingTasksErrorAction
| ListScheduledTasksBeginAction
| ListScheduledTasksSuccessAction
| ListScheduledTasksErrorAction
| ListRetryTasksBeginAction
| ListRetryTasksSuccessAction
| ListRetryTasksErrorAction
| ListDeadTasksBeginAction
| ListDeadTasksSuccessAction
| ListDeadTasksErrorAction;
export function listActiveTasksAsync(qname: string) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_ACTIVE_TASKS_BEGIN, queue: qname });
try {
const response = await listActiveTasks(qname);
dispatch({
type: LIST_ACTIVE_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_ACTIVE_TASKS_ERROR,
queue: qname,
error: `Could not retreive active tasks data for queue: ${qname}`,
});
}
};
}
export function listPendingTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_PENDING_TASKS_BEGIN, queue: qname });
try {
const response = await listPendingTasks(qname, pageOpts);
dispatch({
type: LIST_PENDING_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_PENDING_TASKS_ERROR,
queue: qname,
error: `Could not retreive pending tasks data for queue: ${qname}`,
});
}
};
}
export function listScheduledTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_SCHEDULED_TASKS_BEGIN, queue: qname });
try {
const response = await listScheduledTasks(qname, pageOpts);
dispatch({
type: LIST_SCHEDULED_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_SCHEDULED_TASKS_ERROR,
queue: qname,
error: `Could not retreive scheduled tasks data for queue: ${qname}`,
});
}
};
}
export function listRetryTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_RETRY_TASKS_BEGIN, queue: qname });
try {
const response = await listRetryTasks(qname, pageOpts);
dispatch({
type: LIST_RETRY_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_RETRY_TASKS_ERROR,
queue: qname,
error: `Could not retreive retry tasks data for queue: ${qname}`,
});
}
};
}
export function listDeadTasksAsync(
qname: string,
pageOpts?: PaginationOptions
) {
return async (dispatch: Dispatch<TasksActionTypes>) => {
dispatch({ type: LIST_DEAD_TASKS_BEGIN, queue: qname });
try {
const response = await listDeadTasks(qname, pageOpts);
dispatch({
type: LIST_DEAD_TASKS_SUCCESS,
queue: qname,
payload: response,
});
} catch {
dispatch({
type: LIST_DEAD_TASKS_ERROR,
queue: qname,
error: `Could not retreive dead tasks data for queue: ${qname}`,
});
}
};
}

209
ui/src/api.ts Normal file
View File

@@ -0,0 +1,209 @@
import axios from "axios";
import queryString from "query-string";
const BASE_URL = "http://localhost:8080/api";
export interface ListQueuesResponse {
queues: Queue[];
}
export interface GetQueueResponse {
current: Queue;
history: DailyStat[];
}
export interface ListActiveTasksResponse {
tasks: ActiveTask[];
stats: Queue;
}
export interface ListPendingTasksResponse {
tasks: PendingTask[];
stats: Queue;
}
export interface ListScheduledTasksResponse {
tasks: ScheduledTask[];
stats: Queue;
}
export interface ListRetryTasksResponse {
tasks: RetryTask[];
stats: Queue;
}
export interface ListDeadTasksResponse {
tasks: DeadTask[];
stats: Queue;
}
export interface Queue {
queue: string;
paused: boolean;
size: number;
active: number;
pending: number;
scheduled: number;
retry: number;
dead: number;
processed: number;
failed: number;
timestamp: string;
}
export interface DailyStat {
date: string;
processed: number;
failed: number;
}
// BaseTask corresponds to asynq.Task type.
interface BaseTask {
type: string;
payload: { [key: string]: any };
}
export interface ActiveTask extends BaseTask {
id: string;
queue: string;
}
export interface PendingTask extends BaseTask {
id: string;
queue: string;
}
export interface ScheduledTask extends BaseTask {
id: string;
queue: string;
next_process_at: string;
}
export interface RetryTask extends BaseTask {
id: string;
queue: string;
next_process_at: string;
max_retry: number;
retried: number;
error_message: string;
}
export interface DeadTask extends BaseTask {
id: string;
queue: string;
max_retry: number;
retried: number;
last_failed_at: string;
error_message: string;
}
export interface PaginationOptions extends Record<string, number | undefined> {
size?: number; // size of the page
page?: number; // page number (1 being the first page)
}
export async function listQueues(): Promise<ListQueuesResponse> {
const resp = await axios({
method: "get",
url: `${BASE_URL}/queues`,
});
console.log("debug: listQueues response", resp.data);
return resp.data;
}
export async function getQueue(qname: string): Promise<GetQueueResponse> {
const resp = await axios({
method: "get",
url: `${BASE_URL}/queues/${qname}`,
});
return resp.data;
}
export async function pauseQueue(qname: string): Promise<void> {
await axios({
method: "post",
url: `${BASE_URL}/queues/${qname}/pause`,
});
}
export async function resumeQueue(qname: string): Promise<void> {
await axios({
method: "post",
url: `${BASE_URL}/queues/${qname}/resume`,
});
}
export async function listActiveTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListActiveTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/active_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listPendingTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListPendingTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/pending_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listScheduledTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListScheduledTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/scheduled_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listRetryTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListRetryTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/retry_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}
export async function listDeadTasks(
qname: string,
pageOpts?: PaginationOptions
): Promise<ListDeadTasksResponse> {
let url = `${BASE_URL}/queues/${qname}/dead_tasks`;
if (pageOpts) {
url += `?${queryString.stringify(pageOpts)}`;
}
const resp = await axios({
method: "get",
url,
});
return resp.data;
}

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listActiveTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { ActiveTask } from "../api";
import TablePaginationActions, {
rowsPerPageOptions,
defaultPageSize,
} from "./TablePaginationActions";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.activeTasks.loading,
tasks: state.tasks.activeTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listActiveTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue
}
function ActiveTasksTable(props: Props & ReduxProps) {
const { pollInterval, listActiveTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
listActiveTasksAsync(queue);
const interval = setInterval(
() => listActiveTasksAsync(queue),
pollInterval * 1000
);
return () => clearInterval(interval);
}, [pollInterval, listActiveTasksAsync, queue]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No active tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="active tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{/* TODO: loading and empty state */}
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.tasks.length}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: ActiveTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(ActiveTasksTable);

View File

@@ -0,0 +1,200 @@
import React, { useState, useEffect } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { AppState } from "../store";
import { listDeadTasksAsync } from "../actions/tasksActions";
import { DeadTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { timeAgo } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.deadTasks.loading,
tasks: state.tasks.deadTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listDeadTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of dead tasks.
}
function DeadTasksTable(props: Props & ReduxProps) {
const { pollInterval, listDeadTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listDeadTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listDeadTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listDeadTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No dead tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Last Failed" },
{ label: "Last Error" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="dead tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
function Row(props: { task: DeadTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{timeAgo(task.last_failed_at)}</TableCell>
<TableCell>{task.error_message}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(DeadTasksTable);

View File

@@ -0,0 +1,68 @@
import React, { ReactElement } from "react";
import clsx from "clsx";
import ListItem from "@material-ui/core/ListItem";
import ListItemIcon from "@material-ui/core/ListItemIcon";
import ListItemText from "@material-ui/core/ListItemText";
import { makeStyles } from "@material-ui/core/styles";
import {
useRouteMatch,
Link as RouterLink,
LinkProps as RouterLinkProps,
} from "react-router-dom";
const useStyles = makeStyles({
listItem: {
borderTopRightRadius: "24px",
borderBottomRightRadius: "24px",
},
selected: {
backgroundColor: "rgba(0, 0, 0, 0.07)",
},
boldText: {
fontWeight: 600,
},
});
interface Props {
to: string;
primary: string;
icon?: ReactElement;
}
// Note: See https://material-ui.com/guides/composition/ for details.
function ListItemLink(props: Props): ReactElement {
const classes = useStyles();
const { icon, primary, to } = props;
const isMatch = useRouteMatch({
path: to,
strict: true,
sensitive: true,
exact: true,
});
const renderLink = React.useMemo(
() =>
React.forwardRef<any, Omit<RouterLinkProps, "to">>((itemProps, ref) => (
<RouterLink to={to} ref={ref} {...itemProps} />
)),
[to]
);
return (
<li>
<ListItem
button
component={renderLink}
className={clsx(classes.listItem, isMatch && classes.selected)}
>
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
<ListItemText
primary={primary}
classes={{
primary: isMatch ? classes.boldText : undefined,
}}
/>
</ListItem>
</li>
);
}
export default ListItemLink;

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { listPendingTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { PendingTask } from "../api";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.pendingTasks.loading,
tasks: state.tasks.pendingTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listPendingTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
totalTaskCount: number; // total number of pending tasks
}
function PendingTasksTable(props: Props & ReduxProps) {
const { pollInterval, listPendingTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listPendingTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listPendingTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listPendingTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No pending tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="pending tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: PendingTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={8}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(PendingTasksTable);

View File

@@ -0,0 +1,45 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { useTheme, Theme } from "@material-ui/core/styles";
interface Props {
data: ProcessedStats[];
}
interface ProcessedStats {
queue: string; // name of the queue.
succeeded: number; // number of tasks succeeded.
failed: number; // number of tasks failed.
}
function ProcessedTasksChart(props: Props) {
const theme = useTheme<Theme>();
return (
<ResponsiveContainer>
<BarChart data={props.data} maxBarSize={100}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<Tooltip />
<Legend />
<Bar
dataKey="succeeded"
stackId="a"
fill={theme.palette.success.light}
/>
<Bar dataKey="failed" stackId="a" fill={theme.palette.error.light} />
</BarChart>
</ResponsiveContainer>
);
}
export default ProcessedTasksChart;

View File

@@ -0,0 +1,45 @@
import React from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
interface Props {
data: TaskBreakdown[];
}
interface TaskBreakdown {
queue: string; // name of the queue.
active: number; // number of active tasks in the queue.
pending: number; // number of pending tasks in the queue.
scheduled: number; // number of scheduled tasks in the queue.
retry: number; // number of retry tasks in the queue.
dead: number; // number of dead tasks in the queue.
}
function QueueSizeChart(props: Props) {
return (
<ResponsiveContainer>
<BarChart data={props.data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="queue" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="active" stackId="a" fill="#7bb3ff" />
<Bar dataKey="pending" stackId="a" fill="#e86af0" />
<Bar dataKey="scheduled" stackId="a" fill="#9e379f" />
<Bar dataKey="retry" stackId="a" fill="#493267" />
<Bar dataKey="dead" stackId="a" fill="#373854" />
</BarChart>
</ResponsiveContainer>
);
}
export default QueueSizeChart;

View File

@@ -0,0 +1,348 @@
import React, { useState } from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TableSortLabel from "@material-ui/core/TableSortLabel";
import IconButton from "@material-ui/core/IconButton";
import PauseCircleFilledIcon from "@material-ui/icons/PauseCircleFilled";
import PlayCircleFilledIcon from "@material-ui/icons/PlayCircleFilled";
import { Queue } from "../api";
import { queueDetailsPath } from "../paths";
const useStyles = makeStyles((theme) => ({
table: {
minWidth: 650,
},
linkCell: {
textDecoration: "none",
},
footerCell: {
fontWeight: 600,
fontSize: "0.875rem",
borderBottom: "none",
},
boldCell: {
fontWeight: 600,
},
fixedCell: {
position: "sticky",
zIndex: 1,
left: 0,
background: theme.palette.common.white,
},
}));
interface QueueWithMetadata extends Queue {
pauseRequestPending: boolean; // indicates pause/resume request is pending for the queue.
}
interface Props {
queues: QueueWithMetadata[];
onPauseClick: (qname: string) => Promise<void>;
onResumeClick: (qname: string) => Promise<void>;
}
enum SortBy {
Queue,
Size,
Active,
Pending,
Scheduled,
Retry,
Dead,
Processed,
Succeeded,
Failed,
}
enum SortDirection {
Asc = "asc",
Desc = "desc",
}
const columnConfig = [
{ label: "Queue", key: "queue", sortBy: SortBy.Queue },
{ label: "Size", key: "size", sortBy: SortBy.Size },
{ label: "Active", key: "active", sortBy: SortBy.Active },
{ label: "Pending", key: "pending", sortBy: SortBy.Pending },
{ label: "Scheduled", key: "scheduled", sortBy: SortBy.Scheduled },
{ label: "Retry", key: "retry", sortBy: SortBy.Retry },
{ label: "Dead", key: "dead", sortBy: SortBy.Dead },
{ label: "Processed", key: "processed", sortBy: SortBy.Processed },
{ label: "Succeeded", key: "Succeeded", sortBy: SortBy.Succeeded },
{ label: "Failed", key: "failed", sortBy: SortBy.Failed },
];
// sortQueues takes a array of queues and return a sorted array.
// It returns a new array and leave the original array untouched.
function sortQueues(
queues: QueueWithMetadata[],
cmpFn: (first: QueueWithMetadata, second: QueueWithMetadata) => number
): QueueWithMetadata[] {
let copy = [...queues];
copy.sort(cmpFn);
return copy;
}
export default function QueuesOverviewTable(props: Props) {
const classes = useStyles();
const [sortBy, setSortBy] = useState<SortBy>(SortBy.Queue);
const [sortDir, setSortDir] = useState<SortDirection>(SortDirection.Asc);
const total = getAggregateCounts(props.queues);
const createSortClickHandler = (sortKey: SortBy) => (e: React.MouseEvent) => {
if (sortKey === sortBy) {
// Toggle sort direction.
const nextSortDir =
sortDir === SortDirection.Asc ? SortDirection.Desc : SortDirection.Asc;
setSortDir(nextSortDir);
} else {
// Change the sort key.
setSortBy(sortKey);
}
};
const cmpFunc = (q1: QueueWithMetadata, q2: QueueWithMetadata): number => {
let isQ1Smaller: boolean;
switch (sortBy) {
case SortBy.Queue:
if (q1.queue === q2.queue) return 0;
isQ1Smaller = q1.queue < q2.queue;
break;
case SortBy.Size:
if (q1.size === q2.size) return 0;
isQ1Smaller = q1.size < q2.size;
break;
case SortBy.Active:
if (q1.active === q2.active) return 0;
isQ1Smaller = q1.active < q2.active;
break;
case SortBy.Pending:
if (q1.pending === q2.pending) return 0;
isQ1Smaller = q1.pending < q2.pending;
break;
case SortBy.Scheduled:
if (q1.scheduled === q2.scheduled) return 0;
isQ1Smaller = q1.scheduled < q2.scheduled;
break;
case SortBy.Retry:
if (q1.retry === q2.retry) return 0;
isQ1Smaller = q1.retry < q2.retry;
break;
case SortBy.Dead:
if (q1.dead === q2.dead) return 0;
isQ1Smaller = q1.dead < q2.dead;
break;
case SortBy.Processed:
if (q1.processed === q2.processed) return 0;
isQ1Smaller = q1.processed < q2.processed;
break;
case SortBy.Succeeded:
const q1Succeeded = q1.processed - q1.failed;
const q2Succeeded = q2.processed - q2.failed;
if (q1Succeeded === q2Succeeded) return 0;
isQ1Smaller = q1Succeeded < q2Succeeded;
break;
case SortBy.Failed:
if (q1.failed === q2.failed) return 0;
isQ1Smaller = q1.failed < q2.failed;
break;
default:
// eslint-disable-next-line no-throw-literal
throw `Unexpected order by value: ${sortBy}`;
}
if (sortDir === SortDirection.Asc) {
return isQ1Smaller ? -1 : 1;
} else {
return isQ1Smaller ? 1 : -1;
}
};
return (
<TableContainer>
<Table className={classes.table} aria-label="queues overview table">
<TableHead>
<TableRow>
{columnConfig.map((cfg, i) => (
<TableCell
key={cfg.key}
align={i === 0 ? "left" : "right"}
className={clsx(i === 0 && classes.fixedCell)}
>
<TableSortLabel
active={sortBy === cfg.sortBy}
direction={sortDir}
onClick={createSortClickHandler(cfg.sortBy)}
>
{cfg.label}
</TableSortLabel>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{sortQueues(props.queues, cmpFunc).map((q) => (
<TableRow key={q.queue}>
<TableCell
component="th"
scope="row"
className={clsx(classes.boldCell, classes.fixedCell)}
>
<Link to={queueDetailsPath(q.queue)}>
{q.queue}
{q.paused ? " (paused)" : ""}
</Link>
</TableCell>
<TableCell align="right" className={classes.boldCell}>
{q.size}
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "active")}
className={classes.linkCell}
>
{q.active}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "pending")}
className={classes.linkCell}
>
{q.pending}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "scheduled")}
className={classes.linkCell}
>
{q.scheduled}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "retry")}
className={classes.linkCell}
>
{q.retry}
</Link>
</TableCell>
<TableCell align="right">
<Link
to={queueDetailsPath(q.queue, "dead")}
className={classes.linkCell}
>
{q.dead}
</Link>
</TableCell>
<TableCell align="right" className={classes.boldCell}>
{q.processed}
</TableCell>
<TableCell align="right">{q.processed - q.failed}</TableCell>
<TableCell align="right">{q.failed}</TableCell>
{/* <TableCell align="right">
{q.paused ? (
<IconButton
color="secondary"
onClick={() => props.onResumeClick(q.queue)}
disabled={q.pauseRequestPending}
>
<PlayCircleFilledIcon />
</IconButton>
) : (
<IconButton
color="primary"
onClick={() => props.onPauseClick(q.queue)}
disabled={q.pauseRequestPending}
>
<PauseCircleFilledIcon />
</IconButton>
)}
</TableCell> */}
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className={clsx(classes.fixedCell, classes.footerCell)}>
Total
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.size}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.active}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.pending}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.scheduled}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.retry}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.dead}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.processed}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.succeeded}
</TableCell>
<TableCell className={classes.footerCell} align="right">
{total.failed}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
interface AggregateCounts {
size: number;
active: number;
pending: number;
scheduled: number;
retry: number;
dead: number;
processed: number;
succeeded: number;
failed: number;
}
function getAggregateCounts(queues: Queue[]): AggregateCounts {
let total = {
size: 0,
active: 0,
pending: 0,
scheduled: 0,
retry: 0,
dead: 0,
processed: 0,
succeeded: 0,
failed: 0,
};
queues.forEach((q) => {
total.size += q.size;
total.active += q.active;
total.pending += q.pending;
total.scheduled += q.scheduled;
total.retry += q.retry;
total.dead += q.dead;
total.processed += q.processed;
total.succeeded += q.processed - q.failed;
total.failed += q.failed;
});
return total;
}

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listRetryTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { RetryTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { durationBefore } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.retryTasks.loading,
tasks: state.tasks.retryTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listRetryTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of scheduled tasks.
}
function RetryTasksTable(props: Props & ReduxProps) {
const { pollInterval, listRetryTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listRetryTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listRetryTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listRetryTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No retry tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Retry In" },
{ label: "Last Error" },
{ label: "Retried" },
{ label: "Max Retry" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="retry tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: RetryTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell>
<TableCell>{task.error_message}</TableCell>
<TableCell>{task.retried}</TableCell>
<TableCell>{task.max_retry}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={9}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(RetryTasksTable);

View File

@@ -0,0 +1,197 @@
import React, { useEffect, useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import { makeStyles } from "@material-ui/core/styles";
import Table from "@material-ui/core/Table";
import TableBody from "@material-ui/core/TableBody";
import TableCell from "@material-ui/core/TableCell";
import Button from "@material-ui/core/Button";
import TableContainer from "@material-ui/core/TableContainer";
import TableHead from "@material-ui/core/TableHead";
import TableRow from "@material-ui/core/TableRow";
import TableFooter from "@material-ui/core/TableFooter";
import TablePagination from "@material-ui/core/TablePagination";
import Paper from "@material-ui/core/Paper";
import Box from "@material-ui/core/Box";
import Collapse from "@material-ui/core/Collapse";
import IconButton from "@material-ui/core/IconButton";
import KeyboardArrowUpIcon from "@material-ui/icons/KeyboardArrowUp";
import KeyboardArrowDownIcon from "@material-ui/icons/KeyboardArrowDown";
import Typography from "@material-ui/core/Typography";
import Alert from "@material-ui/lab/Alert";
import AlertTitle from "@material-ui/lab/AlertTitle";
import SyntaxHighlighter from "react-syntax-highlighter";
import syntaxHighlightStyle from "react-syntax-highlighter/dist/esm/styles/hljs/github";
import { listScheduledTasksAsync } from "../actions/tasksActions";
import { AppState } from "../store";
import { ScheduledTask } from "../api";
import TablePaginationActions, {
defaultPageSize,
rowsPerPageOptions,
} from "./TablePaginationActions";
import { durationBefore } from "../timeutil";
const useStyles = makeStyles({
table: {
minWidth: 650,
},
});
function mapStateToProps(state: AppState) {
return {
loading: state.tasks.scheduledTasks.loading,
tasks: state.tasks.scheduledTasks.data,
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { listScheduledTasksAsync };
const connector = connect(mapStateToProps, mapDispatchToProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string; // name of the queue.
totalTaskCount: number; // totoal number of scheduled tasks.
}
function ScheduledTasksTable(props: Props & ReduxProps) {
const { pollInterval, listScheduledTasksAsync, queue } = props;
const classes = useStyles();
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(defaultPageSize);
const handleChangePage = (
event: React.MouseEvent<HTMLButtonElement> | null,
newPage: number
) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setPageSize(parseInt(event.target.value, 10));
setPage(0);
};
useEffect(() => {
const pageOpts = { page: page + 1, size: pageSize };
listScheduledTasksAsync(queue, pageOpts);
const interval = setInterval(() => {
listScheduledTasksAsync(queue, pageOpts);
}, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listScheduledTasksAsync, queue, page, pageSize]);
if (props.tasks.length === 0) {
return (
<Alert severity="info">
<AlertTitle>Info</AlertTitle>
No scheduled tasks at this time.
</Alert>
);
}
const columns = [
{ label: "" },
{ label: "ID" },
{ label: "Type" },
{ label: "Process In" },
{ label: "Actions" },
];
return (
<TableContainer component={Paper}>
<Table
stickyHeader={true}
className={classes.table}
aria-label="scheduled tasks table"
size="small"
>
<TableHead>
<TableRow>
{columns.map((col) => (
<TableCell key={col.label}>{col.label}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{props.tasks.map((task) => (
<Row key={task.id} task={task} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePagination
rowsPerPageOptions={rowsPerPageOptions}
colSpan={columns.length}
count={props.totalTaskCount}
rowsPerPage={pageSize}
page={page}
SelectProps={{
inputProps: { "aria-label": "rows per page" },
native: true,
}}
onChangePage={handleChangePage}
onChangeRowsPerPage={handleChangeRowsPerPage}
ActionsComponent={TablePaginationActions}
/>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
);
}
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset",
},
},
});
function Row(props: { task: ScheduledTask }) {
const { task } = props;
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
return (
<React.Fragment>
<TableRow key={task.id} className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{task.id}
</TableCell>
<TableCell>{task.type}</TableCell>
<TableCell>{durationBefore(task.next_process_at)}</TableCell>
<TableCell>
<Button>Cancel</Button>
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={5}>
<Collapse in={open} timeout="auto" unmountOnExit>
<Box margin={1}>
<Typography variant="h6" gutterBottom component="div">
Payload
</Typography>
<SyntaxHighlighter language="json" style={syntaxHighlightStyle}>
{JSON.stringify(task.payload, null, 2)}
</SyntaxHighlighter>
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
}
export default connector(ScheduledTasksTable);

View File

@@ -0,0 +1,107 @@
import React from "react";
import {
useTheme,
makeStyles,
Theme,
createStyles,
} from "@material-ui/core/styles";
import IconButton from "@material-ui/core/IconButton";
import FirstPageIcon from "@material-ui/icons/FirstPage";
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft";
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight";
import LastPageIcon from "@material-ui/icons/LastPage";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
flexShrink: 0,
marginLeft: theme.spacing(2.5),
},
})
);
interface TablePaginationActionsProps {
count: number;
page: number;
rowsPerPage: number;
onChangePage: (
event: React.MouseEvent<HTMLButtonElement>,
newPage: number
) => void;
}
function TablePaginationActions(props: TablePaginationActionsProps) {
const classes = useStyles();
const theme = useTheme();
const { count, page, rowsPerPage, onChangePage } = props;
const handleFirstPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, 0);
};
const handleBackButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, page - 1);
};
const handleNextButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, page + 1);
};
const handleLastPageButtonClick = (
event: React.MouseEvent<HTMLButtonElement>
) => {
onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1));
};
return (
<div className={classes.root}>
<IconButton
onClick={handleFirstPageButtonClick}
disabled={page === 0}
aria-label="first page"
>
{theme.direction === "rtl" ? <LastPageIcon /> : <FirstPageIcon />}
</IconButton>
<IconButton
onClick={handleBackButtonClick}
disabled={page === 0}
aria-label="previous page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowRight />
) : (
<KeyboardArrowLeft />
)}
</IconButton>
<IconButton
onClick={handleNextButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="next page"
>
{theme.direction === "rtl" ? (
<KeyboardArrowLeft />
) : (
<KeyboardArrowRight />
)}
</IconButton>
<IconButton
onClick={handleLastPageButtonClick}
disabled={page >= Math.ceil(count / rowsPerPage) - 1}
aria-label="last page"
>
{theme.direction === "rtl" ? <FirstPageIcon /> : <LastPageIcon />}
</IconButton>
</div>
);
}
export default TablePaginationActions;
export const rowsPerPageOptions = [10, 20, 30, 60, 100];
export const defaultPageSize = 20;

View File

@@ -0,0 +1,332 @@
import React from "react";
import { connect, ConnectedProps } from "react-redux";
import styled from "styled-components";
import { makeStyles } from "@material-ui/core/styles";
import Tabs from "@material-ui/core/Tabs";
import Tab from "@material-ui/core/Tab";
import ActiveTasksTable from "./ActiveTasksTable";
import PendingTasksTable from "./PendingTasksTable";
import ScheduledTasksTable from "./ScheduledTasksTable";
import RetryTasksTable from "./RetryTasksTable";
import DeadTasksTable from "./DeadTasksTable";
import { useHistory } from "react-router-dom";
import { queueDetailsPath } from "../paths";
import { Typography } from "@material-ui/core";
import Paper from "@material-ui/core/Paper/Paper";
import { QueueInfo } from "../reducers/queuesReducer";
import { AppState } from "../store";
interface TabPanelProps {
children?: React.ReactNode;
selected: string; // currently selected value
value: string; // tab panel will be shown if selected value equals to the value
}
const TabPanelRoot = styled.div`
flex: 1;
overflow-y: scroll;
`;
function TabPanel(props: TabPanelProps) {
const { children, value, selected, ...other } = props;
return (
<TabPanelRoot
role="tabpanel"
hidden={value !== selected}
id={`scrollable-auto-tabpanel-${selected}`}
aria-labelledby={`scrollable-auto-tab-${selected}`}
{...other}
>
{value === selected && children}
</TabPanelRoot>
);
}
function a11yProps(value: string) {
return {
id: `scrollable-auto-tab-${value}`,
"aria-controls": `scrollable-auto-tabpanel-${value}`,
};
}
const Container = styled.div`
display: flex;
width: 100%;
height: 100%;
`;
const TaskCount = styled.div`
font-size: 2rem;
font-weight: 600;
margin: 0;
`;
const Heading = styled.div`
opacity: 0.7;
font-size: 1.7rem;
font-weight: 500;
background: #f5f7f9;
padding-left: 28px;
padding-top: 28px;
padding-bottom: 28px;
`;
const PanelContainer = styled.div`
padding: 24px;
background: #ffffff;
`;
const TabsContainer = styled.div`
background: #f5f7f9;
`;
const useStyles = makeStyles((theme) => ({
paper: {
padding: theme.spacing(2),
marginBottom: theme.spacing(2),
display: "flex",
justifyContent: "space-between",
},
heading: {
padingLeft: theme.spacing(2),
},
tabsRoot: {
paddingLeft: theme.spacing(2),
background: theme.palette.background.default,
},
tabsIndicator: {
right: "auto",
left: "0",
},
tabroot: {
width: "204px",
textAlign: "left",
padding: theme.spacing(2),
},
tabwrapper: {
alignItems: "flex-start",
},
tabSelected: {
background: theme.palette.common.white,
boxShadow: theme.shadows[1],
},
}));
function PanelHeading(props: {
queue: string;
processed: number;
failed: number;
paused: boolean;
}) {
const classes = useStyles();
return (
<Paper className={classes.paper}>
<div>
<Typography variant="overline" display="block">
Queue Name
</Typography>
<Typography variant="h5">{props.queue}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Processed Today (UTC)
</Typography>
<Typography variant="h5">{props.processed}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Failed Today (UTC)
</Typography>
<Typography variant="h5">{props.failed}</Typography>
</div>
<div>
<Typography variant="overline" display="block">
Paused
</Typography>
<Typography variant="h5">{props.paused ? "YES" : "No"}</Typography>
</div>
</Paper>
);
}
function mapStatetoProps(state: AppState, ownProps: Props) {
// TODO: Add loading state for each queue.
const queueInfo = state.queues.data.find(
(q: QueueInfo) => q.name === ownProps.queue
);
const currentStats = queueInfo
? queueInfo.currentStats
: {
queue: ownProps.queue,
paused: false,
size: 0,
active: 0,
pending: 0,
scheduled: 0,
retry: 0,
dead: 0,
processed: 0,
failed: 0,
timestamp: "n/a",
};
return { currentStats };
}
const connector = connect(mapStatetoProps);
type ReduxProps = ConnectedProps<typeof connector>;
interface Props {
queue: string;
selected: string;
}
function TasksTable(props: Props & ReduxProps) {
const { currentStats } = props;
const classes = useStyles();
const history = useHistory();
return (
<Container>
<TabsContainer>
<Heading>Tasks</Heading>
<Tabs
value={props.selected}
onChange={(_, value: string) =>
history.push(queueDetailsPath(props.queue, value))
}
aria-label="tasks table"
orientation="vertical"
classes={{ root: classes.tabsRoot, indicator: classes.tabsIndicator }}
>
<Tab
value="active"
label="Active"
icon={<TaskCount>{currentStats.active}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("active")}
/>
<Tab
value="pending"
label="Pending"
icon={<TaskCount>{currentStats.pending}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("pending")}
/>
<Tab
value="scheduled"
label="Scheduled"
icon={<TaskCount>{currentStats.scheduled}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("scheduled")}
/>
<Tab
value="retry"
label="Retry"
icon={<TaskCount>{currentStats.retry}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("retry")}
/>
<Tab
value="dead"
label="Dead"
icon={<TaskCount>{currentStats.dead}</TaskCount>}
classes={{
root: classes.tabroot,
wrapper: classes.tabwrapper,
selected: classes.tabSelected,
}}
{...a11yProps("dead")}
/>
</Tabs>
</TabsContainer>
<TabPanel value="active" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<ActiveTasksTable queue={props.queue} />
</PanelContainer>
</TabPanel>
<TabPanel value="pending" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<PendingTasksTable
queue={props.queue}
totalTaskCount={currentStats.pending}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="scheduled" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<ScheduledTasksTable
queue={props.queue}
totalTaskCount={currentStats.scheduled}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="retry" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<RetryTasksTable
queue={props.queue}
totalTaskCount={currentStats.retry}
/>
</PanelContainer>
</TabPanel>
<TabPanel value="dead" selected={props.selected}>
<PanelContainer>
<PanelHeading
queue={props.queue}
processed={currentStats.processed}
failed={currentStats.failed}
paused={currentStats.paused}
/>
<DeadTasksTable
queue={props.queue}
totalTaskCount={currentStats.dead}
/>
</PanelContainer>
</TabPanel>
</Container>
);
}
export default connector(TasksTable);

View File

@@ -0,0 +1,13 @@
import { Theme, withStyles } from "@material-ui/core/styles";
import Tooltip from "@material-ui/core/Tooltip";
// Export custom style tooltip.
export default withStyles((theme: Theme) => ({
tooltip: {
backgroundColor: "#f5f5f9",
color: "rgba(0, 0, 0, 0.87)",
maxWidth: 400,
fontSize: theme.typography.pxToRem(12),
border: "1px solid #dadde9",
},
}))(Tooltip);

27
ui/src/index.tsx Normal file
View File

@@ -0,0 +1,27 @@
import React from "react";
import ReactDOM from "react-dom";
import CssBaseline from "@material-ui/core/CssBaseline";
import { Provider } from "react-redux";
import { ThemeProvider } from "@material-ui/core/styles";
import App from "./App";
import store from "./store";
import theme from "./theme";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(
<React.StrictMode>
<CssBaseline />
<Provider store={store}>
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
</Provider>
</React.StrictMode>,
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
// TODO(hibiken): Look into this.
serviceWorker.unregister();

14
ui/src/paths.ts Normal file
View File

@@ -0,0 +1,14 @@
export const paths = {
HOME: "/",
SETTINGS: "/settings",
CRON: "/cron",
QUEUE_DETAILS: "/queues/:qname",
};
export function queueDetailsPath(qname: string, taskStatus?: string): string {
const path = paths.QUEUE_DETAILS.replace(":qname", qname);
if (taskStatus) {
return `${path}?status=${taskStatus}`;
}
return path;
}

1
ui/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,143 @@
import {
LIST_QUEUES_SUCCESS,
LIST_QUEUES_BEGIN,
QueuesActionTypes,
PAUSE_QUEUE_BEGIN,
PAUSE_QUEUE_SUCCESS,
PAUSE_QUEUE_ERROR,
RESUME_QUEUE_BEGIN,
RESUME_QUEUE_ERROR,
RESUME_QUEUE_SUCCESS,
GET_QUEUE_SUCCESS,
} from "../actions/queuesActions";
import {
LIST_ACTIVE_TASKS_SUCCESS,
LIST_DEAD_TASKS_SUCCESS,
LIST_PENDING_TASKS_SUCCESS,
LIST_RETRY_TASKS_SUCCESS,
LIST_SCHEDULED_TASKS_SUCCESS,
TasksActionTypes,
} from "../actions/tasksActions";
import { DailyStat, Queue } from "../api";
interface QueuesState {
loading: boolean;
data: QueueInfo[];
}
export interface QueueInfo {
name: string; // name of the queue.
currentStats: Queue;
history: DailyStat[];
pauseRequestPending: boolean; // indicates pause/resume action is pending on this queue
}
const initialState: QueuesState = { data: [], loading: false };
function queuesReducer(
state = initialState,
action: QueuesActionTypes | TasksActionTypes
): QueuesState {
switch (action.type) {
case LIST_QUEUES_BEGIN:
return { ...state, loading: true };
case LIST_QUEUES_SUCCESS:
const { queues } = action.payload;
return {
...state,
loading: false,
data: queues.map((q: Queue) => ({
name: q.queue,
currentStats: q,
history: [],
pauseRequestPending: false,
})),
};
case GET_QUEUE_SUCCESS:
const newData = state.data
.filter((queueInfo) => queueInfo.name !== action.queue)
.concat({
name: action.queue,
currentStats: action.payload.current,
history: action.payload.history,
pauseRequestPending: false,
});
return { ...state, data: newData };
case PAUSE_QUEUE_BEGIN:
case RESUME_QUEUE_BEGIN: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return { ...queueInfo, pauseRequestPending: true };
});
return { ...state, data: newData };
}
case PAUSE_QUEUE_SUCCESS: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
currentStats: { ...queueInfo.currentStats, paused: true },
};
});
return { ...state, data: newData };
}
case RESUME_QUEUE_SUCCESS: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
currentStats: { ...queueInfo.currentStats, paused: false },
};
});
return { ...state, data: newData };
}
case PAUSE_QUEUE_ERROR:
case RESUME_QUEUE_ERROR: {
const newData = state.data.map((queueInfo) => {
if (queueInfo.name !== action.queue) {
return queueInfo;
}
return {
...queueInfo,
pauseRequestPending: false,
};
});
return { ...state, data: newData };
}
case LIST_ACTIVE_TASKS_SUCCESS:
case LIST_PENDING_TASKS_SUCCESS:
case LIST_SCHEDULED_TASKS_SUCCESS:
case LIST_RETRY_TASKS_SUCCESS:
case LIST_DEAD_TASKS_SUCCESS: {
const newData = state.data
.filter((queueInfo) => queueInfo.name !== action.queue)
.concat({
name: action.queue,
currentStats: action.payload.stats,
history: [],
pauseRequestPending: false,
});
return { ...state, data: newData };
}
default:
return state;
}
}
export default queuesReducer;

View File

@@ -0,0 +1,26 @@
import {
POLL_INTERVAL_CHANGE,
SettingsActionTypes,
} from "../actions/settingsActions";
interface SettingsState {
pollInterval: number;
}
const initialState: SettingsState = {
pollInterval: 8,
};
function settingsReducer(
state = initialState,
action: SettingsActionTypes
): SettingsState {
switch (action.type) {
case POLL_INTERVAL_CHANGE:
return { ...state, pollInterval: action.value };
default:
return state;
}
}
export default settingsReducer;

View File

@@ -0,0 +1,243 @@
import {
LIST_ACTIVE_TASKS_BEGIN,
LIST_ACTIVE_TASKS_SUCCESS,
LIST_ACTIVE_TASKS_ERROR,
TasksActionTypes,
LIST_PENDING_TASKS_BEGIN,
LIST_PENDING_TASKS_SUCCESS,
LIST_PENDING_TASKS_ERROR,
LIST_SCHEDULED_TASKS_BEGIN,
LIST_SCHEDULED_TASKS_SUCCESS,
LIST_SCHEDULED_TASKS_ERROR,
LIST_RETRY_TASKS_BEGIN,
LIST_RETRY_TASKS_SUCCESS,
LIST_RETRY_TASKS_ERROR,
LIST_DEAD_TASKS_BEGIN,
LIST_DEAD_TASKS_SUCCESS,
LIST_DEAD_TASKS_ERROR,
} from "../actions/tasksActions";
import {
ActiveTask,
DeadTask,
PendingTask,
RetryTask,
ScheduledTask,
} from "../api";
interface TasksState {
activeTasks: {
loading: boolean;
error: string;
data: ActiveTask[];
};
pendingTasks: {
loading: boolean;
error: string;
data: PendingTask[];
};
scheduledTasks: {
loading: boolean;
error: string;
data: ScheduledTask[];
};
retryTasks: {
loading: boolean;
error: string;
data: RetryTask[];
};
deadTasks: {
loading: boolean;
error: string;
data: DeadTask[];
};
}
const initialState: TasksState = {
activeTasks: {
loading: false,
error: "",
data: [],
},
pendingTasks: {
loading: false,
error: "",
data: [],
},
scheduledTasks: {
loading: false,
error: "",
data: [],
},
retryTasks: {
loading: false,
error: "",
data: [],
},
deadTasks: {
loading: false,
error: "",
data: [],
},
};
function tasksReducer(
state = initialState,
action: TasksActionTypes
): TasksState {
switch (action.type) {
case LIST_ACTIVE_TASKS_BEGIN:
return {
...state,
activeTasks: {
...state.activeTasks,
error: "",
loading: true,
},
};
case LIST_ACTIVE_TASKS_SUCCESS:
return {
...state,
activeTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_ACTIVE_TASKS_ERROR:
return {
...state,
activeTasks: {
...state.activeTasks,
loading: false,
error: action.error,
},
};
case LIST_PENDING_TASKS_BEGIN:
return {
...state,
pendingTasks: {
...state.pendingTasks,
error: "",
loading: true,
},
};
case LIST_PENDING_TASKS_SUCCESS:
return {
...state,
pendingTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_PENDING_TASKS_ERROR:
return {
...state,
pendingTasks: {
...state.pendingTasks,
loading: false,
error: action.error,
},
};
case LIST_SCHEDULED_TASKS_BEGIN:
return {
...state,
scheduledTasks: {
...state.scheduledTasks,
error: "",
loading: true,
},
};
case LIST_SCHEDULED_TASKS_SUCCESS:
return {
...state,
scheduledTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_SCHEDULED_TASKS_ERROR:
return {
...state,
scheduledTasks: {
...state.scheduledTasks,
loading: false,
error: action.error,
},
};
case LIST_RETRY_TASKS_BEGIN:
return {
...state,
retryTasks: {
...state.retryTasks,
error: "",
loading: true,
},
};
case LIST_RETRY_TASKS_SUCCESS:
return {
...state,
retryTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_RETRY_TASKS_ERROR:
return {
...state,
retryTasks: {
...state.retryTasks,
loading: false,
error: action.error,
},
};
case LIST_DEAD_TASKS_BEGIN:
return {
...state,
deadTasks: {
...state.deadTasks,
error: "",
loading: true,
},
};
case LIST_DEAD_TASKS_SUCCESS:
return {
...state,
deadTasks: {
loading: false,
error: "",
data: action.payload.tasks,
},
};
case LIST_DEAD_TASKS_ERROR:
return {
...state,
deadTasks: {
...state.deadTasks,
loading: false,
error: action.error,
},
};
default:
return state;
}
}
export default tasksReducer;

149
ui/src/serviceWorker.ts Normal file
View File

@@ -0,0 +1,149 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(
process.env.PUBLIC_URL,
window.location.href
);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

5
ui/src/setupTests.ts Normal file
View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

17
ui/src/store.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import settingsReducer from "./reducers/settingsReducer";
import queuesReducer from "./reducers/queuesReducer";
import tasksReducer from "./reducers/tasksReducer";
const rootReducer = combineReducers({
settings: settingsReducer,
queues: queuesReducer,
tasks: tasksReducer,
});
// AppState is the top-level application state maintained by redux store.
export type AppState = ReturnType<typeof rootReducer>;
export default configureStore({
reducer: rootReducer,
});

18
ui/src/theme.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { createMuiTheme } from "@material-ui/core/styles";
// Got color palette from https://htmlcolors.com/palette/31/stripe
const theme = createMuiTheme({
palette: {
primary: {
main: "#4379FF",
},
secondary: {
main: "#97FBD1",
},
background: {
default: "#f5f7f9",
},
},
});
export default theme;

53
ui/src/timeutil.ts Normal file
View File

@@ -0,0 +1,53 @@
interface Duration {
hour: number;
minute: number;
second: number;
totalSeconds: number;
}
// start and end are in milliseconds.
function durationBetween(start: number, end: number): Duration {
const durationInMillisec = start - end;
const totalSeconds = Math.floor(durationInMillisec / 1000);
const hour = Math.floor(totalSeconds / 3600);
const minute = Math.floor((totalSeconds - 3600 * hour) / 60);
const second = totalSeconds - 3600 * hour - 60 * minute;
return { hour, minute, second, totalSeconds };
}
function stringifyDuration(d: Duration): string {
return (
(d.hour !== 0 ? `${d.hour}h` : "") +
(d.minute !== 0 ? `${d.minute}m` : "") +
`${d.second}s`
);
}
export function durationBefore(timestamp: string): string {
try {
const duration = durationBetween(Date.parse(timestamp), Date.now());
if (duration.totalSeconds < 1) {
return "now";
}
return stringifyDuration(duration);
} catch {
return "-";
}
}
export function timeAgo(timestamp: string): string {
try {
const duration = durationBetween(Date.now(), Date.parse(timestamp));
return stringifyDuration(duration) + " ago";
} catch {
return "-";
}
}
export function getCurrentUTCDate(): string {
const today = new Date();
const dd = today.getUTCDate().toString().padStart(2, "0");
const mm = (today.getMonth() + 1).toString().padStart(2, "0");
const yyyy = today.getFullYear();
return `${yyyy}-${mm}-${dd}`;
}

11
ui/src/views/CronView.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from "react";
function CronView() {
return (
<div>
<h2>Cron</h2>
</div>
);
}
export default CronView;

View File

@@ -0,0 +1,179 @@
import React, { useEffect } from "react";
import { connect, ConnectedProps } from "react-redux";
import Container from "@material-ui/core/Container";
import { makeStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import InfoIcon from "@material-ui/icons/Info";
import {
listQueuesAsync,
pauseQueueAsync,
resumeQueueAsync,
} from "../actions/queuesActions";
import { AppState } from "../store";
import QueueSizeChart from "../components/QueueSizeChart";
import ProcessedTasksChart from "../components/ProcessedTasksChart";
import QueuesOverviewTable from "../components/QueuesOverviewTable";
import Tooltip from "../components/Tooltip";
import { getCurrentUTCDate } from "../timeutil";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
paper: {
padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column",
},
chartHeader: {
display: "flex",
alignItems: "center",
marginBottom: theme.spacing(2),
},
chartContainer: {
width: "100%",
height: "300px",
},
infoIcon: {
marginLeft: theme.spacing(1),
color: theme.palette.grey[500],
cursor: "pointer",
},
tooltipSection: {
marginBottom: "4px",
},
}));
function mapStateToProps(state: AppState) {
return {
loading: state.queues.loading,
queues: state.queues.data.map((q) => ({
...q.currentStats,
pauseRequestPending: q.pauseRequestPending,
})),
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = {
listQueuesAsync,
pauseQueueAsync,
resumeQueueAsync,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = ConnectedProps<typeof connector>;
function DashboardView(props: Props) {
const { pollInterval, listQueuesAsync, queues } = props;
const classes = useStyles();
useEffect(() => {
listQueuesAsync();
const interval = setInterval(listQueuesAsync, pollInterval * 1000);
return () => clearInterval(interval);
}, [pollInterval, listQueuesAsync]);
const processedStats = queues.map((q) => ({
queue: q.queue,
succeeded: q.processed - q.failed,
failed: q.failed,
}));
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={3}>
<Grid item xs={6}>
<Paper className={classes.paper} variant="outlined">
<div className={classes.chartHeader}>
<Typography variant="h6">Queue Size</Typography>
<Tooltip
title={
<div>
<div className={classes.tooltipSection}>
Total number of tasks in the queue
</div>
<div className={classes.tooltipSection}>
<strong>Active</strong>: number of tasks currently being
processed
</div>
<div className={classes.tooltipSection}>
<strong>Pending</strong>: number of tasks ready to be
processed
</div>
<div className={classes.tooltipSection}>
<strong>Scheduled</strong>: number of tasks scheduled to
be processed in the future
</div>
<div className={classes.tooltipSection}>
<strong>Retry</strong>: number of tasks scheduled to be
retried in the future
</div>
<div>
<strong>Dead</strong>: number of tasks exhausted their
retries
</div>
</div>
}
>
<InfoIcon fontSize="small" className={classes.infoIcon} />
</Tooltip>
</div>
<div className={classes.chartContainer}>
<QueueSizeChart data={queues} />
</div>
</Paper>
</Grid>
<Grid item xs={6}>
<Paper className={classes.paper} variant="outlined">
<div className={classes.chartHeader}>
<Typography variant="h6">Tasks Processed</Typography>
<Tooltip
title={
<div>
<div className={classes.tooltipSection}>
Total number of tasks processed today (
{getCurrentUTCDate()} UTC)
</div>
<div className={classes.tooltipSection}>
<strong>Succeeded</strong>: number of tasks successfully
processed from the queue
</div>
<div>
<strong>Failed</strong>: number of tasks failed to be
processed from the queue
</div>
</div>
}
>
<InfoIcon fontSize="small" className={classes.infoIcon} />
</Tooltip>
</div>
<div className={classes.chartContainer}>
<ProcessedTasksChart data={processedStats} />
</div>
</Paper>
</Grid>
<Grid item xs={12}>
<Paper className={classes.paper} variant="outlined">
{/* TODO: Add loading indicator */}
<QueuesOverviewTable
queues={queues}
onPauseClick={props.pauseQueueAsync}
onResumeClick={props.resumeQueueAsync}
/>
</Paper>
</Grid>
</Grid>
</Container>
);
}
export default connector(DashboardView);

View File

@@ -0,0 +1,78 @@
import React, { useState } from "react";
import { connect, ConnectedProps } from "react-redux";
import Container from "@material-ui/core/Container";
import { makeStyles } from "@material-ui/core/styles";
import Grid from "@material-ui/core/Grid";
import Paper from "@material-ui/core/Paper";
import { Typography } from "@material-ui/core";
import Slider from "@material-ui/core/Slider/Slider";
import { pollIntervalChange } from "../actions/settingsActions";
import { AppState } from "../store";
const useStyles = makeStyles((theme) => ({
container: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
},
paper: {
padding: theme.spacing(2),
display: "flex",
overflow: "auto",
flexDirection: "column",
},
}));
function mapStateToProps(state: AppState) {
return {
pollInterval: state.settings.pollInterval,
};
}
const mapDispatchToProps = { pollIntervalChange };
const connector = connect(mapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
function SettingsView(props: PropsFromRedux) {
const classes = useStyles();
const [sliderValue, setSliderValue] = useState(props.pollInterval);
const handleSliderValueChange = (event: any, val: number | number[]) => {
setSliderValue(val as number);
};
const handleSliderValueCommited = (event: any, val: number | number[]) => {
props.pollIntervalChange(val as number);
};
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={3}>
<Grid item xs={12}>
<Typography variant="h5">Settings</Typography>
</Grid>
<Grid item xs={12}>
<Paper className={classes.paper} variant="outlined">
<Typography gutterBottom color="primary">
Polling Interval (Every {sliderValue} seconds)
</Typography>
<Slider
value={sliderValue}
onChange={handleSliderValueChange}
onChangeCommitted={handleSliderValueCommited}
aria-labelledby="continuous-slider"
valueLabelDisplay="auto"
step={1}
marks={true}
min={2}
max={20}
/>
</Paper>
</Grid>
</Grid>
</Container>
);
}
export default connector(SettingsView);

View File

@@ -0,0 +1,55 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import Container from "@material-ui/core/Container";
import Grid from "@material-ui/core/Grid";
import TasksTable from "../components/TasksTable";
import { useParams, useLocation } from "react-router-dom";
const useStyles = makeStyles((theme) => ({
container: {
paddingLeft: 0,
marginLeft: 0,
height: "100%",
},
gridContainer: {
height: "100%",
paddingBottom: 0,
},
gridItem: {
height: "100%",
paddingBottom: 0,
},
}));
function useQuery(): URLSearchParams {
return new URLSearchParams(useLocation().search);
}
interface RouteParams {
qname: string;
}
const validStatus = ["active", "pending", "scheduled", "retry", "dead"];
const defaultStatus = "active";
function TasksView() {
const classes = useStyles();
const { qname } = useParams<RouteParams>();
const query = useQuery();
let selected = query.get("status");
if (!selected || !validStatus.includes(selected)) {
selected = defaultStatus;
}
return (
<Container maxWidth="lg" className={classes.container}>
<Grid container spacing={0} className={classes.gridContainer}>
<Grid item xs={12} className={classes.gridItem}>
<TasksTable queue={qname} selected={selected} />
</Grid>
</Grid>
</Container>
);
}
export default TasksView;