From 5b2352d22503979893f1b6623c8318a2525e8c5f Mon Sep 17 00:00:00 2001 From: Scott Dickerson Date: Thu, 20 Jun 2024 13:08:56 -0400 Subject: [PATCH] :sparkles: Implement the Task Manager drawer (#1955) Resolves: #1938 Add a queued tasks count badge and a task list drawer. - Add a `TaskManagerContext` to control the count indicator and visibility of the drawer. This is a top level context so the task manager is available on all pages. The context itself monitors the task queue report endpoint[^1] that drives the notification badge count. - Use the standard `NotificationDrawer` attached to the `Page` layout to render the task manager item drawer. - The `TaskNotificationBadge` is integrated to the application's `HeaderApp`. - The `TaskManagerDrawer` shows the list of currently "queued" tasks[^2] in task id descending sort order (most recently created tasks on the top). - Each task item is rendered in a "collapsed" state and after a click will render "expanded". The expanded state include the started timestamp. - **_Note 1:_** Until fetch more / infinite scroll is implemented in the task manager drawer list, only the first 20 queued tasks will be displayed. - **_Note 2:_** Task actions need to be added to the task items. These should reuse the same set of actions as the task table in #1957. Related changes: - Update the `HeaderApp` to handle visibility of masthead toolbar items at the `ToolbarGroup` level. - Rename `SSOMenu` to `SsoToolbarItem`. - Updated the typing on a few task related API functions. [^1]: https://github.com/konveyor/tackle2-hub/issues/641 [^2]: https://github.com/konveyor/tackle2-hub/pull/640 --------- Signed-off-by: Scott J Dickerson --- client/src/app/App.tsx | 9 +- client/src/app/api/models.ts | 13 + client/src/app/api/rest.ts | 44 +-- .../task-manager/TaskManagerContext.tsx | 48 ++++ .../task-manager/TaskManagerDrawer.css | 3 + .../task-manager/TaskManagerDrawer.tsx | 256 ++++++++++++++++++ .../task-manager/TaskNotificaitonBadge.tsx | 22 ++ client/src/app/dayjs.ts | 4 + .../layout/DefaultLayout/DefaultLayout.tsx | 21 +- client/src/app/layout/HeaderApp/HeaderApp.tsx | 43 ++- client/src/app/layout/HeaderApp/SSOMenu.tsx | 93 ------- .../app/layout/HeaderApp/SsoToolbarItem.tsx | 87 ++++++ .../__snapshots__/HeaderApp.test.tsx.snap | 104 ++++++- client/src/app/queries/tasks.ts | 43 ++- 14 files changed, 653 insertions(+), 137 deletions(-) create mode 100644 client/src/app/components/task-manager/TaskManagerContext.tsx create mode 100644 client/src/app/components/task-manager/TaskManagerDrawer.css create mode 100644 client/src/app/components/task-manager/TaskManagerDrawer.tsx create mode 100644 client/src/app/components/task-manager/TaskNotificaitonBadge.tsx delete mode 100644 client/src/app/layout/HeaderApp/SSOMenu.tsx create mode 100644 client/src/app/layout/HeaderApp/SsoToolbarItem.tsx diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 3414477eda..084e59d413 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom"; import { AppRoutes } from "./Routes"; import { DefaultLayout } from "./layout"; import { NotificationsProvider } from "./components/NotificationsContext"; +import { TaskManagerProvider } from "./components/task-manager/TaskManagerContext"; import "./app.css"; @@ -11,9 +12,11 @@ const App: React.FC = () => { return ( - - - + + + + + ); diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 63feb1e2cd..06a700f1e0 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -413,6 +413,19 @@ export interface Taskgroup { tasks: TaskgroupTask[]; } +export interface TaskQueue { + /** Total number of tasks scheduled */ + total: number; + /** number of tasks ready to run */ + ready: number; + /** number of postponed tasks */ + postponed: number; + /** number of tasks with pods created awaiting node scheduler */ + pending: number; + /** number of tasks with running pods */ + running: number; +} + export interface Cache { path: string; capacity: string; diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 1bc6977225..bbc5f77d8d 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -1,27 +1,36 @@ import axios, { AxiosPromise, RawAxiosRequestHeaders } from "axios"; import { + AnalysisAppDependency, + AnalysisAppReport, AnalysisDependency, - BaseAnalysisRuleReport, - BaseAnalysisIssueReport, - AnalysisIssue, AnalysisFileReport, AnalysisIncident, + AnalysisIssue, Application, ApplicationAdoptionPlan, ApplicationDependency, ApplicationImport, ApplicationImportSummary, + Archetype, Assessment, + BaseAnalysisIssueReport, + BaseAnalysisRuleReport, BusinessService, Cache, + HubFile, HubPaginatedResult, HubRequestParams, Identity, + InitialAssessment, IReadFile, - Tracker, JobFunction, + MigrationWave, + MimeType, + New, Proxy, + Questionnaire, + Ref, Review, Setting, SettingTypes, @@ -29,23 +38,15 @@ import { StakeholderGroup, Tag, TagCategory, + Target, Task, Taskgroup, - MigrationWave, + TaskQueue, Ticket, - New, - Ref, + Tracker, TrackerProject, TrackerProjectIssuetype, UnstructuredFact, - AnalysisAppDependency, - AnalysisAppReport, - Target, - HubFile, - Questionnaire, - Archetype, - InitialAssessment, - MimeType, } from "./models"; import { serializeRequestParamsForHub } from "@app/hooks/table-controls"; @@ -332,7 +333,7 @@ export function getTaskById(id: number): Promise { export function getTaskByIdAndFormat( id: number, - format: string, + format: "json" | "yaml", merged: boolean = false ): Promise { const isYaml = format === "yaml"; @@ -345,7 +346,7 @@ export function getTaskByIdAndFormat( } return axios - .get(url, { + .get(url, { headers: headers, responseType: responseType, }) @@ -362,10 +363,15 @@ export const getTasks = () => export const getServerTasks = (params: HubRequestParams = {}) => getHubPaginatedResult(TASKS, params); -export const deleteTask = (id: number) => axios.delete(`${TASKS}/${id}`); +export const deleteTask = (id: number) => axios.delete(`${TASKS}/${id}`); export const cancelTask = (id: number) => - axios.put(`${TASKS}/${id}/cancel`); + axios.put(`${TASKS}/${id}/cancel`); + +export const getTaskQueue = (addon?: string): Promise => + axios + .get(`${TASKS}/report/queue`, { params: { addon } }) + .then(({ data }) => data); export const createTaskgroup = (obj: Taskgroup) => axios.post(TASKGROUPS, obj).then((response) => response.data); diff --git a/client/src/app/components/task-manager/TaskManagerContext.tsx b/client/src/app/components/task-manager/TaskManagerContext.tsx new file mode 100644 index 0000000000..69de666343 --- /dev/null +++ b/client/src/app/components/task-manager/TaskManagerContext.tsx @@ -0,0 +1,48 @@ +import { useFetchTaskQueue } from "@app/queries/tasks"; +import React, { useContext, useMemo, useState } from "react"; + +interface TaskManagerContextProps { + /** Count of the currently "queued" Tasks */ + queuedCount: number; + + /** Is the task manager drawer currently visible? */ + isExpanded: boolean; + + /** Control if the task manager drawer is visible */ + setIsExpanded: (value: boolean) => void; +} + +const TaskManagerContext = React.createContext({ + queuedCount: 0, + + isExpanded: false, + setIsExpanded: () => undefined, +}); + +export const useTaskManagerContext = () => { + const values = useContext(TaskManagerContext); + + return values; +}; + +export const TaskManagerProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { taskQueue } = useFetchTaskQueue(); + const [isExpanded, setIsExpanded] = useState(false); + + const contextValue = useMemo( + () => ({ + queuedCount: taskQueue.total, + isExpanded, + setIsExpanded, + }), + [taskQueue.total, isExpanded, setIsExpanded] + ); + + return ( + + {children} + + ); +}; diff --git a/client/src/app/components/task-manager/TaskManagerDrawer.css b/client/src/app/components/task-manager/TaskManagerDrawer.css new file mode 100644 index 0000000000..5550047fcc --- /dev/null +++ b/client/src/app/components/task-manager/TaskManagerDrawer.css @@ -0,0 +1,3 @@ +.task-manager-item-collapsed .pf-v5-c-notification-drawer__list-item-header { + margin-bottom: 0; +} diff --git a/client/src/app/components/task-manager/TaskManagerDrawer.tsx b/client/src/app/components/task-manager/TaskManagerDrawer.tsx new file mode 100644 index 0000000000..3836087f9e --- /dev/null +++ b/client/src/app/components/task-manager/TaskManagerDrawer.tsx @@ -0,0 +1,256 @@ +import React, { forwardRef, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import dayjs from "dayjs"; +import { + EmptyState, + EmptyStateActions, + EmptyStateBody, + EmptyStateFooter, + EmptyStateHeader, + EmptyStateIcon, + EmptyStateVariant, + NotificationDrawer, + NotificationDrawerBody, + NotificationDrawerHeader, + NotificationDrawerList, + NotificationDrawerListItem, + NotificationDrawerListItemBody, + NotificationDrawerListItemHeader, + Tooltip, +} from "@patternfly/react-core"; +import { + CubesIcon, + InProgressIcon, + PauseCircleIcon, + PendingIcon, + CheckCircleIcon, + TaskIcon, +} from "@patternfly/react-icons"; +import { css } from "@patternfly/react-styles"; + +import { TaskState } from "@app/api/models"; +import { useTaskManagerContext } from "./TaskManagerContext"; +import { useServerTasks } from "@app/queries/tasks"; + +import "./TaskManagerDrawer.css"; + +/** A version of `Task` specific for the task manager drawer components */ +interface TaskManagerTask { + id: number; + + createUser: string; + updateUser: string; + createTime: string; + started?: string; + terminated?: string; + + name: string; + kind: string; + addon: string; + extensions: string[]; + state: TaskState; + priority: number; + applicationId: number; + applicationName: string; + preemptEnabled: boolean; +} + +const PAGE_SIZE = 20; + +interface TaskManagerDrawerProps { + ref?: React.ForwardedRef; +} + +export const TaskManagerDrawer: React.FC = forwardRef( + (_props, ref) => { + const { isExpanded, setIsExpanded, queuedCount } = useTaskManagerContext(); + const { tasks } = useTaskManagerData(); + + const [expandedItems, setExpandedItems] = useState([]); + + const closeDrawer = () => { + setIsExpanded(!isExpanded); + setExpandedItems([]); + }; + + return ( + + + View All Tasks + + + {tasks.length == 0 ? ( + + } + /> + + No tasks are currently ready, postponed, blocked, pending or + running. Completed and cancelled tasks may be viewed on the full + task list. + + + + View All Tasks + + + + ) : ( + + {tasks.map((task) => ( + { + setExpandedItems( + expand + ? [...expandedItems, task.id] + : expandedItems.filter((i) => i !== task.id) + ); + }} + /> + ))} + + )} + + + ); + } +); +TaskManagerDrawer.displayName = "TaskManagerDrawer"; + +const TaskStateToIcon: React.FC<{ task: TaskManagerTask }> = ({ task }) => + task.state === "Ready" ? ( + + + + ) : task.state === "Postponed" ? ( + + + + ) : task.state === "Pending" ? ( + + + + ) : task.state === "Running" ? ( + + + + ) : ( + + + + ); + +const TaskItem: React.FC<{ + task: TaskManagerTask; + expanded: boolean; + onExpandToggle: (expand: boolean) => void; +}> = ({ task, expanded, onExpandToggle }) => { + const starttime = dayjs(task.started ?? task.createTime); + const title = expanded + ? `${task.id} (${task.addon})` + : `${task.id} (${task.addon}) - ${task.applicationName} - ${ + task.priority ?? 0 + }`; + + return ( + onExpandToggle(!expanded)} + > + } + > + {/* Put the item's action menu here */} + + {expanded ? ( + + {starttime.fromNow()} + + } + > +
{task.applicationName}
+ {/* TODO: Link to /applications with filter applied? */} +
Priority {task.priority}
+ {/* TODO: Bucket to Low, Medium, High? */} +
+ ) : undefined} +
+ ); +}; + +const useTaskManagerData = () => { + const [pageSize, setPageSize] = useState(PAGE_SIZE); + const increasePageSize = () => { + setPageSize(pageSize + PAGE_SIZE); + }; + + const { result, isFetching } = useServerTasks( + { + filters: [{ field: "state", operator: "=", value: "queued" }], + sort: { + field: "id", + direction: "desc", + }, + page: { + pageNumber: 1, + itemsPerPage: pageSize, + }, + }, + 5000 + ); + + const tasks: TaskManagerTask[] = useMemo( + () => + !result.data + ? [] + : result.data.map( + (task) => + ({ + id: task.id ?? -1, + createUser: task.createUser ?? "", + updateUser: task.updateUser ?? "", + createTime: task.createTime ?? "", + started: task.started ?? "", + terminated: task.terminated ?? "", + name: task.name, + kind: task.kind, + addon: task.addon, + extensions: task.extensions, + state: task.state ?? "", + priority: task.priority ?? 0, + applicationId: task.application.id, + applicationName: task.application.name, + preemptEnabled: task?.policy?.preemptEnabled ?? false, + + // TODO: Add any checks that could be needed later... + // - isCancelable (does the current user own the task? other things to check?) + // - isPreemptionToggleAllowed + }) as TaskManagerTask + ), + [result.data] + ); + + return { + tasks, + increasePageSize, + isFetching, + }; +}; diff --git a/client/src/app/components/task-manager/TaskNotificaitonBadge.tsx b/client/src/app/components/task-manager/TaskNotificaitonBadge.tsx new file mode 100644 index 0000000000..bda4471f82 --- /dev/null +++ b/client/src/app/components/task-manager/TaskNotificaitonBadge.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { NotificationBadge } from "@patternfly/react-core"; +import { useTaskManagerContext } from "./TaskManagerContext"; + +export const TaskNotificationBadge: React.FC = () => { + const { isExpanded, setIsExpanded, queuedCount } = useTaskManagerContext(); + + const badgeClick = () => { + setIsExpanded(!isExpanded); + }; + + return ( + 0 ? "unread" : "read"} + count={queuedCount} + onClick={badgeClick} + isExpanded={isExpanded} + /> + ); +}; diff --git a/client/src/app/dayjs.ts b/client/src/app/dayjs.ts index a86cf31f90..dac982eec3 100644 --- a/client/src/app/dayjs.ts +++ b/client/src/app/dayjs.ts @@ -3,8 +3,12 @@ import isSameOrBefore from "dayjs/plugin/isSameOrBefore"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; import customParseFormat from "dayjs/plugin/customParseFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; +import localizedFormat from "dayjs/plugin/localizedFormat"; dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(customParseFormat); dayjs.extend(isSameOrBefore); +dayjs.extend(relativeTime); +dayjs.extend(localizedFormat); diff --git a/client/src/app/layout/DefaultLayout/DefaultLayout.tsx b/client/src/app/layout/DefaultLayout/DefaultLayout.tsx index 7cb4ce9376..6d65293fc9 100644 --- a/client/src/app/layout/DefaultLayout/DefaultLayout.tsx +++ b/client/src/app/layout/DefaultLayout/DefaultLayout.tsx @@ -1,10 +1,12 @@ -import React from "react"; +import React, { useRef } from "react"; import { Page, SkipToContent } from "@patternfly/react-core"; import { HeaderApp } from "../HeaderApp"; import { SidebarApp } from "../SidebarApp"; import { Notifications } from "@app/components/Notifications"; import { PageContentWithDrawerProvider } from "@app/components/PageDrawerContext"; +import { TaskManagerDrawer } from "@app/components/task-manager/TaskManagerDrawer"; +import { useTaskManagerContext } from "@app/components/task-manager/TaskManagerContext"; export interface DefaultLayoutProps {} @@ -14,6 +16,20 @@ export const DefaultLayout: React.FC = ({ children }) => { Skip to content ); + const drawerRef = useRef(null); + const focusDrawer = () => { + if (drawerRef.current === null) { + return; + } + const firstTabbableItem = drawerRef.current.querySelector("a, button") as + | HTMLAnchorElement + | HTMLButtonElement + | null; + firstTabbableItem?.focus(); + }; + + const { isExpanded } = useTaskManagerContext(); + return ( } @@ -21,6 +37,9 @@ export const DefaultLayout: React.FC = ({ children }) => { isManagedSidebar skipToContent={PageSkipToContent} mainContainerId={pageId} + isNotificationDrawerExpanded={isExpanded} + notificationDrawer={} + onNotificationDrawerExpand={() => focusDrawer()} > {children} diff --git a/client/src/app/layout/HeaderApp/HeaderApp.tsx b/client/src/app/layout/HeaderApp/HeaderApp.tsx index a36705672a..1d6da7579a 100644 --- a/client/src/app/layout/HeaderApp/HeaderApp.tsx +++ b/client/src/app/layout/HeaderApp/HeaderApp.tsx @@ -19,8 +19,9 @@ import HelpIcon from "@patternfly/react-icons/dist/esm/icons/help-icon"; import BarsIcon from "@patternfly/react-icons/dist/js/icons/bars-icon"; import useBranding from "@app/hooks/useBranding"; +import { TaskNotificationBadge } from "@app/components/task-manager/TaskNotificaitonBadge"; import { AppAboutModalState } from "../AppAboutModalState"; -import { SSOMenu } from "./SSOMenu"; +import { SsoToolbarItem } from "./SsoToolbarItem"; import { MobileDropdown } from "./MobileDropdown"; import "./header.css"; @@ -33,9 +34,21 @@ export const HeaderApp: React.FC = () => { const toolbar = ( + {/* toolbar items to always show */} + + + + + + {/* toolbar items to show at desktop sizes */} + { - -