From cf8720144b00aba8ae54527913ce3fad1f858ebe Mon Sep 17 00:00:00 2001 From: Radoslaw Szwajkowski Date: Thu, 20 Jun 2024 20:32:25 +0200 Subject: [PATCH] :sparkles: Task table (#1957) Functional changes: 1. create task manager entry in the sidebar navigation list 2. place task page under /tasks route 3. present tasks in a server-filtered table 4. row actions supported: canceling, enable/disable preemption flag 5. use column management to hide optional columns: pod, started, terminated Related features: 1. make id property required in Task type 2. switch task update endpoint to use patch method 3. provide icon-to-state mapping that preserves original state names which are required for server filtering Resolves: https://github.com/konveyor/tackle2-ui/issues/1931 --------- Signed-off-by: Radoslaw Szwajkowski --- client/public/locales/en/translation.json | 20 +- client/src/app/Constants.ts | 1 + client/src/app/Paths.ts | 1 + client/src/app/Routes.tsx | 8 + client/src/app/api/models.ts | 3 +- client/src/app/api/rest.ts | 3 + client/src/app/components/Icons/index.ts | 1 + .../app/components/Icons/taskStateToIcon.tsx | 39 ++ .../src/app/layout/SidebarApp/SidebarApp.tsx | 5 + client/src/app/pages/tasks/tasks-page.tsx | 356 ++++++++++++++++++ client/src/app/pages/tasks/useTaskActions.tsx | 53 +++ client/src/app/queries/tasks.ts | 28 +- 12 files changed, 510 insertions(+), 8 deletions(-) create mode 100644 client/src/app/components/Icons/taskStateToIcon.tsx create mode 100644 client/src/app/pages/tasks/tasks-page.tsx create mode 100644 client/src/app/pages/tasks/useTaskActions.tsx diff --git a/client/public/locales/en/translation.json b/client/public/locales/en/translation.json index af9e7cc6f..bc5733294 100644 --- a/client/public/locales/en/translation.json +++ b/client/public/locales/en/translation.json @@ -29,12 +29,14 @@ "createTags": "Create tags", "cancelAnalysis": "Cancel analysis", "delete": "Delete", + "disablePreemption": "Disable preemption", "discardAssessment": "Discard assessment(s)", "discardReview": "Discard review", "downloadCsvTemplate": "Download CSV template", "download": "Download {{what}}", "duplicate": "Duplicate", "edit": "Edit", + "enablePreemption": "Enable preemption", "export": "Export", "filterBy": "Filter by {{what}}", "import": "Import", @@ -224,7 +226,11 @@ "noAnswers": "Are you sure you want to close the assessment? There are no answers to save.", "unlinkTicket": "Unlink from Jira", "noTagsAvailable": "No tags available", - "noAssociatedTags": "This tag category has no associated tags." + "noAssociatedTags": "This tag category has no associated tags.", + "updateFailed": "Update failed.", + "updateRequestSubmitted": "Update request submitted.", + "cancelationFailed": "Cancelation failed.", + "cancelationRequestSubmitted": "Cancelation request submitted" }, "proposedActions": { "refactor": "Refactor", @@ -249,7 +255,8 @@ "reports": "Reports", "migrationWaves": "Migration waves", "issues": "Issues", - "dependencies": "Dependencies" + "dependencies": "Dependencies", + "tasks": "Task Manager" }, "terms": { "accepted": "Accepted", @@ -367,6 +374,7 @@ "jobFunction": "Job function", "jobFunctionDeleted": "Job function deleted", "jobFunctions": "Job functions", + "kind": "Kind", "language": "Language", "label": "Label", "loading": "Loading", @@ -391,6 +399,8 @@ "notYetReviewed": "Not yet reviewed", "other": "Other", "owner": "Owner", + "pod": "Pod", + "preemption": "Preemption", "priority": "Priority", "proposedAction": "Proposed action", "proxyConfig": "Proxy configuration", @@ -434,6 +444,7 @@ "stakeholderGroups": "Stakeholder groups", "stakeholders": "Stakeholders", "startDate": "Start date", + "started": "Started", "status": "Status", "suggestedAdoptionPlan": "Suggested adoption plan", "svnConfig": "Subversion configuration", @@ -452,6 +463,7 @@ "tagCategoryDeleted": "Tag category deleted", "tagCategories": "Tag categories", "teamMember": "team member", + "terminated": "Terminated", "ticket": "Ticket", "trivialButMigratable": "Trivial but migratable", "type": "Type", @@ -468,7 +480,9 @@ "YAMLTemplate": "YAML template" }, "titles": { - "archetypeDrawer": "Archetype details" + "archetypeDrawer": "Archetype details", + "taskManager": "Task Manager", + "task": "Task" }, "toastr": { "success": { diff --git a/client/src/app/Constants.ts b/client/src/app/Constants.ts index 2633f1e29..80a622ee9 100644 --- a/client/src/app/Constants.ts +++ b/client/src/app/Constants.ts @@ -244,4 +244,5 @@ export enum TablePersistenceKeyPrefix { issuesRemainingIncidents = "ii", dependencyApplications = "da", archetypes = "ar", + tasks = "t", } diff --git a/client/src/app/Paths.ts b/client/src/app/Paths.ts index 92b7a0faf..7f3551cc8 100644 --- a/client/src/app/Paths.ts +++ b/client/src/app/Paths.ts @@ -40,6 +40,7 @@ export const DevPaths = { issuesSingleAppSelected: "/issues/single-app/:applicationId", dependencies: "/dependencies", + tasks: "/tasks", } as const; export type DevPathValues = (typeof DevPaths)[keyof typeof DevPaths]; diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx index 8a44ef977..ba970d233 100644 --- a/client/src/app/Routes.tsx +++ b/client/src/app/Routes.tsx @@ -63,6 +63,9 @@ const AssessmentSummary = lazy( "./pages/assessment/components/assessment-summary/assessment-summary-page" ) ); + +const TaskManager = lazy(() => import("./pages/tasks/tasks-page")); + export interface IRoute { path: T; comp: React.ComponentType; @@ -189,6 +192,11 @@ export const devRoutes: IRoute[] = [ comp: Archetypes, exact: false, }, + { + path: Paths.tasks, + comp: TaskManager, + exact: false, + }, ]; export const adminRoutes: IRoute[] = [ diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 06a700f1e..6297ed707 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -301,12 +301,13 @@ export type TaskState = | "Failed" | "Running" | "No task" + | "QuotaBlocked" | "Ready" | "Pending" | "Postponed"; export interface Task { - id?: number; + id: number; createUser?: string; updateUser?: string; createTime?: string; diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index bbc5f77d8..bb9a4b709 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -373,6 +373,9 @@ export const getTaskQueue = (addon?: string): Promise => .get(`${TASKS}/report/queue`, { params: { addon } }) .then(({ data }) => data); +export const updateTask = (task: Partial & { id: number }) => + axios.patch(`${TASKS}/${task.id}`, task); + export const createTaskgroup = (obj: Taskgroup) => axios.post(TASKGROUPS, obj).then((response) => response.data); diff --git a/client/src/app/components/Icons/index.ts b/client/src/app/components/Icons/index.ts index dbd719c6e..1ad916bfe 100644 --- a/client/src/app/components/Icons/index.ts +++ b/client/src/app/components/Icons/index.ts @@ -1,3 +1,4 @@ export * from "./OptionalTooltip"; export * from "./IconedStatus"; export * from "./IconWithLabel"; +export * from "./taskStateToIcon"; diff --git a/client/src/app/components/Icons/taskStateToIcon.tsx b/client/src/app/components/Icons/taskStateToIcon.tsx new file mode 100644 index 000000000..a4b616feb --- /dev/null +++ b/client/src/app/components/Icons/taskStateToIcon.tsx @@ -0,0 +1,39 @@ +import { TaskState } from "@app/api/models"; +import React from "react"; +import { Icon } from "@patternfly/react-core"; +import CheckCircleIcon from "@patternfly/react-icons/dist/esm/icons/check-circle-icon"; +import TimesCircleIcon from "@patternfly/react-icons/dist/esm/icons/times-circle-icon"; +import InProgressIcon from "@patternfly/react-icons/dist/esm/icons/in-progress-icon"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; +import UnknownIcon from "@patternfly/react-icons/dist/esm/icons/unknown-icon"; + +export const taskStateToIcon = (state?: TaskState) => { + switch (state) { + case "not supported": + case "No task": + return ; + case "Canceled": + return ; + case "Succeeded": + return ( + + + + ); + case "Failed": + return ( + + + + ); + case "Pending": + return ; + case "Created": + case "QuotaBlocked": + case "Running": + case "Ready": + case "Postponed": + default: + return <>; + } +}; diff --git a/client/src/app/layout/SidebarApp/SidebarApp.tsx b/client/src/app/layout/SidebarApp/SidebarApp.tsx index 0fff82fe1..2b2c6d7c9 100644 --- a/client/src/app/layout/SidebarApp/SidebarApp.tsx +++ b/client/src/app/layout/SidebarApp/SidebarApp.tsx @@ -149,6 +149,11 @@ export const MigrationSidebar = () => { ) : null} + + + {t("sidebar.tasks")} + + ); diff --git a/client/src/app/pages/tasks/tasks-page.tsx b/client/src/app/pages/tasks/tasks-page.tsx new file mode 100644 index 000000000..f0d04c837 --- /dev/null +++ b/client/src/app/pages/tasks/tasks-page.tsx @@ -0,0 +1,356 @@ +import React, { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import { + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + PageSection, + PageSectionVariants, + Text, + TextContent, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; +import { + Table, + Tbody, + Th, + Thead, + Tr, + Td, + ActionsColumn, +} from "@patternfly/react-table"; +import { CubesIcon } from "@patternfly/react-icons"; + +import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; +import { + ConditionalTableBody, + TableHeaderContentWithControls, + TableRowContentWithControls, +} from "@app/components/TableControls"; +import { + deserializeFilterUrlParams, + getHubRequestParams, + useTableControlProps, + useTableControlState, +} from "@app/hooks/table-controls"; + +import { SimplePagination } from "@app/components/SimplePagination"; +import { TablePersistenceKeyPrefix } from "@app/Constants"; + +import { useSelectionState } from "@migtools/lib-ui"; +import { useServerTasks } from "@app/queries/tasks"; +import { Task } from "@app/api/models"; +import { IconWithLabel, taskStateToIcon } from "@app/components/Icons"; +import { ManageColumnsToolbar } from "../applications/applications-table/components/manage-columns-toolbar"; +import dayjs from "dayjs"; +import { useTaskActions } from "./useTaskActions"; + +export const TasksPage: React.FC = () => { + const { t } = useTranslation(); + const history = useHistory(); + + const urlParams = new URLSearchParams(window.location.search); + const filters = urlParams.get("filters") ?? ""; + + const deserializedFilterValues = deserializeFilterUrlParams({ filters }); + + const tableControlState = useTableControlState({ + tableName: "tasks-table", + persistTo: { filter: "urlParams" }, + persistenceKeyPrefix: TablePersistenceKeyPrefix.tasks, + columnNames: { + id: "ID", + application: t("terms.application"), + state: t("terms.status"), + kind: t("terms.kind"), + priority: t("terms.priority"), + preemption: t("terms.preemption"), + createUser: t("terms.createdBy"), + pod: t("terms.pod"), + started: t("terms.started"), + terminated: t("terms.terminated"), + }, + initialFilterValues: deserializedFilterValues, + initialColumns: { + id: { isIdentity: true }, + pod: { isVisible: false }, + started: { isVisible: false }, + terminated: { isVisible: false }, + }, + isFilterEnabled: true, + isSortEnabled: true, + isPaginationEnabled: true, + isActiveItemEnabled: false, + sortableColumns: [ + "id", + "state", + "application", + "kind", + "createUser", + "priority", + ], + initialSort: { columnKey: "id", direction: "desc" }, + filterCategories: [ + { + categoryKey: "id", + title: "ID", + type: FilterType.numsearch, + placeholderText: t("actions.filterBy", { + what: "ID...", + }), + getServerFilterValue: (value) => (value ? value : []), + }, + { + categoryKey: "state", + title: t("terms.status"), + type: FilterType.search, + placeholderText: t("actions.filterBy", { + what: t("terms.status") + "...", + }), + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + categoryKey: "application", + title: t("terms.application"), + type: FilterType.search, + placeholderText: t("actions.filterBy", { + what: t("terms.application") + "...", + }), + serverFilterField: "application.name", + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + categoryKey: "kind", + title: t("terms.kind"), + type: FilterType.search, + placeholderText: t("actions.filterBy", { + what: t("terms.kind") + "...", + }), + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + { + categoryKey: "createUser", + title: t("terms.createdBy"), + type: FilterType.search, + placeholderText: t("actions.filterBy", { + what: t("terms.createdBy") + "...", + }), + getServerFilterValue: (value) => (value ? [`*${value[0]}*`] : []), + }, + ], + initialItemsPerPage: 10, + }); + + const { + result: { data: currentPageItems = [], total: totalItemCount }, + isFetching, + fetchError, + } = useServerTasks( + getHubRequestParams({ + ...tableControlState, + hubSortFieldKeys: { + id: "id", + state: "state", + application: "application.name", + kind: "kind", + createUser: "createUser", + priority: "priority", + }, + }) + ); + + const tableControls = useTableControlProps({ + ...tableControlState, + // task.id is defined as optional + idProperty: "name", + currentPageItems, + totalItemCount, + isLoading: isFetching, + selectionState: useSelectionState({ + items: currentPageItems, + isEqual: (a, b) => a.name === b.name, + }), + }); + + const { + numRenderedColumns, + propHelpers: { + toolbarProps, + filterToolbarProps, + paginationToolbarItemProps, + paginationProps, + tableProps, + getThProps, + getTrProps, + getTdProps, + getColumnVisibility, + }, + columnState, + } = tableControls; + + const clearFilters = () => { + const currentPath = history.location.pathname; + const newSearch = new URLSearchParams(history.location.search); + newSearch.delete("filters"); + history.push(`${currentPath}`); + filterToolbarProps.setFilterValues({}); + }; + + const { cancelTask, togglePreemption } = useTaskActions(); + + const toCells = ({ + id, + application, + kind, + addon, + state, + priority = 0, + policy, + createUser, + pod, + started, + terminated, + }: Task) => ({ + id, + application: application.name, + kind: kind ?? addon, + state: ( + + ), + priority, + preemption: String(!!policy?.preemptEnabled), + createUser, + pod, + started: started ? dayjs(started).format() : "", + terminated: terminated ? dayjs(terminated).format() : "", + }); + + return ( + <> + + + {t("titles.taskManager")} + + + +
+ + + + + + + + + + + + + + + {columnState.columns + .filter(({ id }) => getColumnVisibility(id)) + .map(({ id }) => ( + + + + } + /> + + } + numRenderedColumns={numRenderedColumns} + > + + {currentPageItems + ?.map((task): [Task, { [p: string]: ReactNode }] => [ + task, + toCells(task), + ]) + .map(([task, cells], rowIndex) => ( + + + {columnState.columns + .filter(({ id }) => getColumnVisibility(id)) + .map(({ id: columnKey }) => ( + + ))} + + + + ))} + + +
+ ))} + + +
+ {cells[columnKey]} + + cancelTask(task.id), + }, + { + title: task.policy?.preemptEnabled + ? t("actions.disablePreemption") + : t("actions.enablePreemption"), + isDisabled: "Running" === task.state, + onClick: () => togglePreemption(task), + }, + ]} + /> +
+ +
+
+ + ); +}; + +export default TasksPage; diff --git a/client/src/app/pages/tasks/useTaskActions.tsx b/client/src/app/pages/tasks/useTaskActions.tsx new file mode 100644 index 000000000..3d29aca49 --- /dev/null +++ b/client/src/app/pages/tasks/useTaskActions.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +import { + useCancelTaskMutation, + useUpdateTaskMutation, +} from "@app/queries/tasks"; +import { Task } from "@app/api/models"; +import { NotificationsContext } from "@app/components/NotificationsContext"; +import { useTranslation } from "react-i18next"; + +export const useTaskActions = () => { + const { t } = useTranslation(); + const { pushNotification } = React.useContext(NotificationsContext); + const { mutate: cancelTask } = useCancelTaskMutation( + () => + pushNotification({ + title: t("titles.task"), + message: t("message.cancelationRequestSubmitted"), + variant: "info", + }), + () => + pushNotification({ + title: t("titles.task"), + message: t("message.cancelationFailed"), + variant: "danger", + }) + ); + + const { mutate: updateTask } = useUpdateTaskMutation( + () => + pushNotification({ + title: t("titles.task"), + message: t("message.updateRequestSubmitted"), + variant: "info", + }), + () => + pushNotification({ + title: t("titles.task"), + message: t("message.updateFailed"), + variant: "danger", + }) + ); + + const togglePreemption = (task: Task) => + updateTask({ + id: task.id, + policy: { + preemptEnabled: !task.policy?.preemptEnabled, + }, + }); + + return { cancelTask, togglePreemption }; +}; diff --git a/client/src/app/queries/tasks.ts b/client/src/app/queries/tasks.ts index 6fbddbbeb..f24aa0c75 100644 --- a/client/src/app/queries/tasks.ts +++ b/client/src/app/queries/tasks.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { cancelTask, @@ -9,6 +9,7 @@ import { getTaskQueue, getTasks, getTextFile, + updateTask, } from "@app/api/rest"; import { universalComparator } from "@app/utils/utils"; import { @@ -108,13 +109,32 @@ export const useDeleteTaskMutation = ( }; export const useCancelTaskMutation = ( - onSuccess: () => void, + onSuccess: (statusCode: number) => void, onError: (err: Error | null) => void ) => { + const queryClient = useQueryClient(); return useMutation({ mutationFn: cancelTask, - onSuccess: () => { - onSuccess && onSuccess(); + onSuccess: (response) => { + queryClient.invalidateQueries([TasksQueryKey]); + onSuccess && onSuccess(response.status); + }, + onError: (err: Error) => { + onError && onError(err); + }, + }); +}; + +export const useUpdateTaskMutation = ( + onSuccess: (statusCode: number) => void, + onError: (err: Error | null) => void +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: updateTask, + onSuccess: (response) => { + queryClient.invalidateQueries([TasksQueryKey]); + onSuccess && onSuccess(response.status); }, onError: (err: Error) => { onError && onError(err);