Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 23 additions & 0 deletions ui/apps/pmm/src/api/__mocks__/dump.ts
Original file line number Diff line number Diff line change
@@ -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,
});
48 changes: 48 additions & 0 deletions ui/apps/pmm/src/api/dump.ts
Original file line number Diff line number Diff line change
@@ -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<ListDumpsResponse> => {
const response = await api.get<ListDumpsResponse>('/dumps');
return response.data;
};

export const startDump = async (
payload: StartDumpPayload
): Promise<StartDumpResponse> => {
const response = await api.post<StartDumpResponse>('/dumps:start', payload);
return response.data;
};

export const deleteDumps = async (
payload: DeleteDumpsPayload
): Promise<EmptyResponse> => {
const response = await api.post<EmptyResponse>('/dumps:batchDelete', payload);
return response.data;
};

export const getDumpLogs = async ({
dumpId,
offset,
limit,
}: GetDumpLogsParams): Promise<GetDumpLogsResponse> => {
const response = await api.get<GetDumpLogsResponse>(`/dumps/${dumpId}/logs`, {
params: { offset, limit },
});
return response.data;
};

export const uploadDumps = async (
payload: UploadDumpsPayload
): Promise<EmptyResponse> => {
const response = await api.post<EmptyResponse>('/dumps:upload', payload);
return response.data;
};
4 changes: 2 additions & 2 deletions ui/apps/pmm/src/components/page/Page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,13 @@ export const Page: FC<PageProps> = ({
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,
Expand Down
41 changes: 41 additions & 0 deletions ui/apps/pmm/src/hooks/api/useDump.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
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'],
})
);
});
});
100 changes: 100 additions & 0 deletions ui/apps/pmm/src/hooks/api/useDump.ts
Original file line number Diff line number Diff line change
@@ -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<UseQueryOptions<ListDumpsResponse>>
) =>
useQuery({
queryKey: DUMP_QUERY_KEYS.list,
queryFn: listDumps,
refetchInterval: 5_000,
...options,
});

export const useStartDump = (
options?: Partial<
UseMutationOptions<StartDumpResponse, Error, StartDumpPayload>
>
) => {
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<EmptyResponse, Error, DeleteDumpsPayload>
>
) => {
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<UseQueryOptions<GetDumpLogsResponse>>
) =>
useQuery({
queryKey: [...DUMP_QUERY_KEYS.logs(params.dumpId), params.offset],
queryFn: () => getDumpLogs(params),
enabled: !!params.dumpId,
...options,
});

export const useUploadDumps = (
options?: Partial<
UseMutationOptions<EmptyResponse, Error, UploadDumpsPayload>
>
) =>
useMutation({
mutationKey: DUMP_QUERY_KEYS.upload,
mutationFn: uploadDumps,
...options,
});
13 changes: 13 additions & 0 deletions ui/apps/pmm/src/pages/dump/DumpPage.constants.ts
Original file line number Diff line number Diff line change
@@ -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, DumpStatusColor> = {
[DumpStatus.Unspecified]: 'default',
[DumpStatus.InProgress]: 'info',
[DumpStatus.Success]: 'success',
[DumpStatus.Error]: 'error',
};
51 changes: 51 additions & 0 deletions ui/apps/pmm/src/pages/dump/DumpPage.messages.ts
Original file line number Diff line number Diff line change
@@ -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',
},
};
65 changes: 65 additions & 0 deletions ui/apps/pmm/src/pages/dump/DumpPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@percona/percona-ui')>()),
Table: ({
data,
noDataMessage,
}: {
data: unknown[];
noDataMessage: string;
}) => <div data-testid="pmm-dump-table">{data.length || noDataMessage}</div>,
}));

describe('DumpPage', () => {
beforeEach(() => {
mocks.useDumps.mockReturnValue({
data: { dumps: [] },
isLoading: false,
isError: false,
});
});

it('shows the empty inventory and create action to admins', () => {
render(
<TestWrapper>
<DumpPage />
</TestWrapper>
);

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(
<TestWrapper userContext={{ user: TEST_USER_VIEWER, isLoading: false }}>
<DumpPage />
</TestWrapper>
);

expect(screen.getByTestId('unauthorized')).toBeInTheDocument();
expect(screen.queryByTestId('pmm-dump-page')).not.toBeInTheDocument();
expect(mocks.useDumps).toHaveBeenCalledWith({ enabled: false });
});
});
Loading
Loading