diff --git a/web/packages/studio/src/components/AnonymizerJobActionsMenu/index.tsx b/web/packages/studio/src/components/AnonymizerJobActionsMenu/index.tsx new file mode 100644 index 0000000000..ac6dec012a --- /dev/null +++ b/web/packages/studio/src/components/AnonymizerJobActionsMenu/index.tsx @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CJobCancellableStatuses } from '@nemo/common/src/constants/query'; +import { + getAnonymizerListRunJobsQueryKey, + useAnonymizerCancelRunJob, +} from '@nemo/sdk/generated/anonymizer/api'; +import type { RunJob as AnonymizerJob } from '@nemo/sdk/generated/anonymizer/schema'; +import { DeleteJobModal } from '@studio/components/dataViews/AnonymizerJobsDataView/DeleteJobModal'; +import { + type QuickActionItem, + QuickActionsMenuRoot, +} from '@studio/components/QuickActionsMenu/QuickActionsMenuRoot'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getAnonymizerJobRoute } from '@studio/routes/utils'; +import { useQueryClient } from '@tanstack/react-query'; +import { type FC, useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +interface AnonymizerJobActionsMenuProps { + job: AnonymizerJob; + includeViewDetails?: boolean; + onDeleted?: () => void; + onCancelError?: (message: string | undefined) => void; +} + +export const AnonymizerJobActionsMenu: FC = ({ + job, + includeViewDetails = false, + onDeleted, + onCancelError, +}) => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + const queryClient = useQueryClient(); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const cancelJobMutation = useAnonymizerCancelRunJob({ + mutation: { + onSuccess: () => { + queryClient.resetQueries({ + queryKey: getAnonymizerListRunJobsQueryKey(workspace), + }); + onCancelError?.(undefined); + }, + onError: (error) => { + onCancelError?.(error instanceof Error ? error.message : 'Failed to cancel job'); + }, + }, + }); + + const { mutateAsync: cancelJob } = cancelJobMutation; + + const handleCancel = useCallback(async () => { + if (!job.workspace || !job.name) return; + try { + onCancelError?.(undefined); + await cancelJob({ workspace: job.workspace, name: job.name }); + } catch { + // Error is surfaced via the mutation's onError callback. + } + }, [job.workspace, job.name, cancelJob, onCancelError]); + + const isCancellable = job.status != null && CJobCancellableStatuses.includes(job.status); + + const actions = useMemo( + () => [ + ...(includeViewDetails + ? [ + { + label: 'View details', + onSelect: () => { + if (job.name) { + navigate(getAnonymizerJobRoute(workspace, job.name)); + } + }, + }, + ] + : []), + ...(isCancellable + ? [ + { + label: 'Cancel', + onSelect: handleCancel, + }, + ] + : []), + { + label: 'Delete', + onSelect: () => setShowDeleteModal(true), + danger: true, + }, + ], + [includeViewDetails, isCancellable, handleCancel, navigate, workspace, job.name] + ); + + return ( + <> + + {showDeleteModal && ( + setShowDeleteModal(false)} + onDeleted={onDeleted} + /> + )} + + ); +}; diff --git a/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/DeleteJobModal.tsx b/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/DeleteJobModal.tsx new file mode 100644 index 0000000000..5590cbb562 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/DeleteJobModal.tsx @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getAnonymizerListRunJobsQueryKey, + useAnonymizerDeleteRunJob, +} from '@nemo/sdk/generated/anonymizer/api'; +import type { RunJob as AnonymizerJob } from '@nemo/sdk/generated/anonymizer/schema'; +import { BulkDeleteModal } from '@studio/components/BulkDeleteModal'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { useQueryClient } from '@tanstack/react-query'; +import type { FC } from 'react'; + +interface DeleteJobModalProps { + jobs: AnonymizerJob[]; + onClose: () => void; + onDeleted?: () => void; +} + +export const DeleteJobModal: FC = ({ jobs, onClose, onDeleted }) => { + const queryClient = useQueryClient(); + const workspace = useWorkspaceFromPath(); + + const deleteJobMutation = useAnonymizerDeleteRunJob({ + mutation: { + onSuccess: () => + queryClient.resetQueries({ + queryKey: getAnonymizerListRunJobsQueryKey(workspace), + }), + }, + }); + + const handleDelete = async (jobsToDelete: AnonymizerJob[]) => { + const invalid = jobsToDelete.filter((job) => !job.workspace || !job.name); + if (invalid.length > 0) { + throw new Error( + `Cannot delete ${invalid.length} job${invalid.length !== 1 ? 's' : ''}: missing workspace or name.` + ); + } + await Promise.all( + jobsToDelete.map(async (job) => { + try { + await deleteJobMutation.mutateAsync({ workspace: job.workspace!, name: job.name }); + } catch (error) { + throw new Error( + `Failed to delete job "${job.name}": ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + }) + ); + onDeleted?.(); + }; + + return ( + 0} + onDelete={handleDelete} + title={(count) => `Delete ${count} Anonymizer Job${count !== 1 ? 's' : ''}`} + onClose={onClose} + /> + ); +}; diff --git a/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/index.tsx b/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/index.tsx new file mode 100644 index 0000000000..a9fc6aa585 --- /dev/null +++ b/web/packages/studio/src/components/dataViews/AnonymizerJobsDataView/index.tsx @@ -0,0 +1,260 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { withOperators } from '@nemo/common/src/api/filterOperators'; +import { dateTimeFilter } from '@nemo/common/src/components/DataView/dateTimeFilter'; +import { + ROW_SELECTION_COLUMN_SIZE, + StudioDataView, +} from '@nemo/common/src/components/DataView/StudioDataView'; +import { RelativeTime } from '@nemo/common/src/components/RelativeTime'; +import { StatusBadge } from '@nemo/common/src/components/StatusBadge'; +import { TableEmptyState } from '@nemo/common/src/components/TableEmptyState'; +import { JOB_POLLING_INTERVAL_MS } from '@nemo/common/src/constants'; +import { useStudioDataViewState } from '@nemo/common/src/hooks/useStudioDataViewState'; +import { getSortParam } from '@nemo/common/src/utils/query'; +import { + getAnonymizerListRunJobsQueryKey, + useAnonymizerDeleteRunJob, + useAnonymizerListRunJobs, +} from '@nemo/sdk/generated/anonymizer/api'; +import type { + RunJob as AnonymizerJob, + RunJobsListFilter as AnonymizerJobsListFilter, + RunJobsSortField as AnonymizerJobsSortField, +} from '@nemo/sdk/generated/anonymizer/schema'; +import { Banner, Button, Text } from '@nvidia/foundations-react-core'; +import { AnonymizerJobActionsMenu } from '@studio/components/AnonymizerJobActionsMenu'; +import { BulkDeleteModal } from '@studio/components/BulkDeleteModal'; +import { STATUS_FILTER_OPTIONS } from '@studio/constants/platformJobs'; +import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; +import { getAnonymizerJobRoute, getNewAnonymizerRoute } from '@studio/routes/utils'; +import { keepPreviousData, useQueryClient } from '@tanstack/react-query'; +import { Trash, VenetianMask } from 'lucide-react'; +import { type ComponentProps, type FC, useCallback, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +type AnonymizerJobWithId = AnonymizerJob & { id: string }; + +export const AnonymizerJobsDataView: FC = () => { + const navigate = useNavigate(); + const workspace = useWorkspaceFromPath(); + + const dataViewState = useStudioDataViewState({ + defaultSort: [{ id: 'created_at', desc: true }], + columnVisibility: { updated_at: false }, + }); + + const queryClient = useQueryClient(); + + const [deleteJobs, setDeleteJobs] = useState([]); + const [cancelError, setCancelError] = useState(undefined); + + const deleteJobMutation = useAnonymizerDeleteRunJob({ + mutation: { + onSuccess: () => + queryClient.resetQueries({ + queryKey: getAnonymizerListRunJobsQueryKey(workspace), + }), + }, + }); + + const handleDeleteJobs = async (jobsToDelete: AnonymizerJobWithId[]) => { + const invalid = jobsToDelete.filter((job) => !job.workspace || !job.name); + if (invalid.length > 0) { + throw new Error( + `Cannot delete ${invalid.length} job${invalid.length !== 1 ? 's' : ''}: missing workspace or name.` + ); + } + const results = await Promise.allSettled( + jobsToDelete.map((job) => + deleteJobMutation.mutateAsync({ workspace: job.workspace!, name: job.name }) + ) + ); + const failed = jobsToDelete.filter((_, i) => results[i].status === 'rejected'); + if (failed.length > 0) { + // Keep only the failed jobs selected so a retry doesn't re-delete succeeded ones. + dataViewState.rowSelection.set(Object.fromEntries(failed.map((job) => [job.id, true]))); + throw new Error( + `Failed to delete ${failed.length} of ${jobsToDelete.length} job${ + jobsToDelete.length !== 1 ? 's' : '' + }: ${failed.map((job) => `"${job.name}"`).join(', ')}` + ); + } + }; + + const { data: anonymizerResponse, isLoading } = useAnonymizerListRunJobs( + workspace, + { + sort: getSortParam(dataViewState.sorting.state) as AnonymizerJobsSortField, + page: dataViewState.pagination.state.pageIndex + 1, + page_size: dataViewState.pagination.state.pageSize, + filter: { + ...((dataViewState.apiFilter.filter ?? {}) as AnonymizerJobsListFilter), + ...(dataViewState.apiFilter.searchText + ? withOperators({ + name: { $like: dataViewState.apiFilter.searchText }, + }) + : {}), + }, + }, + { + query: { + placeholderData: keepPreviousData, + refetchInterval: JOB_POLLING_INTERVAL_MS, + refetchOnMount: 'always', + }, + } + ); + + const jobs = useMemo( + () => + (anonymizerResponse?.data || []).map((job) => ({ + ...job, + id: job.id || `${job.workspace ?? ''}/${job.name}`, + })), + [anonymizerResponse?.data] + ); + + const hasActiveFilters = + Boolean(dataViewState.debouncedSearchBar) || dataViewState.debouncedColumnFilters.length > 0; + + const makeColumns: ComponentProps>['makeColumns'] = + useCallback( + ({ accessor }, { rowSelectionColumn, rowActionsColumn }) => [ + rowSelectionColumn({ size: ROW_SELECTION_COLUMN_SIZE }), + accessor('name', { + header: 'Name', + cell: ({ row }) => row.original.name, + }), + accessor('description', { + header: 'Description', + cell: ({ row }) => ( + + {row.original.description ?? '-'} + + ), + }), + accessor('created_at', { + id: 'created_at', + header: 'Created', + enableSorting: true, + size: 150, + meta: { + filter: dateTimeFilter('Created At'), + }, + cell: ({ row }) => + row.original.created_at ? : null, + }), + accessor('status', { + header: 'Status', + size: 125, + meta: { + filter: { + type: 'single-select' as const, + label: 'Status', + options: STATUS_FILTER_OPTIONS, + }, + }, + cell: ({ row }) => + row.original.status ? : null, + }), + accessor('updated_at', { + id: 'updated_at', + header: 'Updated', + enableSorting: false, + meta: { + filter: dateTimeFilter('Updated At'), + }, + cell: ({ row }) => + row.original?.updated_at ? : null, + }), + rowActionsColumn({ + size: 70, + enableResizing: false, + cell: ({ row }) => ( + + ), + }), + ], + [] + ); + + const totalResults = anonymizerResponse?.pagination?.total_results ?? 0; + + return ( + <> + {cancelError && ( + + {cancelError} + + )} + + + dataViewState={dataViewState} + searchField="name" + makeColumns={makeColumns} + onRowClick={(row) => navigate(getAnonymizerJobRoute(workspace, row.name))} + renderBulkActions={({ selectedRows }) => ( + + )} + attributes={{ + DataViewSearchBar: { + placeholder: 'Search jobs...', + }, + DataViewRoot: { + data: jobs, + totalCount: totalResults, + requestStatus: isLoading && !anonymizerResponse ? 'loading' : undefined, + }, + DataViewTableContent: { + renderEmptyState: () => + hasActiveFilters ? ( + + Clear Filters + + } + /> + ) : ( + } + header="Anonymizer Jobs" + emptyMessage="Detect and protect PII in your datasets through context-aware replacement and rewriting." + actions={ + + } + /> + ), + }, + }} + /> + + 0} + onDelete={handleDeleteJobs} + title={(count) => `Delete ${count} Anonymizer Job${count !== 1 ? 's' : ''}`} + onClose={() => { + setDeleteJobs([]); + dataViewState.rowSelection.set({}); + }} + /> + + ); +}; diff --git a/web/packages/studio/src/routes/AnonymizerListRoute/index.tsx b/web/packages/studio/src/routes/AnonymizerListRoute/index.tsx index 2b89ccacf5..0df6eddc16 100644 --- a/web/packages/studio/src/routes/AnonymizerListRoute/index.tsx +++ b/web/packages/studio/src/routes/AnonymizerListRoute/index.tsx @@ -3,6 +3,7 @@ import { Button, PageHeader, Stack } from '@nvidia/foundations-react-core'; import { AccessibleTitle } from '@studio/components/AccessibleTitle'; +import { AnonymizerJobsDataView } from '@studio/components/dataViews/AnonymizerJobsDataView'; import { ANONYMIZER_ENABLED } from '@studio/constants/environment'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; @@ -29,6 +30,7 @@ export const AnonymizerListRoute: FC | null = ANONYMIZER_ENABLED } /> +