From 3befee382d8df09e1fe6e17983a9fffc04b13109 Mon Sep 17 00:00:00 2001 From: Ken Hibino Date: Fri, 30 Jul 2021 05:53:14 -0700 Subject: [PATCH] Add Task details view Allow users to find task by task ID --- conversion_helpers.go | 55 +++++ main.go | 2 + task_handlers.go | 32 +++ ui/src/App.tsx | 4 + ui/src/actions/tasksActions.ts | 47 +++- ui/src/api.ts | 24 +++ ui/src/components/ActiveTasksTable.tsx | 58 +++-- ui/src/components/ArchivedTasksTable.tsx | 54 +++-- ui/src/components/PendingTasksTable.tsx | 58 +++-- ui/src/components/QueueBreadcrumb.tsx | 7 +- ui/src/components/RetryTasksTable.tsx | 59 +++-- ui/src/components/ScheduledTasksTable.tsx | 58 +++-- ui/src/components/TasksTable.tsx | 68 +++++- ui/src/paths.ts | 22 ++ ui/src/reducers/tasksReducer.ts | 41 ++++ ui/src/views/TaskDetailsView.tsx | 251 ++++++++++++++++++++++ ui/src/views/TasksView.tsx | 9 +- 17 files changed, 750 insertions(+), 99 deletions(-) create mode 100644 ui/src/views/TaskDetailsView.tsx diff --git a/conversion_helpers.go b/conversion_helpers.go index 1208b1c..014502f 100644 --- a/conversion_helpers.go +++ b/conversion_helpers.go @@ -85,6 +85,61 @@ func toDailyStatsList(in []*asynq.DailyStats) []*DailyStats { return out } +type TaskInfo struct { + // ID is the identifier of the task. + ID string `json:"id"` + // Queue is the name of the queue in which the task belongs. + Queue string `json:"queue"` + // Type is the type name of the task. + Type string `json:"type"` + // Payload is the payload data of the task. + Payload string `json:"payload"` + // State indicates the task state. + State string `json:"state"` + // MaxRetry is the maximum number of times the task can be retried. + MaxRetry int `json:"max_retry"` + // Retried is the number of times the task has retried so far. + Retried int `json:"retried"` + // LastErr is the error message from the last failure. + LastErr string `json:"error_message"` + // LastFailedAt is the time time of the last failure in RFC3339 format. + // If the task has no failures, empty string. + LastFailedAt string `json:"last_failed_at"` + // Timeout is the number of seconds the task can be processed by Handler before being retried. + Timeout int `json:"timeout_seconds"` + // Deadline is the deadline for the task in RFC3339 format. If not set, empty string. + Deadline string `json:"deadline"` + // NextProcessAt is the time the task is scheduled to be processed in RFC3339 format. + // If not applicable, empty string. + NextProcessAt string `json:"next_process_at"` +} + +// formatTimeInRFC3339 formats t in RFC3339 if the value is non-zero. +// If t is zero time (i.e. time.Time{}), returns empty string +func formatTimeInRFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func toTaskInfo(info *asynq.TaskInfo) *TaskInfo { + return &TaskInfo{ + ID: info.ID, + Queue: info.Queue, + Type: info.Type, + Payload: toPrintablePayload(info.Payload), + State: info.State.String(), + MaxRetry: info.MaxRetry, + Retried: info.Retried, + LastErr: info.LastErr, + LastFailedAt: formatTimeInRFC3339(info.LastFailedAt), + Timeout: int(info.Timeout.Seconds()), + Deadline: formatTimeInRFC3339(info.Deadline), + NextProcessAt: formatTimeInRFC3339(info.NextProcessAt), + } +} + type BaseTask struct { ID string `json:"id"` Type string `json:"type"` diff --git a/main.go b/main.go index 82633d1..201f64e 100644 --- a/main.go +++ b/main.go @@ -197,6 +197,8 @@ func main() { api.HandleFunc("/queues/{qname}/archived_tasks:run_all", newRunAllArchivedTasksHandlerFunc(inspector)).Methods("POST") api.HandleFunc("/queues/{qname}/archived_tasks:batch_run", newBatchRunTasksHandlerFunc(inspector)).Methods("POST") + api.HandleFunc("/queues/{qname}/tasks/{task_id}", newGetTaskHandlerFunc(inspector)).Methods("GET") + // Servers endpoints. api.HandleFunc("/servers", newListServersHandlerFunc(inspector)).Methods("GET") diff --git a/task_handlers.go b/task_handlers.go index 0339cb0..3cc3add 100644 --- a/task_handlers.go +++ b/task_handlers.go @@ -2,9 +2,11 @@ package main import ( "encoding/json" + "errors" "log" "net/http" "strconv" + "strings" "time" "github.com/gorilla/mux" @@ -623,3 +625,33 @@ func getPageOptions(r *http.Request) (pageSize, pageNum int) { } return pageSize, pageNum } + +func newGetTaskHandlerFunc(inspector *asynq.Inspector) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + qname, taskid := vars["qname"], vars["task_id"] + if qname == "" { + http.Error(w, "queue name cannot be empty", http.StatusBadRequest) + return + } + if taskid == "" { + http.Error(w, "task_id cannot be empty", http.StatusBadRequest) + return + } + + info, err := inspector.GetTaskInfo(qname, taskid) + switch { + case errors.Is(err, asynq.ErrQueueNotFound), errors.Is(err, asynq.ErrTaskNotFound): + http.Error(w, strings.TrimPrefix(err.Error(), "asynq: "), http.StatusNotFound) + return + case err != nil: + http.Error(w, strings.TrimPrefix(err.Error(), "asynq: "), http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(toTaskInfo(info)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 10588ee..9d4567f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -33,6 +33,7 @@ import ListItemLink from "./components/ListItemLink"; import SchedulersView from "./views/SchedulersView"; import DashboardView from "./views/DashboardView"; import TasksView from "./views/TasksView"; +import TaskDetailsView from "./views/TaskDetailsView"; import SettingsView from "./views/SettingsView"; import ServersView from "./views/ServersView"; import RedisInfoView from "./views/RedisInfoView"; @@ -269,6 +270,9 @@ function App(props: ConnectedProps) {
+ + + diff --git a/ui/src/actions/tasksActions.ts b/ui/src/actions/tasksActions.ts index 3cc7195..6e01125 100644 --- a/ui/src/actions/tasksActions.ts +++ b/ui/src/actions/tasksActions.ts @@ -47,11 +47,16 @@ import { archivePendingTask, batchArchivePendingTasks, archiveAllPendingTasks, + TaskInfo, + getTaskInfo, } from "../api"; import { Dispatch } from "redux"; import { toErrorString, toErrorStringWithHttpStatus } from "../utils"; // List of tasks related action types. +export const GET_TASK_INFO_BEGIN = "GET_TASK_INFO_BEGIN"; +export const GET_TASK_INFO_SUCCESS = "GET_TASK_INFO_SUCCESS"; +export const GET_TASK_INFO_ERROR = "GET_TASK_INFO_ERROR"; 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"; @@ -80,9 +85,9 @@ export const BATCH_CANCEL_ACTIVE_TASKS_SUCCESS = "BATCH_CANCEL_ACTIVE_TASKS_SUCCESS"; export const BATCH_CANCEL_ACTIVE_TASKS_ERROR = "BATCH_CANCEL_ACTIVE_TASKS_ERROR"; -export const RUN_SCHEDULED_TASK_BEGIN = "RUN_ARCHIVED_TASK_BEGIN"; -export const RUN_SCHEDULED_TASK_SUCCESS = "RUN_ARCHIVED_TASK_SUCCESS"; -export const RUN_SCHEDULED_TASK_ERROR = "RUN_ARCHIVED_TASK_ERROR"; +export const RUN_SCHEDULED_TASK_BEGIN = "RUN_SCHEDULED_TASK_BEGIN"; +export const RUN_SCHEDULED_TASK_SUCCESS = "RUN_SCHEDULED_TASK_SUCCESS"; +export const RUN_SCHEDULED_TASK_ERROR = "RUN_SCHEDULED_TASK_ERROR"; export const RUN_RETRY_TASK_BEGIN = "RUN_RETRY_TASK_BEGIN"; export const RUN_RETRY_TASK_SUCCESS = "RUN_RETRY_TASK_SUCCESS"; export const RUN_RETRY_TASK_ERROR = "RUN_RETRY_TASK_ERROR"; @@ -209,6 +214,20 @@ export const DELETE_ALL_ARCHIVED_TASKS_SUCCESS = export const DELETE_ALL_ARCHIVED_TASKS_ERROR = "DELETE_ALL_ARCHIVED_TASKS_ERROR"; +interface GetTaskInfoBeginAction { + type: typeof GET_TASK_INFO_BEGIN; +} + +interface GetTaskInfoErrorAction { + type: typeof GET_TASK_INFO_ERROR; + error: string; // error description +} + +interface GetTaskInfoSuccessAction { + type: typeof GET_TASK_INFO_SUCCESS; + payload: TaskInfo; +} + interface ListActiveTasksBeginAction { type: typeof LIST_ACTIVE_TASKS_BEGIN; queue: string; @@ -894,6 +913,9 @@ interface DeleteAllArchivedTasksErrorAction { // Union of all tasks related action types. export type TasksActionTypes = + | GetTaskInfoBeginAction + | GetTaskInfoErrorAction + | GetTaskInfoSuccessAction | ListActiveTasksBeginAction | ListActiveTasksSuccessAction | ListActiveTasksErrorAction @@ -1009,6 +1031,25 @@ export type TasksActionTypes = | DeleteAllArchivedTasksSuccessAction | DeleteAllArchivedTasksErrorAction; +export function getTaskInfoAsync(qname: string, id: string) { + return async (dispatch: Dispatch) => { + dispatch({ type: GET_TASK_INFO_BEGIN }); + try { + const response = await getTaskInfo(qname, id); + dispatch({ + type: GET_TASK_INFO_SUCCESS, + payload: response, + }) + } catch (error) { + console.error("getTaskInfoAsync: ", toErrorStringWithHttpStatus(error)); + dispatch({ + type: GET_TASK_INFO_ERROR, + error: toErrorString(error), + }) + } + } +} + export function listActiveTasksAsync( qname: string, pageOpts?: PaginationOptions diff --git a/ui/src/api.ts b/ui/src/api.ts index 574dbcb..eb0bdb9 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -245,6 +245,21 @@ interface BaseTask { payload: string; } +export interface TaskInfo { + id: string; + queue: string; + type: string; + payload: string; + state: string; + max_retry: number; + retried: number; + last_failed_at: string; + error_message: string; + next_process_at: string; + timeout_seconds: number; + deadline: string; +} + export interface ActiveTask extends BaseTask { id: string; queue: string; @@ -369,6 +384,15 @@ export async function listQueueStats(): Promise { return resp.data; } +export async function getTaskInfo(qname: string, id: string): Promise { + const url = `${BASE_URL}/queues/${qname}/tasks/${id}`; + const resp = await axios({ + method: "get", + url, + }); + return resp.data; +} + export async function listActiveTasks( qname: string, pageOpts?: PaginationOptions diff --git a/ui/src/components/ActiveTasksTable.tsx b/ui/src/components/ActiveTasksTable.tsx index 3f4cc38..b5689c0 100644 --- a/ui/src/components/ActiveTasksTable.tsx +++ b/ui/src/components/ActiveTasksTable.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback } 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"; @@ -34,6 +35,7 @@ import { usePolling } from "../hooks"; import { ActiveTaskExtended } from "../reducers/tasksReducer"; import { durationBefore, timeAgo, uuidPrefix } from "../utils"; import { TableColumn } from "../types/table"; +import { taskDetailsPath } from "../paths"; const useStyles = makeStyles((theme) => ({ table: { @@ -189,14 +191,16 @@ function ActiveTasksTable(props: Props & ReduxProps) { padding="checkbox" classes={{ stickyHeader: classes.stickyHeaderCell }} > - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAllClick} - inputProps={{ - "aria-label": "select all tasks shown in the table", - }} - /> + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + {columns.map((col) => ( ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, +})); + interface RowProps { task: ActiveTaskExtended; isSelected: boolean; @@ -269,15 +285,24 @@ interface RowProps { function Row(props: RowProps) { const { task } = props; + const classes = useRowStyles(); + const history = useHistory(); return ( - - - ) => - props.onSelectChange(event.target.checked) - } - checked={props.isSelected} - /> + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + {uuidPrefix(task.id)} @@ -302,6 +327,7 @@ function Row(props: RowProps) { align="center" onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave} + onClick={(e) => e.stopPropagation()} > {props.showActions ? ( diff --git a/ui/src/components/ArchivedTasksTable.tsx b/ui/src/components/ArchivedTasksTable.tsx index b9ca6c4..68cc09e 100644 --- a/ui/src/components/ArchivedTasksTable.tsx +++ b/ui/src/components/ArchivedTasksTable.tsx @@ -1,4 +1,5 @@ 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"; @@ -38,6 +39,7 @@ import { timeAgo, uuidPrefix } from "../utils"; import { usePolling } from "../hooks"; import { ArchivedTaskExtended } from "../reducers/tasksReducer"; import { TableColumn } from "../types/table"; +import { taskDetailsPath } from "../paths"; const useStyles = makeStyles((theme) => ({ table: { @@ -216,14 +218,16 @@ function ArchivedTasksTable(props: Props & ReduxProps) { padding="checkbox" classes={{ stickyHeader: classes.stickyHeaderCell }} > - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAllClick} - inputProps={{ - "aria-label": "select all tasks shown in the table", - }} - /> + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + {columns.map((col) => ( ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, actionCell: { width: "96px", }, @@ -312,15 +325,23 @@ interface RowProps { function Row(props: RowProps) { const { task } = props; const classes = useRowStyles(); + const history = useHistory(); return ( - - - ) => - props.onSelectChange(event.target.checked) - } - checked={props.isSelected} - /> + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + {uuidPrefix(task.id)} @@ -341,6 +362,7 @@ function Row(props: RowProps) { className={classes.actionCell} onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave} + onClick={(e) => e.stopPropagation()} > {props.showActions ? ( diff --git a/ui/src/components/PendingTasksTable.tsx b/ui/src/components/PendingTasksTable.tsx index f5e7d75..990a8da 100644 --- a/ui/src/components/PendingTasksTable.tsx +++ b/ui/src/components/PendingTasksTable.tsx @@ -1,4 +1,5 @@ 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"; @@ -38,6 +39,7 @@ import { usePolling } from "../hooks"; import { uuidPrefix } from "../utils"; import { TableColumn } from "../types/table"; import { PendingTaskExtended } from "../reducers/tasksReducer"; +import { taskDetailsPath } from "../paths"; const useStyles = makeStyles((theme) => ({ table: { @@ -216,14 +218,16 @@ function PendingTasksTable(props: Props & ReduxProps) { padding="checkbox" classes={{ stickyHeader: classes.stickyHeaderCell }} > - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAllClick} - inputProps={{ - "aria-label": "select all tasks shown in the table", - }} - /> + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + {columns.map((col) => ( ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, actionCell: { width: "96px", }, @@ -297,7 +310,7 @@ const useRowStyles = makeStyles({ marginLeft: 3, marginRight: 3, }, -}); +})); interface RowProps { task: PendingTaskExtended; @@ -314,15 +327,23 @@ interface RowProps { function Row(props: RowProps) { const { task } = props; const classes = useRowStyles(); + const history = useHistory(); return ( - - - ) => - props.onSelectChange(event.target.checked) - } - checked={props.isSelected} - /> + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + {uuidPrefix(task.id)} @@ -343,6 +364,7 @@ function Row(props: RowProps) { className={classes.actionCell} onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave} + onClick={(e) => e.stopPropagation()} > {props.showActions ? ( diff --git a/ui/src/components/QueueBreadcrumb.tsx b/ui/src/components/QueueBreadcrumb.tsx index b3d31ca..4470630 100644 --- a/ui/src/components/QueueBreadcrumb.tsx +++ b/ui/src/components/QueueBreadcrumb.tsx @@ -31,7 +31,9 @@ interface Props { // All queue names. queues: string[]; // Name of the queue currently selected. - selectedQueue: string; + queueName: string; + // ID of the task currently selected (optional). + taskId?: string; } export default function QueueBreadcrumbs(props: Props) { @@ -57,11 +59,12 @@ export default function QueueBreadcrumbs(props: Props) { onClick={() => history.push(paths.HOME)} /> } onClick={handleClick} onDelete={handleClick} /> + {props.taskId && } ({ table: { @@ -246,14 +248,16 @@ function RetryTasksTable(props: Props & ReduxProps) { padding="checkbox" classes={{ stickyHeader: classes.stickyHeaderCell }} > - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAllClick} - inputProps={{ - "aria-label": "select all tasks shown in the table", - }} - /> + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + {columns.map((col) => ( ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, actionCell: { width: "140px", }, @@ -328,7 +341,7 @@ const useRowStyles = makeStyles({ marginLeft: 3, marginRight: 3, }, -}); +})); interface RowProps { task: RetryTaskExtended; @@ -346,15 +359,24 @@ interface RowProps { function Row(props: RowProps) { const { task } = props; const classes = useRowStyles(); + const history = useHistory(); + return ( - - - ) => - props.onSelectChange(event.target.checked) - } - checked={props.isSelected} - /> + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + {uuidPrefix(task.id)} @@ -377,6 +399,7 @@ function Row(props: RowProps) { className={classes.actionCell} onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave} + onClick={(e) => e.stopPropagation()} > {props.showActions ? ( diff --git a/ui/src/components/ScheduledTasksTable.tsx b/ui/src/components/ScheduledTasksTable.tsx index 7dffca1..204aed8 100644 --- a/ui/src/components/ScheduledTasksTable.tsx +++ b/ui/src/components/ScheduledTasksTable.tsx @@ -1,4 +1,5 @@ import React, { useState, useCallback } 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"; @@ -42,6 +43,7 @@ import { durationBefore, uuidPrefix } from "../utils"; import { usePolling } from "../hooks"; import { ScheduledTaskExtended } from "../reducers/tasksReducer"; import { TableColumn } from "../types/table"; +import { taskDetailsPath } from "../paths"; const useStyles = makeStyles((theme) => ({ table: { @@ -243,14 +245,16 @@ function ScheduledTasksTable(props: Props & ReduxProps) { padding="checkbox" classes={{ stickyHeader: classes.stickyHeaderCell }} > - 0 && numSelected < rowCount} - checked={rowCount > 0 && numSelected === rowCount} - onChange={handleSelectAllClick} - inputProps={{ - "aria-label": "select all tasks shown in the table", - }} - /> + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={handleSelectAllClick} + inputProps={{ + "aria-label": "select all tasks shown in the table", + }} + /> + {columns.map((col) => ( ({ + root: { + cursor: "pointer", + "&:hover": { + boxShadow: theme.shadows[2], + }, + "&:hover .MuiTableCell-root": { + borderBottomColor: theme.palette.background.paper, + }, + }, actionCell: { width: "140px", }, @@ -325,7 +338,7 @@ const useRowStyles = makeStyles({ marginLeft: 3, marginRight: 3, }, -}); +})); interface RowProps { task: ScheduledTaskExtended; @@ -343,15 +356,23 @@ interface RowProps { function Row(props: RowProps) { const { task } = props; const classes = useRowStyles(); + const history = useHistory(); return ( - - - ) => - props.onSelectChange(event.target.checked) - } - checked={props.isSelected} - /> + history.push(taskDetailsPath(task.queue, task.id))} + > + e.stopPropagation()}> + + ) => + props.onSelectChange(event.target.checked) + } + checked={props.isSelected} + /> + {uuidPrefix(task.id)} @@ -371,6 +392,7 @@ function Row(props: RowProps) { className={classes.actionCell} onMouseEnter={props.onActionCellEnter} onMouseLeave={props.onActionCellLeave} + onClick={(e) => e.stopPropagation()} > {props.showActions ? ( diff --git a/ui/src/components/TasksTable.tsx b/ui/src/components/TasksTable.tsx index e34186b..df92d01 100644 --- a/ui/src/components/TasksTable.tsx +++ b/ui/src/components/TasksTable.tsx @@ -1,16 +1,18 @@ -import React from "react"; +import React, { useState } from "react"; import { connect, ConnectedProps } from "react-redux"; import { makeStyles } from "@material-ui/core/styles"; import Typography from "@material-ui/core/Typography"; import Paper from "@material-ui/core/Paper"; import Chip from "@material-ui/core/Chip"; +import InputBase from "@material-ui/core/InputBase"; +import SearchIcon from "@material-ui/icons/Search"; import ActiveTasksTable from "./ActiveTasksTable"; import PendingTasksTable from "./PendingTasksTable"; import ScheduledTasksTable from "./ScheduledTasksTable"; import RetryTasksTable from "./RetryTasksTable"; import ArchivedTasksTable from "./ArchivedTasksTable"; import { useHistory } from "react-router-dom"; -import { queueDetailsPath } from "../paths"; +import { queueDetailsPath, taskDetailsPath } from "../paths"; import { QueueInfo } from "../reducers/queuesReducer"; import { AppState } from "../store"; import { isDarkTheme } from "../theme"; @@ -101,6 +103,38 @@ const useStyles = makeStyles((theme) => ({ borderRadius: "10px", marginLeft: "2px", }, + searchbar: { + marginLeft: theme.spacing(4), + }, + search: { + position: "relative", + width: "312px", + borderRadius: "18px", + backgroundColor: isDarkTheme(theme) ? "#303030" : theme.palette.grey[100], + "&:hover, &:focus": { + backgroundColor: isDarkTheme(theme) ? "#303030" : theme.palette.grey[200], + }, + }, + searchIcon: { + padding: theme.spacing(0, 2), + height: "100%", + position: "absolute", + pointerEvents: "none", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + inputRoot: { + color: "inherit", + width: "100%", + }, + inputInput: { + padding: theme.spacing(1, 1, 1, 0), + // vertical padding + font size from searchIcon + paddingLeft: `calc(1em + ${theme.spacing(4)}px)`, + width: "100%", + fontSize: "0.85rem", + }, })); function TasksTable(props: Props & ReduxProps) { @@ -115,6 +149,8 @@ function TasksTable(props: Props & ReduxProps) { { key: "archived", label: "Archived", count: currentStats.archived }, ]; + const [searchQuery, setSearchQuery] = useState(""); + return (
@@ -137,6 +173,34 @@ function TasksTable(props: Props & ReduxProps) { /> ))}
+
+
+
+ +
+ { + setSearchQuery(e.target.value); + }} + inputProps={{ + "aria-label": "search", + onKeyDown: (e) => { + if (e.key === "Enter") { + history.push( + taskDetailsPath(props.queue, searchQuery.trim()) + ); + } + }, + }} + /> +
+
diff --git a/ui/src/paths.ts b/ui/src/paths.ts index cf99c65..d97f361 100644 --- a/ui/src/paths.ts +++ b/ui/src/paths.ts @@ -5,8 +5,13 @@ export const paths = { SCHEDULERS: "/schedulers", QUEUE_DETAILS: "/queues/:qname", REDIS: "/redis", + TASK_DETAILS: "/queues/:qname/tasks/:taskId", }; +/************************************************************** + Path Helper functions + **************************************************************/ + export function queueDetailsPath(qname: string, taskStatus?: string): string { const path = paths.QUEUE_DETAILS.replace(":qname", qname); if (taskStatus) { @@ -14,3 +19,20 @@ export function queueDetailsPath(qname: string, taskStatus?: string): string { } return path; } + +export function taskDetailsPath(qname: string, taskId: string): string { + return paths.TASK_DETAILS.replace(":qname", qname).replace(":taskId", taskId); +} + +/************************************************************** + URL Params + **************************************************************/ + +export interface QueueDetailsRouteParams { + qname: string; +} + +export interface TaskDetailsRouteParams { + qname: string; + taskId: string; +} \ No newline at end of file diff --git a/ui/src/reducers/tasksReducer.ts b/ui/src/reducers/tasksReducer.ts index 1a4454e..13b0d26 100644 --- a/ui/src/reducers/tasksReducer.ts +++ b/ui/src/reducers/tasksReducer.ts @@ -114,6 +114,9 @@ import { BATCH_DELETE_PENDING_TASKS_SUCCESS, BATCH_ARCHIVE_PENDING_TASKS_ERROR, BATCH_DELETE_PENDING_TASKS_ERROR, + GET_TASK_INFO_BEGIN, + GET_TASK_INFO_ERROR, + GET_TASK_INFO_SUCCESS, } from "../actions/tasksActions"; import { ActiveTask, @@ -121,6 +124,7 @@ import { PendingTask, RetryTask, ScheduledTask, + TaskInfo, } from "../api"; export interface ActiveTaskExtended extends ActiveTask { @@ -193,6 +197,11 @@ interface TasksState { error: string; data: ArchivedTaskExtended[]; }; + taskInfo: { + loading: boolean; + error: string; + data?: TaskInfo; + }, } const initialState: TasksState = { @@ -231,6 +240,10 @@ const initialState: TasksState = { error: "", data: [], }, + taskInfo: { + loading: false, + error: "", + } }; function tasksReducer( @@ -238,6 +251,34 @@ function tasksReducer( action: TasksActionTypes ): TasksState { switch (action.type) { + case GET_TASK_INFO_BEGIN: + return { + ...state, + taskInfo: { + ...state.taskInfo, + loading: true, + }, + } + + case GET_TASK_INFO_ERROR: + return { + ...state, + taskInfo: { + loading: false, + error: action.error, + }, + }; + + case GET_TASK_INFO_SUCCESS: + return { + ...state, + taskInfo: { + loading: false, + error: "", + data: action.payload, + }, + }; + case LIST_ACTIVE_TASKS_BEGIN: return { ...state, diff --git a/ui/src/views/TaskDetailsView.tsx b/ui/src/views/TaskDetailsView.tsx new file mode 100644 index 0000000..9b5ac69 --- /dev/null +++ b/ui/src/views/TaskDetailsView.tsx @@ -0,0 +1,251 @@ +import React, { useMemo, useEffect } from "react"; +import { connect, ConnectedProps } from "react-redux"; +import { useHistory } from "react-router-dom"; +import { makeStyles } from "@material-ui/core/styles"; +import Container from "@material-ui/core/Container"; +import Grid from "@material-ui/core/Grid"; +import Paper from "@material-ui/core/Paper"; +import Typography from "@material-ui/core/Typography"; +import Button from "@material-ui/core/Button"; +import Alert from "@material-ui/lab/Alert"; +import AlertTitle from "@material-ui/lab/AlertTitle"; +import ArrowBackIcon from "@material-ui/icons/ArrowBack"; +import { useParams } from "react-router-dom"; +import QueueBreadCrumb from "../components/QueueBreadcrumb"; +import { AppState } from "../store"; +import { getTaskInfoAsync } from "../actions/tasksActions"; +import { TaskDetailsRouteParams } from "../paths"; +import { usePolling } from "../hooks"; +import { listQueuesAsync } from "../actions/queuesActions"; +import SyntaxHighlighter from "../components/SyntaxHighlighter"; + +function mapStateToProps(state: AppState) { + return { + loading: state.tasks.taskInfo.loading, + error: state.tasks.taskInfo.error, + taskInfo: state.tasks.taskInfo.data, + pollInterval: state.settings.pollInterval, + queues: state.queues.data.map((q) => q.name), // FIXME: This data may not be available + }; +} + +const connector = connect(mapStateToProps, { + getTaskInfoAsync, + listQueuesAsync, +}); + +const useStyles = makeStyles((theme) => ({ + container: { + paddingTop: theme.spacing(2), + }, + alert: { + borderTopLeftRadius: 0, + borderTopRightRadius: 0, + }, + paper: { + padding: theme.spacing(2), + marginTop: theme.spacing(2), + }, + breadcrumbs: { + marginBottom: theme.spacing(2), + }, + infoRow: { + display: "flex", + alignItems: "center", + paddingTop: theme.spacing(1), + }, + infoKeyCell: { + width: "140px", + }, + infoValueCell: { + width: "auto", + }, + footer: { + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + }, +})); + +type Props = ConnectedProps; + +function TaskDetailsView(props: Props) { + const classes = useStyles(); + const { qname, taskId } = useParams(); + const { getTaskInfoAsync, pollInterval, listQueuesAsync, taskInfo } = props; + const history = useHistory(); + + const fetchTaskInfo = useMemo(() => { + return () => { + getTaskInfoAsync(qname, taskId); + }; + }, [qname, taskId, getTaskInfoAsync]); + + usePolling(fetchTaskInfo, pollInterval); + + // Fetch queues data to populate props.queues + useEffect(() => { + listQueuesAsync(); + }, [listQueuesAsync]); + + return ( + + + + + + + {props.error ? ( + + Error + {props.error} + + ) : ( + + Task Info +
+
+ + ID:{" "} + + + {taskInfo?.id} + +
+
+ + Type:{" "} + + + {taskInfo?.type} + +
+
+ + State:{" "} + + + {taskInfo?.state} + +
+
+ + Queue:{" "} + + + {taskInfo?.queue} + +
+
+ + Retry:{" "} + + + {taskInfo?.retried}/{taskInfo?.max_retry} + +
+
+ + Last Failure:{" "} + + + {taskInfo?.last_failed_at ? ( + + {taskInfo?.error_message} ({taskInfo?.last_failed_at}) + + ) : ( + n/a + )} + +
+
+ + Next Process Time:{" "} + + {taskInfo?.next_process_at ? ( + {taskInfo?.next_process_at} + ) : ( + n/a + )} +
+
+
+ + Timeout:{" "} + + + {taskInfo?.timeout_seconds ? ( + {taskInfo?.timeout_seconds} seconds + ) : ( + n/a + )} + +
+
+ + Deadline:{" "} + + + {taskInfo?.deadline ? ( + {taskInfo?.deadline} + ) : ( + n/a + )} + +
+
+ + Payload:{" "} + +
+ {taskInfo?.payload && ( + + {taskInfo.payload} + + )} +
+
+
+ )} +
+ +
+
+
+
+ ); +} + +export default connector(TaskDetailsView); diff --git a/ui/src/views/TasksView.tsx b/ui/src/views/TasksView.tsx index f7179e8..23db498 100644 --- a/ui/src/views/TasksView.tsx +++ b/ui/src/views/TasksView.tsx @@ -9,6 +9,7 @@ import QueueBreadCrumb from "../components/QueueBreadcrumb"; import { useParams, useLocation } from "react-router-dom"; import { listQueuesAsync } from "../actions/queuesActions"; import { AppState } from "../store"; +import { QueueDetailsRouteParams } from "../paths"; function mapStateToProps(state: AppState) { return { @@ -37,16 +38,12 @@ function useQuery(): URLSearchParams { return new URLSearchParams(useLocation().search); } -interface RouteParams { - qname: string; -} - const validStatus = ["active", "pending", "scheduled", "retry", "archived"]; const defaultStatus = "active"; function TasksView(props: ConnectedProps) { const classes = useStyles(); - const { qname } = useParams(); + const { qname } = useParams(); const query = useQuery(); let selected = query.get("status"); if (!selected || !validStatus.includes(selected)) { @@ -62,7 +59,7 @@ function TasksView(props: ConnectedProps) { - +