mirror of
https://github.com/hibiken/asynqmon.git
synced 2025-08-24 14:48:42 +08:00
Initial commit
This commit is contained in:
9
ui/src/App.test.tsx
Normal file
9
ui/src/App.test.tsx
Normal 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
194
ui/src/App.tsx
Normal 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;
|
158
ui/src/actions/queuesActions.ts
Normal file
158
ui/src/actions/queuesActions.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
17
ui/src/actions/settingsActions.ts
Normal file
17
ui/src/actions/settingsActions.ts
Normal 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,
|
||||
};
|
||||
}
|
246
ui/src/actions/tasksActions.ts
Normal file
246
ui/src/actions/tasksActions.ts
Normal 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
209
ui/src/api.ts
Normal 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;
|
||||
}
|
195
ui/src/components/ActiveTasksTable.tsx
Normal file
195
ui/src/components/ActiveTasksTable.tsx
Normal 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);
|
200
ui/src/components/DeadTasksTable.tsx
Normal file
200
ui/src/components/DeadTasksTable.tsx
Normal 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);
|
68
ui/src/components/ListItemLink.tsx
Normal file
68
ui/src/components/ListItemLink.tsx
Normal 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;
|
195
ui/src/components/PendingTasksTable.tsx
Normal file
195
ui/src/components/PendingTasksTable.tsx
Normal 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);
|
45
ui/src/components/ProcessedTasksChart.tsx
Normal file
45
ui/src/components/ProcessedTasksChart.tsx
Normal 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;
|
45
ui/src/components/QueueSizeChart.tsx
Normal file
45
ui/src/components/QueueSizeChart.tsx
Normal 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;
|
348
ui/src/components/QueuesOverviewTable.tsx
Normal file
348
ui/src/components/QueuesOverviewTable.tsx
Normal 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;
|
||||
}
|
204
ui/src/components/RetryTasksTable.tsx
Normal file
204
ui/src/components/RetryTasksTable.tsx
Normal 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);
|
197
ui/src/components/ScheduledTasksTable.tsx
Normal file
197
ui/src/components/ScheduledTasksTable.tsx
Normal 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);
|
107
ui/src/components/TablePaginationActions.tsx
Normal file
107
ui/src/components/TablePaginationActions.tsx
Normal 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;
|
332
ui/src/components/TasksTable.tsx
Normal file
332
ui/src/components/TasksTable.tsx
Normal 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);
|
13
ui/src/components/Tooltip.tsx
Normal file
13
ui/src/components/Tooltip.tsx
Normal 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
27
ui/src/index.tsx
Normal 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
14
ui/src/paths.ts
Normal 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
1
ui/src/react-app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
143
ui/src/reducers/queuesReducer.ts
Normal file
143
ui/src/reducers/queuesReducer.ts
Normal 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;
|
26
ui/src/reducers/settingsReducer.ts
Normal file
26
ui/src/reducers/settingsReducer.ts
Normal 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;
|
243
ui/src/reducers/tasksReducer.ts
Normal file
243
ui/src/reducers/tasksReducer.ts
Normal 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
149
ui/src/serviceWorker.ts
Normal 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
5
ui/src/setupTests.ts
Normal 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
17
ui/src/store.tsx
Normal 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
18
ui/src/theme.tsx
Normal 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
53
ui/src/timeutil.ts
Normal 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
11
ui/src/views/CronView.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
|
||||
function CronView() {
|
||||
return (
|
||||
<div>
|
||||
<h2>Cron</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CronView;
|
179
ui/src/views/DashboardView.tsx
Normal file
179
ui/src/views/DashboardView.tsx
Normal 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);
|
78
ui/src/views/SettingsView.tsx
Normal file
78
ui/src/views/SettingsView.tsx
Normal 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);
|
55
ui/src/views/TasksView.tsx
Normal file
55
ui/src/views/TasksView.tsx
Normal 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;
|
Reference in New Issue
Block a user