diff --git a/ui/AGENTS.md b/ui/AGENTS.md index ce9318f1240..46a894e0be9 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -51,6 +51,8 @@ Routes are defined in `ui/apps/pmm/src/router.tsx` using React Router's `createB | `/updates` | PMM Server updates | | `/updates/clients` | Client updates | | `/help` | Help center | +| `/pmm-dump` | PMM Dump datasets | +| `/pmm-dump/new` | Create PMM Dump dataset | | `/rta` | Real-Time Analytics tab | | `/rta/selection` | RTA service selection | | `/rta/sessions` | RTA sessions list | diff --git a/ui/apps/pmm/src/api/__mocks__/dump.ts b/ui/apps/pmm/src/api/__mocks__/dump.ts new file mode 100644 index 00000000000..d3194ea6d50 --- /dev/null +++ b/ui/apps/pmm/src/api/__mocks__/dump.ts @@ -0,0 +1,23 @@ +import { DumpStatus } from 'types/dump.types'; + +export const listDumps = vi.fn().mockResolvedValue({ + dumps: [ + { + dumpId: 'dump-1', + status: DumpStatus.Success, + serviceNames: ['mysql'], + startTime: '2026-07-10T08:00:00Z', + endTime: '2026-07-10T10:00:00Z', + createdAt: '2026-07-10T10:01:00Z', + encrypted: false, + }, + ], +}); + +export const startDump = vi.fn().mockResolvedValue({ dumpId: 'dump-2' }); +export const deleteDumps = vi.fn().mockResolvedValue({}); +export const uploadDumps = vi.fn().mockResolvedValue({}); +export const getDumpLogs = vi.fn().mockResolvedValue({ + logs: [{ chunkId: 1, data: 'dump log' }], + end: true, +}); diff --git a/ui/apps/pmm/src/api/dump.ts b/ui/apps/pmm/src/api/dump.ts new file mode 100644 index 00000000000..03a8a34a6e8 --- /dev/null +++ b/ui/apps/pmm/src/api/dump.ts @@ -0,0 +1,48 @@ +import { + DeleteDumpsPayload, + GetDumpLogsParams, + GetDumpLogsResponse, + ListDumpsResponse, + StartDumpPayload, + StartDumpResponse, + UploadDumpsPayload, +} from 'types/dump.types'; +import { EmptyResponse } from 'types/util.types'; +import { api } from './api'; + +export const listDumps = async (): Promise => { + const response = await api.get('/dumps'); + return response.data; +}; + +export const startDump = async ( + payload: StartDumpPayload +): Promise => { + const response = await api.post('/dumps:start', payload); + return response.data; +}; + +export const deleteDumps = async ( + payload: DeleteDumpsPayload +): Promise => { + const response = await api.post('/dumps:batchDelete', payload); + return response.data; +}; + +export const getDumpLogs = async ({ + dumpId, + offset, + limit, +}: GetDumpLogsParams): Promise => { + const response = await api.get(`/dumps/${dumpId}/logs`, { + params: { offset, limit }, + }); + return response.data; +}; + +export const uploadDumps = async ( + payload: UploadDumpsPayload +): Promise => { + const response = await api.post('/dumps:upload', payload); + return response.data; +}; diff --git a/ui/apps/pmm/src/components/page/Page.tsx b/ui/apps/pmm/src/components/page/Page.tsx index 029e9e3fd6b..9b1c481ab63 100644 --- a/ui/apps/pmm/src/components/page/Page.tsx +++ b/ui/apps/pmm/src/components/page/Page.tsx @@ -50,13 +50,13 @@ export const Page: FC = ({ flex: 1, width: '100%', maxWidth: { - lg: 1000, + lg: fullWidth ? undefined : 1000, }, p: { xs: 2, }, px: { - md: fullWidth ? 4 : undefined, + md: 4, }, mx: 'auto', gap: 2, diff --git a/ui/apps/pmm/src/hooks/api/useDump.test.tsx b/ui/apps/pmm/src/hooks/api/useDump.test.tsx new file mode 100644 index 00000000000..121aee3385f --- /dev/null +++ b/ui/apps/pmm/src/hooks/api/useDump.test.tsx @@ -0,0 +1,41 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { startDump } from 'api/dump'; +import { PropsWithChildren } from 'react'; +import { useStartDump } from './useDump'; + +vi.mock('api/dump'); + +describe('useDump', () => { + it('invalidates the dump list after starting a dump', async () => { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }); + const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + const payload = { + serviceNames: ['mysql'], + startTime: '2026-07-10T08:00:00Z', + endTime: '2026-07-10T10:00:00Z', + exportQan: false, + ignoreLoad: true, + enableEncryption: false, + encryptionPassword: '', + }; + const { result } = renderHook(() => useStartDump(), { wrapper }); + + await act(() => result.current.mutateAsync(payload)); + + expect(vi.mocked(startDump)).toHaveBeenCalledWith( + payload, + expect.anything() + ); + await waitFor(() => + expect(invalidate).toHaveBeenCalledWith({ + queryKey: ['dumps:list'], + }) + ); + }); +}); diff --git a/ui/apps/pmm/src/hooks/api/useDump.ts b/ui/apps/pmm/src/hooks/api/useDump.ts new file mode 100644 index 00000000000..fe146fb52f2 --- /dev/null +++ b/ui/apps/pmm/src/hooks/api/useDump.ts @@ -0,0 +1,100 @@ +import { + useMutation, + UseMutationOptions, + useQuery, + useQueryClient, + UseQueryOptions, +} from '@tanstack/react-query'; +import { + deleteDumps, + getDumpLogs, + listDumps, + startDump, + uploadDumps, +} from 'api/dump'; +import { + DeleteDumpsPayload, + GetDumpLogsParams, + GetDumpLogsResponse, + ListDumpsResponse, + StartDumpPayload, + StartDumpResponse, + UploadDumpsPayload, +} from 'types/dump.types'; +import { EmptyResponse } from 'types/util.types'; + +export const DUMP_QUERY_KEYS = { + list: ['dumps:list'] as const, + logs: (dumpId: string) => ['dumps:logs', dumpId] as const, + start: ['dumps:start'] as const, + delete: ['dumps:delete'] as const, + upload: ['dumps:upload'] as const, +}; + +export const useDumps = ( + options?: Partial> +) => + useQuery({ + queryKey: DUMP_QUERY_KEYS.list, + queryFn: listDumps, + refetchInterval: 5_000, + ...options, + }); + +export const useStartDump = ( + options?: Partial< + UseMutationOptions + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: DUMP_QUERY_KEYS.start, + mutationFn: startDump, + ...options, + onSuccess: async (data, variables, onMutateResult, context) => { + await options?.onSuccess?.(data, variables, onMutateResult, context); + await queryClient.invalidateQueries({ queryKey: DUMP_QUERY_KEYS.list }); + }, + }); +}; + +export const useDeleteDumps = ( + options?: Partial< + UseMutationOptions + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: DUMP_QUERY_KEYS.delete, + mutationFn: deleteDumps, + ...options, + onSuccess: async (data, variables, onMutateResult, context) => { + await options?.onSuccess?.(data, variables, onMutateResult, context); + await queryClient.invalidateQueries({ queryKey: DUMP_QUERY_KEYS.list }); + }, + }); +}; + +export const useDumpLogs = ( + params: GetDumpLogsParams, + options?: Partial> +) => + useQuery({ + queryKey: [...DUMP_QUERY_KEYS.logs(params.dumpId), params.offset], + queryFn: () => getDumpLogs(params), + enabled: !!params.dumpId, + ...options, + }); + +export const useUploadDumps = ( + options?: Partial< + UseMutationOptions + > +) => + useMutation({ + mutationKey: DUMP_QUERY_KEYS.upload, + mutationFn: uploadDumps, + ...options, + }); diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.constants.ts b/ui/apps/pmm/src/pages/dump/DumpPage.constants.ts new file mode 100644 index 00000000000..e14c30ed162 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.constants.ts @@ -0,0 +1,13 @@ +import { DumpStatus } from 'types/dump.types'; +import { DumpStatusColor } from './DumpPage.types'; + +export const DOWNLOAD_DELAY_MS = 900; +export const LOG_CHUNK_LIMIT = 200; +export const LOG_REFETCH_INTERVAL_MS = 3_000; + +export const STATUS_COLORS: Record = { + [DumpStatus.Unspecified]: 'default', + [DumpStatus.InProgress]: 'info', + [DumpStatus.Success]: 'success', + [DumpStatus.Error]: 'error', +}; diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.messages.ts b/ui/apps/pmm/src/pages/dump/DumpPage.messages.ts new file mode 100644 index 00000000000..0ce3abf007e --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.messages.ts @@ -0,0 +1,51 @@ +export const Messages = { + title: 'PMM Dump', + createDataset: 'Create dataset', + empty: 'No dumps available', + notAvailable: 'N/A', + loading: 'Loading datasets', + loadError: 'Could not load datasets.', + selectDatasets: 'Select one or more datasets to use bulk actions.', + columns: { + id: 'ID', + status: 'Status', + created: 'Created', + timeRange: 'Time range', + start: 'Start date', + end: 'End date', + services: 'Service names', + }, + status: { + unspecified: 'Unspecified', + inProgress: 'In progress', + success: 'Success', + error: 'Error', + }, + actions: { + download: 'Download', + downloadSelected: (count: number) => `Download ${count} items`, + delete: 'Delete', + deleteSelected: (count: number) => `Delete ${count} items`, + sendToSupport: 'Send to Support', + viewLogs: 'View logs', + }, + delete: { + title: 'Delete PMM dump', + single: 'Are you sure you want to delete this PMM dump?', + multiple: 'Are you sure you want to delete these PMM dumps?', + success: 'Dataset deleted successfully.', + multipleSuccess: 'Datasets deleted successfully.', + cancel: 'Cancel', + confirm: 'Delete', + }, + downloadError: 'Could not download the selected datasets.', + logs: { + title: (id: string) => `Logs for ${id}`, + empty: 'No logs available.', + loading: 'Loading logs', + error: 'Could not load dump logs.', + copy: 'Copy logs', + copied: 'Logs copied to clipboard.', + close: 'Close', + }, +}; diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.test.tsx b/ui/apps/pmm/src/pages/dump/DumpPage.test.tsx new file mode 100644 index 00000000000..829e3643a9b --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from '@testing-library/react'; +import { DumpPage } from './DumpPage'; +import { TestWrapper } from 'utils/testWrapper'; +import { TEST_USER_VIEWER } from 'utils/testStubs'; + +const mocks = vi.hoisted(() => ({ + useDumps: vi.fn(), +})); + +vi.mock('hooks/api/useDump', () => ({ + useDumps: mocks.useDumps, + useDeleteDumps: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDumpLogs: () => ({ data: undefined, isLoading: false, isError: false }), + useUploadDumps: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +vi.mock('@percona/percona-ui', async (importOriginal) => ({ + ...(await importOriginal()), + Table: ({ + data, + noDataMessage, + }: { + data: unknown[]; + noDataMessage: string; + }) =>
{data.length || noDataMessage}
, +})); + +describe('DumpPage', () => { + beforeEach(() => { + mocks.useDumps.mockReturnValue({ + data: { dumps: [] }, + isLoading: false, + isError: false, + }); + }); + + it('shows the empty inventory and create action to admins', () => { + render( + + + + ); + + expect(screen.getByTestId('pmm-dump-table')).toHaveTextContent( + 'No dumps available' + ); + expect(screen.getByTestId('create-dataset')).toHaveAttribute( + 'href', + '/pmm-dump/new' + ); + expect(mocks.useDumps).toHaveBeenCalledWith({ enabled: true }); + }); + + it('shows the unauthorized state to non-admin users', () => { + render( + + + + ); + + expect(screen.getByTestId('unauthorized')).toBeInTheDocument(); + expect(screen.queryByTestId('pmm-dump-page')).not.toBeInTheDocument(); + expect(mocks.useDumps).toHaveBeenCalledWith({ enabled: false }); + }); +}); diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.tsx b/ui/apps/pmm/src/pages/dump/DumpPage.tsx new file mode 100644 index 00000000000..278c5c81a45 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.tsx @@ -0,0 +1,323 @@ +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import DownloadIcon from '@mui/icons-material/Download'; +import SendIcon from '@mui/icons-material/Send'; +import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'; +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + List, + ListItem, + ListItemIcon, + ListItemText, + MenuItem, + Stack, + Typography, +} from '@mui/material'; +import { Table } from '@percona/percona-ui'; +import { MRT_ColumnDef, MRT_RowSelectionState } from 'material-react-table'; +import { FC, useMemo, useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { enqueueSnackbar } from 'notistack'; +import { Page } from 'components/page'; +import { useUser } from 'contexts/user'; +import { useDeleteDumps, useDumps } from 'hooks/api/useDump'; +import { Dump, DumpStatus } from 'types/dump.types'; +import { OrgRole } from 'types/user.types'; +import { STATUS_COLORS } from './DumpPage.constants'; +import { Messages } from './DumpPage.messages'; +import { + downloadDumps, + formatDate, + formatTimeRange, + getStatusLabel, +} from './DumpPage.utils'; +import { DumpLogsDialog } from './components/dump-logs/DumpLogsDialog'; +import { SendToSupport } from './components/send-to-support/SendToSupport'; + +export const DumpPage: FC = () => { + const { user } = useUser(); + const { data, isLoading, isError } = useDumps({ + enabled: !!user?.isPMMAdmin, + }); + const { mutateAsync: remove, isPending: isDeleting } = useDeleteDumps(); + const [rowSelection, setRowSelection] = useState({}); + const [deleteIds, setDeleteIds] = useState([]); + const [supportIds, setSupportIds] = useState([]); + const [logsDumpId, setLogsDumpId] = useState(null); + const dumps = data?.dumps ?? []; + const selectedDumps = dumps.filter(({ dumpId }) => rowSelection[dumpId]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: 'dumpId', + header: Messages.columns.id, + }, + { + accessorKey: 'status', + header: Messages.columns.status, + Cell: ({ cell }) => { + const status = cell.getValue(); + return ( + + ); + }, + }, + { + accessorKey: 'createdAt', + header: Messages.columns.created, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + { + id: 'timeRange', + header: Messages.columns.timeRange, + accessorFn: formatTimeRange, + }, + { + accessorKey: 'startTime', + header: Messages.columns.start, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + { + accessorKey: 'endTime', + header: Messages.columns.end, + Cell: ({ cell }) => formatDate(cell.getValue()), + }, + ], + [] + ); + + const requestDelete = (ids: string[]) => setDeleteIds(ids); + + const confirmDelete = async () => { + await remove( + { dumpIds: deleteIds }, + { + onSuccess: () => { + enqueueSnackbar( + deleteIds.length === 1 + ? Messages.delete.success + : Messages.delete.multipleSuccess, + { variant: 'success' } + ); + setDeleteIds([]); + setRowSelection({}); + }, + } + ); + }; + + const handleDownload = async (items: Dump[]) => { + try { + await downloadDumps(items); + } catch { + enqueueSnackbar(Messages.downloadError, { variant: 'error' }); + } + }; + + return ( + + + + {selectedDumps.length > 0 ? ( + + + + + + ) : ( + + {Messages.selectDatasets} + + )} + + + + {isError && {Messages.loadError}} + {isLoading ? ( + + + + ) : ( + row.dumpId} + enableRowSelection + enableRowActions + enableExpanding + enableHiding={false} + enableGlobalFilter={false} + onRowSelectionChange={setRowSelection} + state={{ rowSelection }} + initialState={{ + pagination: { pageIndex: 0, pageSize: 25 }, + columnVisibility: { dumpId: false }, + }} + renderDetailPanel={({ row }) => + row.original.serviceNames.length > 0 ? ( + + + {Messages.columns.services} + + + {row.original.serviceNames.map((service) => ( + + {service} + + ))} + + + ) : null + } + renderRowActionMenuItems={({ row, closeMenu }) => [ + { + handleDownload([row.original]); + closeMenu(); + }} + > + + + + {Messages.actions.download} + , + { + setSupportIds([row.original.dumpId]); + closeMenu(); + }} + > + + + + {Messages.actions.sendToSupport} + , + { + setLogsDumpId(row.original.dumpId); + closeMenu(); + }} + > + + + + {Messages.actions.viewLogs} + , + { + requestDelete([row.original.dumpId]); + closeMenu(); + }} + > + + + + {Messages.actions.delete} + , + ]} + /> + )} + + + 0} onClose={() => setDeleteIds([])}> + {Messages.delete.title} + + {deleteIds.length === 1 + ? Messages.delete.single + : Messages.delete.multiple} + + + + + + + 0} + dumpIds={supportIds} + onClose={() => setSupportIds([])} + /> + setLogsDumpId(null)} /> + + ); +}; diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.types.ts b/ui/apps/pmm/src/pages/dump/DumpPage.types.ts new file mode 100644 index 00000000000..f981b89e8f6 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.types.ts @@ -0,0 +1 @@ +export type DumpStatusColor = 'default' | 'info' | 'success' | 'error'; diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.utils.test.ts b/ui/apps/pmm/src/pages/dump/DumpPage.utils.test.ts new file mode 100644 index 00000000000..dfa889e2aef --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.utils.test.ts @@ -0,0 +1,30 @@ +import { Dump, DumpStatus } from 'types/dump.types'; +import { + formatTimeRange, + getDumpArchiveUrl, + getStatusLabel, +} from './DumpPage.utils'; + +const dump: Dump = { + dumpId: 'dump-1', + status: DumpStatus.Success, + serviceNames: ['mysql'], + startTime: '2026-07-10T08:00:00Z', + endTime: '2026-07-10T10:00:00Z', + createdAt: '2026-07-10T10:01:00Z', + encrypted: false, +}; + +describe('DumpPage utils', () => { + it('builds archive URLs for plain and encrypted dumps', () => { + expect(getDumpArchiveUrl(dump)).toBe('/dump/dump-1.tar.gz'); + expect(getDumpArchiveUrl({ ...dump, encrypted: true })).toBe( + '/dump/dump-1.tar.gz.enc' + ); + }); + + it('formats status and time range values', () => { + expect(getStatusLabel(DumpStatus.InProgress)).toBe('In progress'); + expect(formatTimeRange(dump)).toBe('2 hours'); + }); +}); diff --git a/ui/apps/pmm/src/pages/dump/DumpPage.utils.ts b/ui/apps/pmm/src/pages/dump/DumpPage.utils.ts new file mode 100644 index 00000000000..9ac97b549ce --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/DumpPage.utils.ts @@ -0,0 +1,58 @@ +import { formatDistanceStrict } from 'date-fns'; +import { Dump, DumpStatus } from 'types/dump.types'; +import { DOWNLOAD_DELAY_MS } from './DumpPage.constants'; +import { Messages } from './DumpPage.messages'; + +export const getStatusLabel = (status: DumpStatus) => { + switch (status) { + case DumpStatus.InProgress: + return Messages.status.inProgress; + case DumpStatus.Success: + return Messages.status.success; + case DumpStatus.Error: + return Messages.status.error; + default: + return Messages.status.unspecified; + } +}; + +export const formatDate = (value: string) => + value + ? new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'medium', + }).format(new Date(value)) + : Messages.notAvailable; + +export const formatTimeRange = (dump: Dump) => { + if (!dump.startTime || !dump.endTime) { + return Messages.notAvailable; + } + + return formatDistanceStrict(new Date(dump.endTime), new Date(dump.startTime)); +}; + +export const getDumpArchiveUrl = (dump: Dump) => + `/dump/${dump.dumpId}.tar.gz${dump.encrypted ? '.enc' : ''}`; + +const triggerDownload = (dump: Dump) => { + const link = document.createElement('a'); + link.href = getDumpArchiveUrl(dump); + link.download = ''; + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +export const downloadDumps = async (dumps: Dump[]) => { + const downloadable = dumps.filter( + ({ status }) => status === DumpStatus.Success + ); + + for (const [index, dump] of downloadable.entries()) { + if (index > 0) { + await new Promise((resolve) => setTimeout(resolve, DOWNLOAD_DELAY_MS)); + } + triggerDownload(dump); + } +}; diff --git a/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.test.tsx b/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.test.tsx new file mode 100644 index 00000000000..92da9463ca0 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DumpLogsDialog } from './DumpLogsDialog'; + +const mocks = vi.hoisted(() => ({ + useDumpLogs: vi.fn(), +})); + +vi.mock('hooks/api/useDump', () => ({ + useDumpLogs: mocks.useDumpLogs, +})); + +vi.mock('notistack', () => ({ + enqueueSnackbar: vi.fn(), +})); + +describe('DumpLogsDialog', () => { + it('renders newline-separated chunks and copies them', async () => { + mocks.useDumpLogs.mockReturnValue({ + data: { + logs: [ + { chunkId: 1, data: 'first line' }, + { chunkId: 2, data: 'second line' }, + ], + end: true, + }, + isError: false, + }); + + render(); + + expect(await screen.findByText(/first line\s+second line/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: 'Copy logs' })); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + 'first line\nsecond line' + ) + ); + }); + + it('keeps showing progress while an active dump has no logs', () => { + mocks.useDumpLogs.mockReturnValue({ + data: { logs: [], end: false }, + isError: false, + }); + + render(); + + expect(screen.getByLabelText('Loading logs')).toBeVisible(); + expect(screen.queryByText('No logs available.')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.tsx b/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.tsx new file mode 100644 index 00000000000..a06e87501d3 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/dump-logs/DumpLogsDialog.tsx @@ -0,0 +1,114 @@ +import { + Alert, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Typography, +} from '@mui/material'; +import { FC, useEffect, useState } from 'react'; +import { enqueueSnackbar } from 'notistack'; +import { useDumpLogs } from 'hooks/api/useDump'; +import { DumpLogChunk } from 'types/dump.types'; +import { + LOG_CHUNK_LIMIT, + LOG_REFETCH_INTERVAL_MS, +} from '../../DumpPage.constants'; +import { Messages } from '../../DumpPage.messages'; + +interface DumpLogsDialogProps { + dumpId: string | null; + onClose: () => void; +} + +export const DumpLogsDialog: FC = ({ + dumpId, + onClose, +}) => { + const [offset, setOffset] = useState(0); + const [chunks, setChunks] = useState([]); + const { data, isError } = useDumpLogs( + { dumpId: dumpId ?? '', offset, limit: LOG_CHUNK_LIMIT }, + { + enabled: !!dumpId, + refetchInterval: (query) => + query.state.data?.end ? false : LOG_REFETCH_INTERVAL_MS, + } + ); + + useEffect(() => { + setOffset(0); + setChunks([]); + }, [dumpId]); + + useEffect(() => { + if (!data?.logs.length) { + return; + } + + setChunks((current) => { + const ids = new Set(current.map(({ chunkId }) => chunkId)); + return [ + ...current, + ...data.logs.filter(({ chunkId }) => !ids.has(chunkId)), + ]; + }); + setOffset(data.logs[data.logs.length - 1].chunkId); + }, [data]); + + const logs = chunks.map(({ data }) => data).join('\n'); + const copyLogs = async () => { + await navigator.clipboard.writeText(logs); + enqueueSnackbar(Messages.logs.copied, { variant: 'success' }); + }; + + return ( + + {Messages.logs.title(dumpId ?? '')} + + {isError && {Messages.logs.error}} + {!data?.end && chunks.length === 0 && !isError && ( + + + + )} + {data?.end && chunks.length === 0 && !isError && ( + {Messages.logs.empty} + )} + {chunks.length > 0 && ( + + {logs} + + )} + + + + + + + ); +}; diff --git a/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.messages.ts b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.messages.ts new file mode 100644 index 00000000000..00320fdc155 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.messages.ts @@ -0,0 +1,13 @@ +export const Messages = { + title: 'Send to Support', + address: 'Address', + addressPlaceholder: 'address:port', + user: 'Name', + password: 'Password', + directory: 'Directory', + required: 'Required field', + send: 'Send', + sending: 'Sending...', + cancel: 'Cancel', + success: 'Datasets were sent to Support.', +}; diff --git a/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.test.ts b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.test.ts new file mode 100644 index 00000000000..194fce1e32a --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.test.ts @@ -0,0 +1,26 @@ +import { sendToSupportSchema } from './SendToSupport.schema'; +import { + SEND_TO_SUPPORT_DEFAULT_VALUES, + toUploadPayload, +} from './SendToSupport.utils'; + +describe('sendToSupportSchema', () => { + it('requires address, user, and password', () => { + expect( + sendToSupportSchema.safeParse(SEND_TO_SUPPORT_DEFAULT_VALUES).success + ).toBe(false); + }); + + it('maps valid form values to an upload payload', () => { + const values = { + ...SEND_TO_SUPPORT_DEFAULT_VALUES, + user: 'customer', + password: 'secret', + }; + + expect(toUploadPayload(values, ['dump-1'])).toEqual({ + dumpIds: ['dump-1'], + sftpParameters: values, + }); + }); +}); diff --git a/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.ts b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.ts new file mode 100644 index 00000000000..dd3e1d32c16 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.schema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { Messages } from './SendToSupport.messages'; + +export const sendToSupportSchema = z.object({ + address: z.string().trim().min(1, Messages.required), + user: z.string().trim().min(1, Messages.required), + password: z.string().min(1, Messages.required), + directory: z.string(), +}); + +export type SendToSupportFormValues = z.infer; diff --git a/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.tsx b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.tsx new file mode 100644 index 00000000000..94994197d04 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.tsx @@ -0,0 +1,131 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, +} from '@mui/material'; +import { TextInput } from '@percona/percona-ui'; +import { FC } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { enqueueSnackbar } from 'notistack'; +import { useUploadDumps } from 'hooks/api/useDump'; +import { Messages } from './SendToSupport.messages'; +import { + SendToSupportFormValues, + sendToSupportSchema, +} from './SendToSupport.schema'; +import { + SEND_TO_SUPPORT_DEFAULT_VALUES, + toUploadPayload, +} from './SendToSupport.utils'; + +interface SendToSupportProps { + dumpIds: string[]; + open: boolean; + onClose: () => void; +} + +export const SendToSupport: FC = ({ + dumpIds, + open, + onClose, +}) => { + const { mutateAsync: upload, isPending } = useUploadDumps(); + const methods = useForm({ + resolver: zodResolver(sendToSupportSchema), + defaultValues: SEND_TO_SUPPORT_DEFAULT_VALUES, + mode: 'onChange', + }); + const { + formState: { isDirty, isValid }, + handleSubmit, + reset, + } = methods; + + const close = () => { + reset(SEND_TO_SUPPORT_DEFAULT_VALUES); + onClose(); + }; + + const onSubmit = async (values: SendToSupportFormValues) => { + await upload(toUploadPayload(values, dumpIds), { + onSuccess: () => { + enqueueSnackbar(Messages.success, { variant: 'success' }); + close(); + }, + }); + }; + + return ( + + + + {Messages.title} + + + + + + + + + + + + + + + + ); +}; diff --git a/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.utils.ts b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.utils.ts new file mode 100644 index 00000000000..ff7bf57bfdd --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/components/send-to-support/SendToSupport.utils.ts @@ -0,0 +1,17 @@ +import { UploadDumpsPayload } from 'types/dump.types'; +import { SendToSupportFormValues } from './SendToSupport.schema'; + +export const SEND_TO_SUPPORT_DEFAULT_VALUES: SendToSupportFormValues = { + address: 'sftp.percona.com:2222', + user: '', + password: '', + directory: '', +}; + +export const toUploadPayload = ( + values: SendToSupportFormValues, + dumpIds: string[] +): UploadDumpsPayload => ({ + dumpIds, + sftpParameters: values, +}); diff --git a/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.messages.ts b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.messages.ts new file mode 100644 index 00000000000..9121fc769e6 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.messages.ts @@ -0,0 +1,37 @@ +export const Messages = { + pageTitle: 'Export new dataset', + summary: + 'Simplify troubleshooting and accelerate issue resolution by securely sharing relevant data with Percona Support.', + title: 'Select data to export', + services: 'Service names', + allServices: 'All services', + noServices: 'No services available', + servicesError: 'Could not load services.', + retry: 'Retry', + startTime: 'Start time', + endTime: 'End time', + exportQan: 'Export QAN', + exportQanHelp: + 'Include Query Analytics (QAN) metrics alongside core metrics in the export.', + ignoreLoad: 'Ignore load', + ignoreLoadHelp: + 'Bypass the default resource limit restrictions to export faster.', + enableEncryption: 'Enable encryption', + enableEncryptionHelp: + 'Protect the exported dataset with password-based encryption.', + encryptionPassword: 'Encryption password', + encryptionPasswordPlaceholder: 'Password', + create: 'Create dataset', + creating: 'Creating...', + cancel: 'Cancel', + success: 'Dataset creation started.', + validation: { + validRange: 'Please select a valid time range.', + futureDate: 'Dates cannot be in the future.', + passwordRequired: 'Encryption password is required.', + passwordLength: 'Password must contain at least 8 characters.', + passwordLetter: 'Password must contain a letter.', + passwordNumber: 'Password must contain a number.', + passwordSpecial: 'Password must contain a special character.', + }, +}; diff --git a/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.test.ts b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.test.ts new file mode 100644 index 00000000000..2f838b4470c --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.test.ts @@ -0,0 +1,52 @@ +import { exportDatasetSchema } from './ExportDataset.schema'; +import { getDefaultValues, toStartDumpPayload } from './ExportDataset.utils'; + +describe('exportDatasetSchema', () => { + it('accepts the default unencrypted form', () => { + expect(exportDatasetSchema.safeParse(getDefaultValues()).success).toBe( + true + ); + }); + + it('rejects an invalid date range', () => { + const values = getDefaultValues(); + values.startTime = values.endTime; + + const result = exportDatasetSchema.safeParse(values); + + expect(result.success).toBe(false); + }); + + it('enforces encryption password complexity', () => { + const values = { + ...getDefaultValues(), + enableEncryption: true, + encryptionPassword: 'password', + }; + + const result = exportDatasetSchema.safeParse(values); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['encryptionPassword']); + } + }); + + it('maps valid values to an API payload', () => { + const values = { + ...getDefaultValues(), + serviceNames: ['mysql'], + exportQan: true, + enableEncryption: true, + encryptionPassword: 'Password1!', + }; + + expect(toStartDumpPayload(values)).toMatchObject({ + serviceNames: ['mysql'], + exportQan: true, + ignoreLoad: true, + enableEncryption: true, + encryptionPassword: 'Password1!', + }); + }); +}); diff --git a/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.ts b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.ts new file mode 100644 index 00000000000..0419c71161d --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.schema.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; +import { Messages } from './ExportDataset.messages'; + +export const exportDatasetSchema = z + .object({ + serviceNames: z.array(z.string()), + startTime: z.date(), + endTime: z.date(), + exportQan: z.boolean(), + ignoreLoad: z.boolean(), + enableEncryption: z.boolean(), + encryptionPassword: z.string(), + }) + .superRefine((values, context) => { + const now = new Date(); + if (values.startTime >= values.endTime) { + context.addIssue({ + code: 'custom', + path: ['startTime'], + message: Messages.validation.validRange, + }); + } + if (values.startTime > now) { + context.addIssue({ + code: 'custom', + path: ['startTime'], + message: Messages.validation.futureDate, + }); + } + if (values.endTime > now) { + context.addIssue({ + code: 'custom', + path: ['endTime'], + message: Messages.validation.futureDate, + }); + } + if (!values.enableEncryption) { + return; + } + + const password = values.encryptionPassword; + const validations = [ + [password.length > 0, Messages.validation.passwordRequired], + [password.length >= 8, Messages.validation.passwordLength], + [/[A-Za-z]/.test(password), Messages.validation.passwordLetter], + [/\d/.test(password), Messages.validation.passwordNumber], + [/[^A-Za-z0-9]/.test(password), Messages.validation.passwordSpecial], + ] as const; + + const failed = validations.find(([valid]) => !valid); + if (failed) { + context.addIssue({ + code: 'custom', + path: ['encryptionPassword'], + message: failed[1], + }); + } + }); + +export type ExportDatasetFormValues = z.infer; diff --git a/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.tsx b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.tsx new file mode 100644 index 00000000000..6db3b5546b0 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.tsx @@ -0,0 +1,258 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Alert, + Autocomplete, + Button, + Card, + CardContent, + CircularProgress, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { DateTimePicker } from '@mui/x-date-pickers'; +import { SwitchInput, TextInput } from '@percona/percona-ui'; +import { FC, useMemo } from 'react'; +import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form'; +import { Link as RouterLink, useNavigate } from 'react-router-dom'; +import { enqueueSnackbar } from 'notistack'; +import { Page } from 'components/page'; +import { useUser } from 'contexts/user'; +import { useStartDump } from 'hooks/api/useDump'; +import { useManagedServices } from 'hooks/api/useServices'; +import { OrgRole } from 'types/user.types'; +import { Messages } from './ExportDataset.messages'; +import { + ExportDatasetFormValues, + exportDatasetSchema, +} from './ExportDataset.schema'; +import { + getDefaultValues, + getServiceOptions, + toStartDumpPayload, +} from './ExportDataset.utils'; + +export const ExportDataset: FC = () => { + const { user } = useUser(); + const navigate = useNavigate(); + const { + data, + isLoading: isLoadingServices, + isError: isServicesError, + refetch: refetchServices, + } = useManagedServices({}, { enabled: !!user?.isPMMAdmin }); + const { mutateAsync: startDump, isPending } = useStartDump(); + const serviceOptions = useMemo( + () => getServiceOptions(data?.services ?? []), + [data] + ); + const methods = useForm({ + resolver: zodResolver(exportDatasetSchema), + defaultValues: getDefaultValues(), + mode: 'onChange', + }); + const { + control, + formState: { isValid }, + handleSubmit, + } = methods; + const enableEncryption = useWatch({ control, name: 'enableEncryption' }); + + const onSubmit = async (values: ExportDatasetFormValues) => { + await startDump(toStartDumpPayload(values), { + onSuccess: () => { + enqueueSnackbar(Messages.success, { variant: 'success' }); + navigate('/pmm-dump'); + }, + }); + }; + + return ( + + + + + + {Messages.summary} + {Messages.title} + {isServicesError && ( + refetchServices()}> + {Messages.retry} + + } + > + {Messages.servicesError} + + )} + + ( + + field.value.includes(value) + )} + onChange={(_, selected) => + field.onChange(selected.map(({ value }) => value)) + } + loading={isLoadingServices} + noOptionsText={Messages.noServices} + getOptionLabel={({ label }) => label} + isOptionEqualToValue={(option, value) => + option.value === value.value + } + renderInput={(params) => ( + + {isLoadingServices && ( + + )} + {params.InputProps.endAdornment} + + ), + }, + htmlInput: { + ...params.inputProps, + 'data-testid': 'service-select-input', + }, + }} + /> + )} + /> + )} + /> + + + ( + value && field.onChange(value)} + maxDateTime={new Date()} + slotProps={{ + textField: { + error: !!fieldState.error, + helperText: fieldState.error?.message, + inputProps: { + 'data-testid': 'dump-start-time-input', + }, + }, + }} + /> + )} + /> + ( + value && field.onChange(value)} + maxDateTime={new Date()} + slotProps={{ + textField: { + error: !!fieldState.error, + helperText: fieldState.error?.message, + inputProps: { + 'data-testid': 'dump-end-time-input', + }, + }, + }} + /> + )} + /> + + + + + + + + {Messages.exportQanHelp} + + + + + + {Messages.ignoreLoadHelp} + + + + + + {Messages.enableEncryptionHelp} + + + + {enableEncryption && ( + + )} + + + + + + + + + + + ); +}; diff --git a/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.utils.ts b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.utils.ts new file mode 100644 index 00000000000..5541f167da4 --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/export-dataset/ExportDataset.utils.ts @@ -0,0 +1,43 @@ +import { subHours } from 'date-fns'; +import { StartDumpPayload } from 'types/dump.types'; +import { ManagedService } from 'types/services.types'; +import { ExportDatasetFormValues } from './ExportDataset.schema'; + +export interface ServiceOption { + label: string; + value: string; +} + +export const getDefaultValues = (): ExportDatasetFormValues => { + const endTime = new Date(); + endTime.setSeconds(0, 0); + + return { + serviceNames: [], + startTime: subHours(endTime, 12), + endTime, + exportQan: false, + ignoreLoad: true, + enableEncryption: false, + encryptionPassword: '', + }; +}; + +export const getServiceOptions = ( + services: ManagedService[] +): ServiceOption[] => + Array.from(new Set(services.map(({ serviceName }) => serviceName))) + .sort() + .map((serviceName) => ({ label: serviceName, value: serviceName })); + +export const toStartDumpPayload = ( + values: ExportDatasetFormValues +): StartDumpPayload => ({ + serviceNames: values.serviceNames, + startTime: values.startTime.toISOString(), + endTime: values.endTime.toISOString(), + exportQan: values.exportQan, + ignoreLoad: values.ignoreLoad, + enableEncryption: values.enableEncryption, + encryptionPassword: values.enableEncryption ? values.encryptionPassword : '', +}); diff --git a/ui/apps/pmm/src/pages/dump/index.ts b/ui/apps/pmm/src/pages/dump/index.ts new file mode 100644 index 00000000000..d497d09997d --- /dev/null +++ b/ui/apps/pmm/src/pages/dump/index.ts @@ -0,0 +1,2 @@ +export { DumpPage } from './DumpPage'; +export { ExportDataset } from './export-dataset/ExportDataset'; diff --git a/ui/apps/pmm/src/pages/help-center/HelpCenter.constants.ts b/ui/apps/pmm/src/pages/help-center/HelpCenter.constants.ts index bf8691ac0b0..730f6803f1e 100644 --- a/ui/apps/pmm/src/pages/help-center/HelpCenter.constants.ts +++ b/ui/apps/pmm/src/pages/help-center/HelpCenter.constants.ts @@ -1,4 +1,4 @@ -import { PMM_NEW_NAV_GRAFANA_PATH } from 'lib/constants'; +import { PMM_NEW_NAV_PATH } from 'lib/constants'; import { HelpCard } from './help-center-card/HelpCenterCard.types'; export const CARD_IDS = { @@ -74,7 +74,7 @@ export const getCardData = ({ buttons: [ { text: 'Manage datasets', - to: `${PMM_NEW_NAV_GRAFANA_PATH}/pmm-dump`, + to: `${PMM_NEW_NAV_PATH}/pmm-dump`, }, ], adminOnly: true, diff --git a/ui/apps/pmm/src/pages/help-center/HelpCenter.test.tsx b/ui/apps/pmm/src/pages/help-center/HelpCenter.test.tsx index a5bba3e0b95..be7f64fb47f 100644 --- a/ui/apps/pmm/src/pages/help-center/HelpCenter.test.tsx +++ b/ui/apps/pmm/src/pages/help-center/HelpCenter.test.tsx @@ -44,6 +44,9 @@ describe('HelpCenter', () => { expect( screen.queryByTestId(`help-card-${CARD_IDS.pmmLogs}`) ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Manage datasets' }) + ).toHaveAttribute('href', '/pmm-dump'); expect(screen.queryAllByTestId(/^help-card-/).length).toEqual(7); }); diff --git a/ui/apps/pmm/src/pages/settings/Settings.tsx b/ui/apps/pmm/src/pages/settings/Settings.tsx index 2632aba112f..9e504182ff4 100644 --- a/ui/apps/pmm/src/pages/settings/Settings.tsx +++ b/ui/apps/pmm/src/pages/settings/Settings.tsx @@ -40,12 +40,7 @@ export const Settings: FC = () => { const setTab = (value: TabValue) => navigate(`/settings/${value}`); return ( - + , }, + { + path: 'pmm-dump', + children: [ + { + path: '', + element: , + }, + { + path: 'new', + element: , + }, + ], + }, { path: 'settings/:tab?', element: , diff --git a/ui/apps/pmm/src/types/dump.types.ts b/ui/apps/pmm/src/types/dump.types.ts new file mode 100644 index 00000000000..f2e770fbb04 --- /dev/null +++ b/ui/apps/pmm/src/types/dump.types.ts @@ -0,0 +1,66 @@ +export enum DumpStatus { + Unspecified = 'DUMP_STATUS_UNSPECIFIED', + InProgress = 'DUMP_STATUS_IN_PROGRESS', + Success = 'DUMP_STATUS_SUCCESS', + Error = 'DUMP_STATUS_ERROR', +} + +export interface Dump { + dumpId: string; + status: DumpStatus; + serviceNames: string[]; + startTime: string; + endTime: string; + createdAt: string; + encrypted: boolean; +} + +export interface ListDumpsResponse { + dumps: Dump[]; +} + +export interface StartDumpPayload { + serviceNames: string[]; + startTime: string; + endTime: string; + exportQan: boolean; + ignoreLoad: boolean; + enableEncryption: boolean; + encryptionPassword: string; +} + +export interface StartDumpResponse { + dumpId: string; +} + +export interface DeleteDumpsPayload { + dumpIds: string[]; +} + +export interface DumpLogChunk { + chunkId: number; + data: string; +} + +export interface GetDumpLogsParams { + dumpId: string; + offset: number; + limit: number; +} + +export interface GetDumpLogsResponse { + logs: DumpLogChunk[]; + end: boolean; +} + +export interface SftpParameters { + address: string; + user: string; + password: string; + directory: string; +} + +export interface UploadDumpsPayload { + dumpIds: string[]; + sftpParameters: SftpParameters; +} diff --git a/ui/apps/pmm/vite.config.ts b/ui/apps/pmm/vite.config.ts index 916f28c09dc..90b5181a505 100644 --- a/ui/apps/pmm/vite.config.ts +++ b/ui/apps/pmm/vite.config.ts @@ -61,6 +61,11 @@ export default defineConfig({ secure: false, changeOrigin: true, }, + '/dump': { + target, + secure: false, + changeOrigin: true, + }, }, host: '0.0.0.0', port,