diff --git a/conversion_helpers.go b/conversion_helpers.go index ea01249..486276f 100644 --- a/conversion_helpers.go +++ b/conversion_helpers.go @@ -67,6 +67,7 @@ type queueStateSnapshot struct { Scheduled int `json:"scheduled"` Retry int `json:"retry"` Archived int `json:"archived"` + Completed int `json:"completed"` // Total number of tasks processed during the given date. // The number includes both succeeded and failed tasks. @@ -91,6 +92,7 @@ func toQueueStateSnapshot(s *asynq.QueueInfo) *queueStateSnapshot { Scheduled: s.Scheduled, Retry: s.Retry, Archived: s.Archived, + Completed: s.Completed, Processed: s.Processed, Succeeded: s.Processed - s.Failed, Failed: s.Failed, diff --git a/ui/src/actions/tasksActions.ts b/ui/src/actions/tasksActions.ts index 6e01125..5ff2a6d 100644 --- a/ui/src/actions/tasksActions.ts +++ b/ui/src/actions/tasksActions.ts @@ -34,6 +34,8 @@ import { ListRetryTasksResponse, listScheduledTasks, ListScheduledTasksResponse, + listCompletedTasks, + ListCompletedTasksResponse, PaginationOptions, runAllArchivedTasks, runAllRetryTasks, @@ -72,6 +74,9 @@ export const LIST_RETRY_TASKS_ERROR = "LIST_RETRY_TASKS_ERROR"; export const LIST_ARCHIVED_TASKS_BEGIN = "LIST_ARCHIVED_TASKS_BEGIN"; export const LIST_ARCHIVED_TASKS_SUCCESS = "LIST_ARCHIVED_TASKS_SUCCESS"; export const LIST_ARCHIVED_TASKS_ERROR = "LIST_ARCHIVED_TASKS_ERROR"; +export const LIST_COMPLETED_TASKS_BEGIN = "LIST_COMPLETED_TASKS_BEGIN"; +export const LIST_COMPLETED_TASKS_SUCCESS = "LIST_COMPLETED_TASKS_SUCCESS"; +export const LIST_COMPLETED_TASKS_ERROR = "LIST_COMPLETED_TASKS_ERROR"; export const CANCEL_ACTIVE_TASK_BEGIN = "CANCEL_ACTIVE_TASK_BEGIN"; export const CANCEL_ACTIVE_TASK_SUCCESS = "CANCEL_ACTIVE_TASK_SUCCESS"; export const CANCEL_ACTIVE_TASK_ERROR = "CANCEL_ACTIVE_TASK_ERROR"; @@ -313,6 +318,23 @@ interface ListArchivedTasksErrorAction { error: string; // error description } +interface ListCompletedTasksBeginAction { + type: typeof LIST_COMPLETED_TASKS_BEGIN; + queue: string; +} + +interface ListCompletedTasksSuccessAction { + type: typeof LIST_COMPLETED_TASKS_SUCCESS; + queue: string; + payload: ListCompletedTasksResponse; +} + +interface ListCompletedTasksErrorAction { + type: typeof LIST_COMPLETED_TASKS_ERROR; + queue: string; + error: string; // error description +} + interface CancelActiveTaskBeginAction { type: typeof CANCEL_ACTIVE_TASK_BEGIN; queue: string; @@ -931,6 +953,9 @@ export type TasksActionTypes = | ListArchivedTasksBeginAction | ListArchivedTasksSuccessAction | ListArchivedTasksErrorAction + | ListCompletedTasksBeginAction + | ListCompletedTasksSuccessAction + | ListCompletedTasksErrorAction | CancelActiveTaskBeginAction | CancelActiveTaskSuccessAction | CancelActiveTaskErrorAction @@ -1185,6 +1210,30 @@ export function listArchivedTasksAsync( }; } +export function listCompletedTasksAsync(qname: string, pageOpts?: PaginationOptions) { + return async (dispatch: Dispatch) => { + try { + dispatch({ type: LIST_COMPLETED_TASKS_BEGIN, queue: qname }) + const response = await listCompletedTasks(qname, pageOpts); + dispatch({ + type: LIST_COMPLETED_TASKS_SUCCESS, + queue: qname, + payload: response, + }) + } catch (error) { + console.error( + "listCompletedTasksAsync: ", + toErrorStringWithHttpStatus(error) + ); + dispatch({ + type: LIST_COMPLETED_TASKS_ERROR, + queue: qname, + error: toErrorString(error) + }) + } + } +} + export function cancelActiveTaskAsync(queue: string, taskId: string) { return async (dispatch: Dispatch) => { dispatch({ type: CANCEL_ACTIVE_TASK_BEGIN, queue, taskId }); diff --git a/ui/src/api.ts b/ui/src/api.ts index 5b07d84..dcebed4 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -36,6 +36,11 @@ export interface ListArchivedTasksResponse { stats: Queue; } +export interface ListCompletedTasksResponse { + tasks: CompletedTask[]; + stats: Queue; +} + export interface ListServersResponse { servers: ServerInfo[]; } @@ -239,6 +244,7 @@ export interface Queue { scheduled: number; retry: number; archived: number; + completed: number, processed: number; failed: number; timestamp: string; @@ -317,6 +323,14 @@ export interface ArchivedTask extends BaseTask { error_message: string; } +export interface CompletedTask extends BaseTask { + id: string; + queue: string; + max_retry: number; + retried: number; + completed_at: string; +} + export interface ServerInfo { id: string; host: string; @@ -511,6 +525,18 @@ export async function listArchivedTasks( return resp.data; } +export async function listCompletedTasks(qname: string, pageOpts?: PaginationOptions): Promise { + let url = `${BASE_URL}/queues/${qname}/completed_tasks` + if (pageOpts) { + url += `?${queryString.stringify(pageOpts)}` + } + const resp = await axios({ + method: "get", + url, + }) + return resp.data +} + export async function archivePendingTask( qname: string, taskId: string diff --git a/ui/src/components/CompletedTasksTable.tsx b/ui/src/components/CompletedTasksTable.tsx new file mode 100644 index 0000000..0a6c7a2 --- /dev/null +++ b/ui/src/components/CompletedTasksTable.tsx @@ -0,0 +1,344 @@ +import React, { useCallback, useState } from "react"; +import { useHistory } from "react-router-dom"; +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 Checkbox from "@material-ui/core/Checkbox"; +import TableContainer from "@material-ui/core/TableContainer"; +import TableHead from "@material-ui/core/TableHead"; +import TableRow from "@material-ui/core/TableRow"; +import Tooltip from "@material-ui/core/Tooltip"; +import Paper from "@material-ui/core/Paper"; +import IconButton from "@material-ui/core/IconButton"; +import DeleteIcon from "@material-ui/icons/Delete"; +import MoreHorizIcon from "@material-ui/icons/MoreHoriz"; +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 "./SyntaxHighlighter"; +import { AppState } from "../store"; +import { listCompletedTasksAsync } from "../actions/tasksActions"; +import TablePaginationActions, { + rowsPerPageOptions, +} from "./TablePaginationActions"; +import { taskRowsPerPageChange } from "../actions/settingsActions"; +import TableActions from "./TableActions"; +import { timeAgo, uuidPrefix } from "../utils"; +import { usePolling } from "../hooks"; +import { CompletedTaskExtended } from "../reducers/tasksReducer"; +import { TableColumn } from "../types/table"; +import { taskDetailsPath } from "../paths"; + +const useStyles = makeStyles((theme) => ({ + table: { + minWidth: 650, + }, + stickyHeaderCell: { + background: theme.palette.background.paper, + }, + alert: { + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + pagination: { + border: "none", + }, +})); + +function mapStateToProps(state: AppState) { + return { + loading: state.tasks.completedTasks.loading, + error: state.tasks.completedTasks.error, + tasks: state.tasks.completedTasks.data, + batchActionPending: state.tasks.completedTasks.batchActionPending, + allActionPending: state.tasks.completedTasks.allActionPending, + pollInterval: state.settings.pollInterval, + pageSize: state.settings.taskRowsPerPage, + }; +} + +const mapDispatchToProps = { + listCompletedTasksAsync, + taskRowsPerPageChange, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +type ReduxProps = ConnectedProps; + +interface Props { + queue: string; // name of the queue. + totalTaskCount: number; // totoal number of completed tasks. +} + +function CompletedTasksTable(props: Props & ReduxProps) { + const { pollInterval, listCompletedTasksAsync, queue, pageSize } = props; + const classes = useStyles(); + const [page, setPage] = useState(0); + const [selectedIds, setSelectedIds] = useState([]); + const [activeTaskId, setActiveTaskId] = useState(""); + + const handleChangePage = ( + event: React.MouseEvent | null, + newPage: number + ) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = ( + event: React.ChangeEvent + ) => { + props.taskRowsPerPageChange(parseInt(event.target.value, 10)); + setPage(0); + }; + + const handleSelectAllClick = (event: React.ChangeEvent) => { + if (event.target.checked) { + const newSelected = props.tasks.map((t) => t.id); + setSelectedIds(newSelected); + } else { + setSelectedIds([]); + } + }; + + const fetchData = useCallback(() => { + const pageOpts = { page: page + 1, size: pageSize }; + listCompletedTasksAsync(queue, pageOpts); + }, [page, pageSize, queue, listCompletedTasksAsync]); + + usePolling(fetchData, pollInterval); + + if (props.error.length > 0) { + return ( + + Error + {props.error} + + ); + } + if (props.tasks.length === 0) { + return ( + + Info + No completed tasks at this time. + + ); + } + + const columns: TableColumn[] = [ + { key: "id", label: "ID", align: "left" }, + { key: "type", label: "Type", align: "left" }, + { key: "payload", label: "Payload", align: "left" }, + { key: "completed_at", label: "Completed Time", align: "left" }, + { key: "result", label: "Result", align: "left" }, + { key: "actions", label: "Actions", align: "center" }, + ]; + + const rowCount = props.tasks.length; + const numSelected = selectedIds.length; + return ( +
+ 0} + iconButtonActions={[ + { + tooltip: "Delete", + icon: , + onClick: () => { + /* TODO */ + }, + disabled: props.batchActionPending, + }, + ]} + menuItemActions={[ + { + label: "Delete All", + onClick: () => { + /* TODO */ + }, + disabled: props.allActionPending, + }, + ]} + /> + + + + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {props.tasks.map((task) => ( + { + if (checked) { + setSelectedIds(selectedIds.concat(task.id)); + } else { + setSelectedIds(selectedIds.filter((id) => id !== task.id)); + } + }} + onDeleteClick={() => { + // props.deleteCompletedTaskAsync(queue, task.id); + }} + allActionPending={props.allActionPending} + onActionCellEnter={() => setActiveTaskId(task.id)} + onActionCellLeave={() => setActiveTaskId("")} + showActions={activeTaskId === task.id} + /> + ))} + + + + + + +
+
+
+ ); +} + +const useRowStyles = makeStyles((theme) => ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, + actionCell: { + width: "96px", + }, + actionButton: { + marginLeft: 3, + marginRight: 3, + }, +})); + +interface RowProps { + task: CompletedTaskExtended; + isSelected: boolean; + onSelectChange: (checked: boolean) => void; + onDeleteClick: () => void; + allActionPending: boolean; + showActions: boolean; + onActionCellEnter: () => void; + onActionCellLeave: () => void; +} + +function Row(props: RowProps) { + const { task } = props; + const classes = useRowStyles(); + const history = useHistory(); + return ( + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + + + + {uuidPrefix(task.id)} + + {task.type} + + + {task.payload} + + + {timeAgo(task.completed_at)} + {"TODO: Result data here"} + e.stopPropagation()} + > + {props.showActions ? ( + + + + + + + + ) : ( + + + + )} + + + ); +} + +export default connector(CompletedTasksTable); diff --git a/ui/src/components/TasksTable.tsx b/ui/src/components/TasksTable.tsx index df92d01..81885fc 100644 --- a/ui/src/components/TasksTable.tsx +++ b/ui/src/components/TasksTable.tsx @@ -11,6 +11,7 @@ import PendingTasksTable from "./PendingTasksTable"; import ScheduledTasksTable from "./ScheduledTasksTable"; import RetryTasksTable from "./RetryTasksTable"; import ArchivedTasksTable from "./ArchivedTasksTable"; +import CompletedTasksTable from "./CompletedTasksTable"; import { useHistory } from "react-router-dom"; import { queueDetailsPath, taskDetailsPath } from "../paths"; import { QueueInfo } from "../reducers/queuesReducer"; @@ -56,6 +57,7 @@ function mapStatetoProps(state: AppState, ownProps: Props) { scheduled: 0, retry: 0, archived: 0, + completed: 0, processed: 0, failed: 0, timestamp: "n/a", @@ -147,6 +149,7 @@ function TasksTable(props: Props & ReduxProps) { { key: "scheduled", label: "Scheduled", count: currentStats.scheduled }, { key: "retry", label: "Retry", count: currentStats.retry }, { key: "archived", label: "Archived", count: currentStats.archived }, + { key: "completed", label: "Completed", count: currentStats.completed }, ]; const [searchQuery, setSearchQuery] = useState(""); @@ -229,6 +232,12 @@ function TasksTable(props: Props & ReduxProps) { totalTaskCount={currentStats.archived} /> + + + ); } diff --git a/ui/src/reducers/tasksReducer.ts b/ui/src/reducers/tasksReducer.ts index 13b0d26..e8b09ae 100644 --- a/ui/src/reducers/tasksReducer.ts +++ b/ui/src/reducers/tasksReducer.ts @@ -15,6 +15,9 @@ import { LIST_ARCHIVED_TASKS_BEGIN, LIST_ARCHIVED_TASKS_SUCCESS, LIST_ARCHIVED_TASKS_ERROR, + LIST_COMPLETED_TASKS_BEGIN, + LIST_COMPLETED_TASKS_SUCCESS, + LIST_COMPLETED_TASKS_ERROR, CANCEL_ACTIVE_TASK_BEGIN, CANCEL_ACTIVE_TASK_SUCCESS, CANCEL_ACTIVE_TASK_ERROR, @@ -121,6 +124,7 @@ import { import { ActiveTask, ArchivedTask, + CompletedTask, PendingTask, RetryTask, ScheduledTask, @@ -161,6 +165,12 @@ export interface ArchivedTaskExtended extends ArchivedTask { requestPending: boolean; } +export interface CompletedTaskExtended extends CompletedTask { + // Indicates that a request has been sent for this + // task and awaiting for a response. + requestPending: boolean; +} + interface TasksState { activeTasks: { loading: boolean; @@ -197,6 +207,13 @@ interface TasksState { error: string; data: ArchivedTaskExtended[]; }; + completedTasks: { + loading: boolean; + batchActionPending: boolean; + allActionPending: boolean; + error: string; + data: CompletedTaskExtended[]; + } taskInfo: { loading: boolean; error: string; @@ -240,6 +257,13 @@ const initialState: TasksState = { error: "", data: [], }, + completedTasks: { + loading: false, + batchActionPending: false, + allActionPending: false, + error: "", + data: [], + }, taskInfo: { loading: false, error: "", @@ -450,6 +474,40 @@ function tasksReducer( }, }; + case LIST_COMPLETED_TASKS_BEGIN: + return { + ...state, + completedTasks: { + ...state.completedTasks, + loading: true, + }, + }; + + case LIST_COMPLETED_TASKS_SUCCESS: + return { + ...state, + completedTasks: { + ...state.completedTasks, + loading: false, + error: "", + data: action.payload.tasks.map((task) => ({ + ...task, + requestPending: false, + })), + }, + }; + + case LIST_COMPLETED_TASKS_ERROR: + return { + ...state, + completedTasks: { + ...state.completedTasks, + loading: false, + error: action.error, + data: [], + }, + }; + case CANCEL_ACTIVE_TASK_BEGIN: { const newData = state.activeTasks.data.map((task) => { if (task.id !== action.taskId) { diff --git a/ui/src/views/TasksView.tsx b/ui/src/views/TasksView.tsx index 23db498..f6f88e1 100644 --- a/ui/src/views/TasksView.tsx +++ b/ui/src/views/TasksView.tsx @@ -38,7 +38,14 @@ function useQuery(): URLSearchParams { return new URLSearchParams(useLocation().search); } -const validStatus = ["active", "pending", "scheduled", "retry", "archived"]; +const validStatus = [ + "active", + "pending", + "scheduled", + "retry", + "archived", + "completed", +]; const defaultStatus = "active"; function TasksView(props: ConnectedProps) {