From 79e627104103ff9746512f524e39d6d7a7adc845 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 9 Dec 2025 15:27:20 +0200 Subject: [PATCH] refactor(docker/images): migrate list view to react [BE-6562] (#1451) --- app/docker/__module.js | 3 +- app/docker/react/components/index.ts | 11 - app/docker/react/views/images.ts | 13 + app/docker/react/views/index.ts | 7 +- .../views/images/edit/imageController.js | 2 +- app/docker/views/images/images.html | 54 -- app/docker/views/images/imagesController.js | 177 ------ app/react-tools/test-mocks.ts | 30 +- .../findRegistryMatch.test.ts | 6 +- .../ImageConfigFieldset}/findRegistryMatch.ts | 9 +- .../ImageConfigFieldset}/getImageConfig.ts | 8 +- .../AutocompleteSelect/AutocompleteSelect.tsx | 5 +- app/react/docker/agent/NodeSelector.tsx | 5 +- .../CreateView/BaseForm/toViewModel.ts | 3 +- .../containers/CreateView/useInitialValues.ts | 2 +- .../ImagesDatatable/ImagesDatatable.test.tsx | 329 +++++++++++ .../ImagesDatatable/ImagesDatatable.tsx | 121 +--- .../ImportExportButtons.test.tsx | 515 ++++++++++++++++++ .../ImagesDatatable/ImportExportButtons.tsx | 95 ++++ .../ImagesDatatable/RemoveButtonMenu.tsx | 122 +++++ .../docker/images/ListView/ListView.test.tsx | 96 ++++ app/react/docker/images/ListView/ListView.tsx | 24 + .../ListView/PullImageFormWidget.Form.tsx | 54 ++ .../ListView/PullImageFormWidget.test.tsx | 175 ++++++ .../images/ListView/PullImageFormWidget.tsx | 77 +++ .../ListView/PullImageFormWidget.types.tsx | 6 + .../PullImageFormWidget.validation.test.tsx | 30 + .../PullImageFormWidget.validation.ts | 26 + .../images/common/ConfirmExportModal.tsx | 8 +- app/react/docker/images/queries/queryKeys.ts | 2 +- .../images/queries/useDeleteImageMutation.ts | 46 ++ .../queries/useExportImageMutation.test.ts | 353 ++++++++++++ .../images/queries/useExportImageMutation.ts | 71 +++ .../images/queries/usePullImageMutation.ts | 31 ++ app/react/docker/images/utils.ts | 4 + .../docker/proxy/queries/useIsSwarmAgent.ts | 17 + app/setup-tests/setup-handlers/docker.ts | 3 + .../setup-handlers/docker/images.ts | 5 + app/setup-tests/setup-handlers/endpoints.ts | 8 +- 39 files changed, 2164 insertions(+), 389 deletions(-) create mode 100644 app/docker/react/views/images.ts delete mode 100644 app/docker/views/images/images.html delete mode 100644 app/docker/views/images/imagesController.js rename app/react/{portainer/registries/utils => components/ImageConfigFieldset}/findRegistryMatch.test.ts (95%) rename app/react/{portainer/registries/utils => components/ImageConfigFieldset}/findRegistryMatch.ts (85%) rename app/react/{portainer/registries/utils => components/ImageConfigFieldset}/getImageConfig.ts (86%) create mode 100644 app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.test.tsx create mode 100644 app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.test.tsx create mode 100644 app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx create mode 100644 app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx create mode 100644 app/react/docker/images/ListView/ListView.test.tsx create mode 100644 app/react/docker/images/ListView/ListView.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.Form.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.test.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.types.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx create mode 100644 app/react/docker/images/ListView/PullImageFormWidget.validation.ts create mode 100644 app/react/docker/images/queries/useDeleteImageMutation.ts create mode 100644 app/react/docker/images/queries/useExportImageMutation.test.ts create mode 100644 app/react/docker/images/queries/useExportImageMutation.ts create mode 100644 app/react/docker/proxy/queries/useIsSwarmAgent.ts create mode 100644 app/setup-tests/setup-handlers/docker/images.ts diff --git a/app/docker/__module.js b/app/docker/__module.js index 1c108ecbf0..fbf1980b9a 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -199,8 +199,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([ url: '/images', views: { 'content@': { - templateUrl: './views/images/images.html', - controller: 'ImagesController', + component: 'imagesListView', }, }, data: { diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index d111e27bf8..a281765f9c 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -11,7 +11,6 @@ import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus'; import { GpusList } from '@/react/docker/host/SetupView/GpusList'; import { InsightsBox } from '@/react/components/InsightsBox'; import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert'; -import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable'; import { EventsDatatable } from '@/react/docker/events/EventsDatatables'; import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser'; import { AgentVolumeBrowser } from '@/react/docker/volumes/BrowseView/AgentVolumeBrowser'; @@ -68,16 +67,6 @@ const ngModule = angular ]) ) .component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml'])) - .component( - 'dockerImagesDatatable', - r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [ - 'onRemove', - 'isExportInProgress', - 'isHostColumnVisible', - 'onDownload', - 'onRemove', - ]) - ) .component( 'agentHostBrowserReact', r2a(withUIRouter(withCurrentUser(AgentHostBrowser)), [ diff --git a/app/docker/react/views/images.ts b/app/docker/react/views/images.ts new file mode 100644 index 0000000000..c486c111a0 --- /dev/null +++ b/app/docker/react/views/images.ts @@ -0,0 +1,13 @@ +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ListView } from '@/react/docker/images/ListView/ListView'; + +export const imagesModule = angular + .module('portainer.docker.react.views.images', []) + .component( + 'imagesListView', + r2a(withUIRouter(withCurrentUser(ListView)), []) + ).name; diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts index 3bd94fc827..b63ba67be3 100644 --- a/app/docker/react/views/index.ts +++ b/app/docker/react/views/index.ts @@ -9,9 +9,14 @@ import { ListView } from '@/react/docker/events/ListView'; import { containersModule } from './containers'; import { configsModule } from './configs'; +import { imagesModule } from './images'; export const viewsModule = angular - .module('portainer.docker.react.views', [containersModule, configsModule]) + .module('portainer.docker.react.views', [ + containersModule, + configsModule, + imagesModule, + ]) .component( 'dockerDashboardView', r2a(withUIRouter(withCurrentUser(DashboardView)), []) diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js index 8ed5c94959..898fe3920c 100644 --- a/app/docker/views/images/edit/imageController.js +++ b/app/docker/views/images/edit/imageController.js @@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [ return; } - confirmImageExport(function (confirmed) { + confirmImageExport().then(function (confirmed) { if (!confirmed) { return; } diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html deleted file mode 100644 index c92ffc7870..0000000000 --- a/app/docker/views/images/images.html +++ /dev/null @@ -1,54 +0,0 @@ - - -
-
- - - -
- - -
-
Deployment
- - - -
-
-
- -
-
-
- -
-
-
-
-
- - diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js deleted file mode 100644 index 370d055121..0000000000 --- a/app/docker/views/images/imagesController.js +++ /dev/null @@ -1,177 +0,0 @@ -import _ from 'lodash-es'; -import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; -import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal'; -import { confirmDestructive } from '@@/modals/confirm'; -import { buildConfirmButton } from '@@/modals/utils'; -import { processItemsInBatches } from '@/react/common/processItemsInBatches'; - -angular.module('portainer.docker').controller('ImagesController', [ - '$scope', - '$state', - 'Authentication', - 'ImageService', - 'Notifications', - 'HttpRequestHelper', - 'FileSaver', - 'Blob', - 'endpoint', - '$async', - function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint) { - $scope.endpoint = endpoint; - $scope.isAdmin = Authentication.isAdmin(); - - $scope.state = { - actionInProgress: false, - exportInProgress: false, - pullRateValid: false, - }; - - $scope.formValues = { - RegistryModel: new PorImageRegistryModel(), - NodeName: null, - }; - - $scope.pullImage = function () { - const registryModel = $scope.formValues.RegistryModel; - - var nodeName = $scope.formValues.NodeName; - - $scope.state.actionInProgress = true; - ImageService.pullImage(registryModel, nodeName) - .then(function success() { - Notifications.success('Image successfully pulled', registryModel.Image); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to pull image'); - }) - .finally(function final() { - $scope.state.actionInProgress = false; - }); - }; - - function confirmImageForceRemoval() { - return confirmDestructive({ - title: 'Are you sure?', - message: - "Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?", - confirmButton: buildConfirmButton('Remove the image', 'danger'), - }); - } - - function confirmRegularRemove() { - return confirmDestructive({ - title: 'Are you sure?', - message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?', - confirmButton: buildConfirmButton('Remove the image', 'danger'), - }); - } - - /** - * - * @param {Array} selectedItems - * @param {boolean} force - */ - $scope.confirmRemovalAction = async function (selectedItems, force) { - const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove()); - - if (!confirmed) { - return; - } - - $scope.removeAction(selectedItems, force); - }; - - /** - * - * @param {Array} selectedItems - */ - function isAuthorizedToDownload(selectedItems) { - for (var i = 0; i < selectedItems.length; i++) { - var image = selectedItems[i]; - - var untagged = _.find(image.tags, function (item) { - return item.indexOf('') > -1; - }); - - if (untagged) { - Notifications.warning('', 'Cannot download a untagged image'); - return false; - } - } - - if (_.uniqBy(selectedItems, 'NodeName').length > 1) { - Notifications.warning('', 'Cannot download images from different nodes at the same time'); - return false; - } - - return true; - } - - /** - * - * @param {Array} images - */ - function exportImages(images) { - HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName); - $scope.state.exportInProgress = true; - ImageService.downloadImages(images) - .then(function success(data) { - var downloadData = new Blob([data], { type: 'application/x-tar' }); - FileSaver.saveAs(downloadData, 'images.tar'); - Notifications.success('Success', 'Image(s) successfully downloaded'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to download image(s)'); - }) - .finally(function final() { - $scope.state.exportInProgress = false; - }); - } - - /** - * - * @param {Array} selectedItems - */ - $scope.downloadAction = function (selectedItems) { - if (!isAuthorizedToDownload(selectedItems)) { - return; - } - - confirmImageExport(function (confirmed) { - if (!confirmed) { - return; - } - exportImages(selectedItems); - }); - }; - - $scope.removeAction = removeAction; - - /** - * - * @param {Array} selectedItems - * @param {boolean} force - */ - async function removeAction(selectedItems, force) { - async function doRemove(image) { - HttpRequestHelper.setPortainerAgentTargetHeader(image.nodeName); - return ImageService.deleteImage(image.id, force) - .then(function success() { - Notifications.success('Image successfully removed', image.id); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to remove image'); - }); - } - - await processItemsInBatches(selectedItems, doRemove); - $state.reload(); - } - - $scope.setPullImageValidity = setPullImageValidity; - function setPullImageValidity(validity) { - $scope.state.pullRateValid = validity; - } - }, -]); diff --git a/app/react-tools/test-mocks.ts b/app/react-tools/test-mocks.ts index d9c7d273c7..ee553f415a 100644 --- a/app/react-tools/test-mocks.ts +++ b/app/react-tools/test-mocks.ts @@ -8,14 +8,10 @@ import { Environment, } from '@/react/portainer/environments/types'; -export function createMockUsers( - count: number, - roles: Role | Role[] | ((id: UserId) => Role) -): User[] { - return _.range(1, count + 1).map((value) => ({ - Id: value, - Username: `user${value}`, - Role: getRoles(roles, value), +export function createMockUser(overrides: Partial = {}) { + return { + Id: 1, + Username: 'user', RoleName: '', AuthenticationMethod: '', Checked: false, @@ -24,8 +20,24 @@ export function createMockUsers( UseCache: false, ThemeSettings: { color: 'auto', + subtleUpgradeButton: false, + ...overrides.ThemeSettings, }, - })); + ...overrides, + } as User; +} + +export function createMockUsers( + count: number, + roles: Role | Role[] | ((id: UserId) => Role) +): User[] { + return _.range(1, count + 1).map((value) => + createMockUser({ + Id: value, + Username: `user${value}`, + Role: getRoles(roles, value), + }) + ); } function getRoles( diff --git a/app/react/portainer/registries/utils/findRegistryMatch.test.ts b/app/react/components/ImageConfigFieldset/findRegistryMatch.test.ts similarity index 95% rename from app/react/portainer/registries/utils/findRegistryMatch.test.ts rename to app/react/components/ImageConfigFieldset/findRegistryMatch.test.ts index af3c9f3b70..0a54e34b31 100644 --- a/app/react/portainer/registries/utils/findRegistryMatch.test.ts +++ b/app/react/components/ImageConfigFieldset/findRegistryMatch.test.ts @@ -1,4 +1,8 @@ -import { Registry, RegistryId, RegistryTypes } from '../types/registry'; +import { + Registry, + RegistryId, + RegistryTypes, +} from '../../portainer/registries/types/registry'; import { findBestMatchRegistry } from './findRegistryMatch'; diff --git a/app/react/portainer/registries/utils/findRegistryMatch.ts b/app/react/components/ImageConfigFieldset/findRegistryMatch.ts similarity index 85% rename from app/react/portainer/registries/utils/findRegistryMatch.ts rename to app/react/components/ImageConfigFieldset/findRegistryMatch.ts index e32fe6e524..145585adb9 100644 --- a/app/react/portainer/registries/utils/findRegistryMatch.ts +++ b/app/react/components/ImageConfigFieldset/findRegistryMatch.ts @@ -1,6 +1,9 @@ -import { Registry, RegistryId, RegistryTypes } from '../types/registry'; - -import { getURL } from './getUrl'; +import { + Registry, + RegistryId, + RegistryTypes, +} from '../../portainer/registries/types/registry'; +import { getURL } from '../../portainer/registries/utils/getUrl'; /** * findBestMatchRegistry finds out the best match registry for repository diff --git a/app/react/portainer/registries/utils/getImageConfig.ts b/app/react/components/ImageConfigFieldset/getImageConfig.ts similarity index 86% rename from app/react/portainer/registries/utils/getImageConfig.ts rename to app/react/components/ImageConfigFieldset/getImageConfig.ts index 3beeab9eae..e431c67cc6 100644 --- a/app/react/portainer/registries/utils/getImageConfig.ts +++ b/app/react/components/ImageConfigFieldset/getImageConfig.ts @@ -1,11 +1,13 @@ import { imageContainsURL } from '@/react/docker/images/utils'; +import { + Registry, + RegistryId, +} from '@/react/portainer/registries/types/registry'; +import { getURL } from '@/react/portainer/registries/utils/getUrl'; import { ImageConfigValues } from '@@/ImageConfigFieldset'; -import { Registry, RegistryId } from '../types/registry'; - import { findBestMatchRegistry } from './findRegistryMatch'; -import { getURL } from './getUrl'; export function getDefaultImageConfig(): ImageConfigValues { return { diff --git a/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx b/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx index 07fc5def05..8586fe617d 100644 --- a/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx +++ b/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx @@ -43,14 +43,13 @@ export function AutocompleteSelect({ return ( void; + error?: FormikErrors; }) { const environmentId = useEnvironmentId(); @@ -36,7 +39,7 @@ export function NodeSelector({ }, [nodesQuery.data, onChange, value]); return ( - + Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: '1' }, + })), +})); + +vi.mock('@@/Link', () => ({ + Link: ({ + children, + 'data-cy': dataCy, + }: { + children: React.ReactNode; + 'data-cy'?: string; + }) => ( + // eslint-disable-next-line jsx-a11y/anchor-is-valid + {children} + ), +})); + +// Mock child components to simplify testing +vi.mock('./RemoveButtonMenu', () => ({ + RemoveButtonMenu: ({ + selectedItems, + }: { + selectedItems: ImagesListResponse[]; + }) => ( + + ), +})); + +vi.mock('./ImportExportButtons', () => ({ + ImportExportButtons: ({ + selectedItems, + }: { + selectedItems: ImagesListResponse[]; + }) => ( +
+ Import/Export ({selectedItems.length}) +
+ ), +})); + +describe('ImagesDatatable', () => { + describe('Data Fetching and Display', () => { + it('should fetch and display images list', async () => { + const mockImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + createMockImage({ id: 'sha256:def456', tags: ['redis:alpine'] }), + ]; + + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json(mockImages) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByText(/nginx:latest/)).toBeVisible(); + expect(screen.getByText(/redis:alpine/)).toBeVisible(); + }); + }); + + it('should show loading state while fetching images', () => { + server.use( + http.get('/api/docker/:envId/images', async () => { + // Never resolve to simulate loading state + await new Promise(() => {}); + return HttpResponse.json([]); + }) + ); + + renderComponent({ isHostColumnVisible: false }); + + expect(screen.getByText(/Loading/)).toBeVisible(); + }); + + it('should handle empty images list', async () => { + server.use( + http.get('/api/docker/:envId/images', () => HttpResponse.json([])) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByText(/No items available./i)).toBeInTheDocument(); + }); + }); + }); + + describe('Column Visibility', () => { + it('should show host column when isHostColumnVisible=true', async () => { + const mockImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: ['nginx:latest'], + nodeName: 'worker-node-1', + }), + createMockImage({ + id: 'sha256:def456', + tags: ['redis:alpine'], + nodeName: 'worker-node-2', + }), + ]; + + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json(mockImages) + ) + ); + + renderComponent({ isHostColumnVisible: true }); + + await waitFor(() => { + expect(screen.getByText('worker-node-1')).toBeVisible(); + expect(screen.getByText('worker-node-2')).toBeVisible(); + }); + }); + + it('should hide host column when isHostColumnVisible=false', async () => { + const mockImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: ['nginx:latest'], + nodeName: 'worker-node-1', + }), + ]; + + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json(mockImages) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByText('nginx:latest')).toBeVisible(); + expect(screen.queryByText('worker-node-1')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Table Actions', () => { + it('should render RemoveButtonMenu', async () => { + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json([createMockImage()]) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByTestId('remove-button-menu')).toBeVisible(); + }); + }); + + it('should render ImportExportButtons', async () => { + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json([createMockImage()]) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByTestId('import-export-buttons')).toBeVisible(); + }); + }); + + it('should render Build Image button when user has DockerImageBuild authorization', async () => { + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json([createMockImage()]) + ) + ); + + renderComponent( + { isHostColumnVisible: false }, + createMockUser({ + Role: Role.Standard, + EndpointAuthorizations: { + 1: { DockerImageBuild: true }, + }, + }) + ); + + await waitFor(() => { + const buildButton = screen.getByText(/Build a new image/i); + + expect(buildButton).toBeVisible(); + expect(buildButton).toHaveAttribute( + 'data-cy', + 'image-buildImageButton' + ); + }); + }); + + it('should hide Build Image button when user lacks DockerImageBuild authorization', async () => { + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json([createMockImage()]) + ) + ); + + renderComponent( + { isHostColumnVisible: false }, + createMockUser({ + Role: Role.Standard, + EndpointAuthorizations: { + 1: { DockerImageBuild: false }, + }, + }) + ); + + await waitFor(() => { + expect( + screen.queryByText(/Build a new image/i) + ).not.toBeInTheDocument(); + }); + }); + }); + + describe('Image Data Display', () => { + it('should display image id, tags, size, and created date', async () => { + const mockImages = [ + createMockImage({ + id: 'sha256:abcdef123456', + tags: ['nginx:1.21', 'nginx:latest'], + size: 142000000, + created: 1704067200, // 2024-01-01 00:00:00 UTC + }), + ]; + + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json(mockImages) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + // Image ID (shortened) + expect(screen.getByText(/abcdef123456/)).toBeVisible(); + + // Tags + expect(screen.getByText(/nginx:1.21/)).toBeVisible(); + expect(screen.getByText(/nginx:latest/)).toBeVisible(); + }); + }); + + it('should handle images without tags', async () => { + const mockImages = [ + createMockImage({ + id: 'sha256:untagged123', + tags: undefined, + }), + ]; + + server.use( + http.get('/api/docker/:envId/images', () => + HttpResponse.json(mockImages) + ) + ); + + renderComponent({ isHostColumnVisible: false }); + + await waitFor(() => { + expect(screen.getByText(/untagged123/)).toBeVisible(); + }); + }); + }); +}); + +function createMockImage( + overrides?: Partial +): ImagesListResponse { + return { + id: 'sha256:default123', + tags: ['test:latest'], + size: 100000000, + created: 1704067200, + used: false, + nodeName: undefined, + ...overrides, + }; +} + +function renderComponent( + { isHostColumnVisible }: { isHostColumnVisible: boolean }, + user: User = createMockUser() +) { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(ImagesDatatable), user) + ); + + const rendered = render( + + ); + + expect(screen.getByTestId('docker-images-datatable')).toBeVisible(); + + return rendered; +} diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx index 3bffb13610..3a71cc8097 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx @@ -1,6 +1,4 @@ -import { ChevronDown, Download, List, Trash2, Upload } from 'lucide-react'; -import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button'; -import { positionRight } from '@reach/popover'; +import { List } from 'lucide-react'; import { useMemo } from 'react'; import { Authorized } from '@/react/hooks/useUser'; @@ -16,17 +14,17 @@ import { RefreshableTableSettings, } from '@@/datatables/types'; import { useTableState } from '@@/datatables/useTableState'; -import { AddButton, Button, ButtonGroup, LoadingButton } from '@@/buttons'; -import { Link } from '@@/Link'; -import { ButtonWithRef } from '@@/buttons/Button'; +import { AddButton } from '@@/buttons'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { mergeOptions } from '@@/datatables/extend-options/mergeOptions'; import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters'; -import { ImagesListResponse, useImages } from '../../queries/useImages'; +import { useImages } from '../../queries/useImages'; import { columns as defColumns } from './columns'; import { host as hostColumn } from './columns/host'; +import { RemoveButtonMenu } from './RemoveButtonMenu'; +import { ImportExportButtons } from './ImportExportButtons'; const tableKey = 'images'; @@ -46,15 +44,8 @@ const settingsStore = createPersistedStore( export function ImagesDatatable({ isHostColumnVisible, - isExportInProgress, - onDownload, - onRemove, }: { isHostColumnVisible: boolean; - - onDownload: (images: Array) => void; - onRemove: (images: Array, force: true) => void; - isExportInProgress: boolean; }) { const environmentId = useEnvironmentId(); const tableState = useTableState(settingsStore, tableKey); @@ -76,13 +67,9 @@ export function ImagesDatatable({ )} renderTableActions={(selectedItems) => (
- + - + ); } - -function RemoveButtonMenu({ - onRemove, - selectedItems, -}: { - selectedItems: Array; - onRemove(selectedItems: Array, force: boolean): void; -}) { - return ( - - - - - - Toggle Dropdown - - -
- { - onRemove(selectedItems, true); - }} - > - Force Remove - -
-
-
-
-
- ); -} - -function ImportExportButtons({ - isExportInProgress, - selectedItems, - onExportClick, -}: { - isExportInProgress: boolean; - selectedItems: Array; - onExportClick(selectedItems: Array): void; -}) { - return ( - - - - - - onExportClick(selectedItems)} - disabled={selectedItems.length === 0} - > - Export - - - - ); -} diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.test.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.test.tsx new file mode 100644 index 0000000000..f04d3aea42 --- /dev/null +++ b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.test.tsx @@ -0,0 +1,515 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http } from 'msw'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { server } from '@/setup-tests/server'; +import { createMockUser } from '@/react-tools/test-mocks'; +import { Role } from '@/portainer/users/types'; + +import { ImagesListResponse } from '../../queries/useImages'; + +import { ImportExportButtons } from './ImportExportButtons'; + +// Use vi.hoisted to ensure mocks are available before imports +const { mockConfirmImageExport, mockNotifyWarning, mockSaveAs } = vi.hoisted( + () => ({ + mockConfirmImageExport: vi.fn(), + mockNotifyWarning: vi.fn(), + mockSaveAs: vi.fn(), + }) +); + +vi.mock('file-saver', () => ({ + saveAs: mockSaveAs, +})); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: '1' }, + })), +})); + +vi.mock('@@/Link', () => ({ + Link: ({ + children, + 'data-cy': dataCy, + }: { + children: React.ReactNode; + 'data-cy'?: string; + }) => ( + + {children} + + ), +})); + +// Mock the confirm modal +vi.mock('../../common/ConfirmExportModal', () => ({ + confirmImageExport: mockConfirmImageExport, +})); + +// Mock the notification service +vi.mock('@/portainer/services/notifications', () => ({ + notifyWarning: mockNotifyWarning, +})); + +describe('ImportExportButtons', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfirmImageExport.mockResolvedValue(false); + }); + + describe('Authorization', () => { + it('should render Import button when user has DockerImageLoad authorization', async () => { + renderComponent( + [], + createMockUser({ + Role: Role.Standard, + EndpointAuthorizations: { + 1: { DockerImageLoad: true }, + }, + }) + ); + + await waitFor(() => { + expect(screen.getByRole('link', { name: /import/i })).toBeVisible(); + }); + }); + + it('should render Export button when user has DockerImageGet authorization', async () => { + renderComponent( + [], + createMockUser({ + Role: Role.Standard, + EndpointAuthorizations: { + 1: { DockerImageGet: true }, + }, + }) + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /export/i })).toBeVisible(); + }); + }); + }); + + describe('Export Button State', () => { + it('should disable Export button when no images are selected', async () => { + renderComponent( + [], + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + await waitFor(() => { + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).toBeDisabled(); + }); + }); + + it('should enable Export button when images are selected', async () => { + const selectedImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + ]; + + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + await waitFor(() => { + const exportButton = screen.getByRole('button', { name: /export/i }); + expect(exportButton).not.toBeDisabled(); + }); + }); + + it('should disable Export button while export is in progress', async () => { + mockConfirmImageExport.mockResolvedValue(true); + + const selectedImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + ]; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', async () => { + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + // Button should show loading state + await waitFor(() => { + expect(screen.getByText('Export in progress...')).toBeVisible(); + }); + }); + }); + + describe('Export Validation', () => { + it('should prevent export of untagged images', async () => { + const selectedImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: [''], + }), + ]; + + let apiCalled = false; + server.use( + http.get('/api/endpoints/:envId/docker/images/get', () => { + apiCalled = true; + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + expect(mockNotifyWarning).toHaveBeenCalledWith( + '', + 'Cannot download an untagged image' + ); + expect(apiCalled).toBe(false); + expect(mockConfirmImageExport).not.toHaveBeenCalled(); + expect(mockSaveAs).not.toHaveBeenCalled(); + }); + + it('should prevent export of images from different nodes', async () => { + const selectedImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: ['nginx:latest'], + nodeName: 'node-1', + }), + createMockImage({ + id: 'sha256:def456', + tags: ['redis:alpine'], + nodeName: 'node-2', + }), + ]; + + let apiCalled = false; + server.use( + http.get('/api/endpoints/:envId/docker/images/get', () => { + apiCalled = true; + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + expect(mockNotifyWarning).toHaveBeenCalledWith( + '', + 'Cannot download images from different nodes at the same time' + ); + expect(apiCalled).toBe(false); + expect(mockConfirmImageExport).not.toHaveBeenCalled(); + }); + + it('should allow export of images from the same node', async () => { + mockConfirmImageExport.mockResolvedValue(true); + + const selectedImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: ['nginx:latest'], + nodeName: 'node-1', + }), + createMockImage({ + id: 'sha256:def456', + tags: ['redis:alpine'], + nodeName: 'node-1', + }), + ]; + + server.use( + http.get( + '/api/endpoints/:envId/docker/images/get', + () => + new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=images.tar', + }, + }) + ) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + await waitFor(() => { + expect(mockSaveAs).toHaveBeenCalled(); + }); + }); + }); + + describe('Export Confirmation Flow', () => { + it('should show confirmation modal before exporting', async () => { + mockConfirmImageExport.mockResolvedValue(true); + + const selectedImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + ]; + + server.use( + http.get( + '/api/endpoints/:envId/docker/images/get', + () => + new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }) + ) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + expect(mockConfirmImageExport).toHaveBeenCalled(); + + await waitFor(() => { + expect(mockSaveAs).toHaveBeenCalled(); + }); + }); + + it('should not export when user cancels confirmation', async () => { + mockConfirmImageExport.mockResolvedValue(false); + + const selectedImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + ]; + + let apiCalled = false; + server.use( + http.get('/api/endpoints/:envId/docker/images/get', () => { + apiCalled = true; + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + expect(mockConfirmImageExport).toHaveBeenCalled(); + expect(apiCalled).toBe(false); + expect(mockSaveAs).not.toHaveBeenCalled(); + }); + }); + + describe('Export Success', () => { + it('should successfully export selected images', async () => { + mockConfirmImageExport.mockResolvedValue(true); + + const selectedImages = [ + createMockImage({ id: 'sha256:abc123', tags: ['nginx:latest'] }), + createMockImage({ id: 'sha256:def456', tags: ['redis:alpine'] }), + ]; + + let requestUrl = ''; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestUrl = request.url; + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=images.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + await waitFor(() => { + expect(mockSaveAs).toHaveBeenCalled(); + }); + + // Verify the request was made with image names + expect(requestUrl).toContain('names'); + }); + + it('should include nodeName in request when exporting from swarm node', async () => { + mockConfirmImageExport.mockResolvedValue(true); + + const selectedImages = [ + createMockImage({ + id: 'sha256:abc123', + tags: ['nginx:latest'], + nodeName: 'worker-node-1', + }), + ]; + + let requestHeaders: Record = {}; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestHeaders = Object.fromEntries(request.headers.entries()); + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + const user = userEvent.setup(); + renderComponent( + selectedImages, + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageGet: true } }, + }) + ); + + const exportButton = await waitFor(() => + screen.getByRole('button', { name: /export/i }) + ); + await user.click(exportButton); + + await waitFor(() => { + expect(mockSaveAs).toHaveBeenCalled(); + }); + + expect(requestHeaders['x-portaineragent-target']).toBe('worker-node-1'); + }); + }); + + describe('Import Button', () => { + it('should link to import page', async () => { + renderComponent( + [], + createMockUser({ + EndpointAuthorizations: { 1: { DockerImageLoad: true } }, + }) + ); + + await waitFor(() => { + expect(screen.getByRole('link', { name: /import/i })).toBeVisible(); + }); + }); + }); +}); + +function createMockImage( + overrides?: Partial +): ImagesListResponse { + return { + id: 'sha256:default123', + tags: ['test:latest'], + size: 100000000, + created: 1704067200, + used: false, + nodeName: undefined, + ...overrides, + }; +} + +function renderComponent( + selectedItems: ImagesListResponse[], + user = createMockUser() +) { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(ImportExportButtons), user) + ); + + return render(); +} diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx new file mode 100644 index 0000000000..1546caf393 --- /dev/null +++ b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx @@ -0,0 +1,95 @@ +import { Download, Upload } from 'lucide-react'; +import _ from 'lodash'; + +import { Authorized } from '@/react/hooks/useUser'; +import { notifyWarning } from '@/portainer/services/notifications'; + +import { Button, ButtonGroup, LoadingButton } from '@@/buttons'; +import { Link } from '@@/Link'; + +import { ImagesListResponse } from '../../queries/useImages'; +import { useExportMutation } from '../../queries/useExportImageMutation'; +import { confirmImageExport } from '../../common/ConfirmExportModal'; + +export function ImportExportButtons({ + selectedItems, +}: { + selectedItems: Array; +}) { + const exportMutation = useExportMutation(); + + return ( + + + + + + handleExport()} + disabled={selectedItems.length === 0} + > + Export + + + + ); + + async function handleExport() { + if (!isValidToDownload(selectedItems)) { + return; + } + + const confirmed = await confirmImageExport(); + + if (!confirmed) { + return; + } + + exportMutation.mutate({ + images: selectedItems, + nodeName: selectedItems[0].nodeName, + }); + } +} + +function isValidToDownload(selectedItems: Array) { + for (let i = 0; i < selectedItems.length; i++) { + const image = selectedItems[i]; + + const untagged = image.tags?.find((item) => item.includes('')); + + if (untagged) { + notifyWarning('', 'Cannot download an untagged image'); + return false; + } + } + + if (_.uniqBy(selectedItems, 'nodeName').length > 1) { + notifyWarning( + '', + 'Cannot download images from different nodes at the same time' + ); + return false; + } + + return true; +} diff --git a/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx b/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx new file mode 100644 index 0000000000..299a9590e2 --- /dev/null +++ b/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx @@ -0,0 +1,122 @@ +import { ChevronDown, Trash2 } from 'lucide-react'; +import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button'; +import { positionRight } from '@reach/popover'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { Authorized } from '@/react/hooks/useUser'; +import { withInvalidate } from '@/react-tools/react-query'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { processItemsInBatches } from '@/react/common/processItemsInBatches'; + +import { Button, ButtonGroup } from '@@/buttons'; +import { ButtonWithRef } from '@@/buttons/Button'; +import { confirmDestructive } from '@@/modals/confirm'; +import { buildConfirmButton } from '@@/modals/utils'; + +import { ImagesListResponse } from '../../queries/useImages'; +import { queryKeys } from '../../queries/queryKeys'; +import { deleteImage } from '../../queries/useDeleteImageMutation'; + +export function RemoveButtonMenu({ + selectedItems, +}: { + selectedItems: Array; +}) { + const deleteImageListMutation = useDeleteImageListMutation(); + + return ( + + + + + + Toggle Dropdown + + +
+ { + handleRemove(true); + }} + > + Force Remove + +
+
+
+
+
+ ); + + function confirmForceRemove() { + return confirmDestructive({ + title: 'Are you sure?', + message: + "Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?", + confirmButton: buildConfirmButton('Remove the image', 'danger'), + }); + } + + function confirmRegularRemove() { + return confirmDestructive({ + title: 'Are you sure?', + message: + 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?', + confirmButton: buildConfirmButton('Remove the image', 'danger'), + }); + } + + async function handleRemove(force: boolean) { + const confirmed = await (force + ? confirmForceRemove() + : confirmRegularRemove()); + + if (!confirmed) { + return; + } + + deleteImageListMutation.mutate({ + imageIds: selectedItems.map((image) => image.id), + force, + }); + } +} + +function useDeleteImageListMutation() { + const environmentId = useEnvironmentId(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + imageIds, + ...args + }: { + imageIds: Array; + } & Omit[0], 'imageId' | 'environmentId'>) => + processItemsInBatches(imageIds, (imageId) => + deleteImage({ ...args, environmentId, imageId }).then(() => + notifySuccess('Image successfully removed', imageId) + ) + ), + ...withInvalidate(queryClient, [queryKeys.base(environmentId)]), + }); +} diff --git a/app/react/docker/images/ListView/ListView.test.tsx b/app/react/docker/images/ListView/ListView.test.tsx new file mode 100644 index 0000000000..24eeb15217 --- /dev/null +++ b/app/react/docker/images/ListView/ListView.test.tsx @@ -0,0 +1,96 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; + +import { useIsSwarmAgent } from '../../proxy/queries/useIsSwarmAgent'; + +import { ListView } from './ListView'; + +vi.mock('../../proxy/queries/useIsSwarmAgent'); +vi.mock('./PullImageFormWidget', () => ({ + PullImageFormWidget: ({ isNodeVisible }: { isNodeVisible: boolean }) => ( +
+ PullImageFormWidget - isNodeVisible: {String(isNodeVisible)} +
+ ), +})); +vi.mock('./ImagesDatatable/ImagesDatatable', () => ({ + ImagesDatatable: ({ + isHostColumnVisible, + }: { + isHostColumnVisible: boolean; + }) => ( +
+ ImagesDatatable - isHostColumnVisible: {String(isHostColumnVisible)} +
+ ), +})); + +describe('ListView', () => { + afterEach(() => { + vi.resetAllMocks(); + }); + + describe('Rendering', () => { + it('should render all main sections', async () => { + vi.mocked(useIsSwarmAgent).mockReturnValue(false); + + renderComponent(); + + // PageHeader with title + expect( + screen.getByRole('heading', { name: /image list/i }) + ).toBeVisible(); + + // PullImageFormWidget + expect(screen.getByTestId('pull-image-form-widget')).toBeVisible(); + + // ImagesDatatable + expect(screen.getByTestId('images-datatable')).toBeVisible(); + }); + }); + + describe('Swarm Agent Integration', () => { + it('should pass isNodeVisible=false to child components when not swarm agent', async () => { + vi.mocked(useIsSwarmAgent).mockReturnValue(false); + + renderComponent(); + + // Verify PullImageFormWidget receives isNodeVisible={false} + expect(screen.getByTestId('pull-image-form-widget')).toHaveTextContent( + 'isNodeVisible: false' + ); + + // Verify ImagesDatatable receives isHostColumnVisible={false} + expect(screen.getByTestId('images-datatable')).toHaveTextContent( + 'isHostColumnVisible: false' + ); + }); + + it('should pass isNodeVisible=true to child components when swarm agent', async () => { + vi.mocked(useIsSwarmAgent).mockReturnValue(true); + + renderComponent(); + + // Verify PullImageFormWidget receives isNodeVisible={true} + expect(screen.getByTestId('pull-image-form-widget')).toHaveTextContent( + 'isNodeVisible: true' + ); + + // Verify ImagesDatatable receives isHostColumnVisible={true} + expect(screen.getByTestId('images-datatable')).toHaveTextContent( + 'isHostColumnVisible: true' + ); + }); + }); +}); + +function renderComponent() { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(ListView)) + ); + return render(); +} diff --git a/app/react/docker/images/ListView/ListView.tsx b/app/react/docker/images/ListView/ListView.tsx new file mode 100644 index 0000000000..87e13c6d33 --- /dev/null +++ b/app/react/docker/images/ListView/ListView.tsx @@ -0,0 +1,24 @@ +import { PageHeader } from '@@/PageHeader'; + +import { useIsSwarmAgent } from '../../proxy/queries/useIsSwarmAgent'; + +import { PullImageFormWidget } from './PullImageFormWidget'; +import { ImagesDatatable } from './ImagesDatatable/ImagesDatatable'; + +export function ListView() { + const isSwarmAgent = useIsSwarmAgent(); + + return ( + <> + + +
+
+ +
+
+ + + + ); +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx b/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx new file mode 100644 index 0000000000..3f912309ac --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx @@ -0,0 +1,54 @@ +import { Form, useFormikContext } from 'formik'; + +import { ImageConfigFieldset } from '@@/ImageConfigFieldset'; +import { FormSection } from '@@/form-components/FormSection'; +import { FormActions } from '@@/form-components/FormActions'; + +import { NodeSelector } from '../../agent/NodeSelector'; + +import { FormValues } from './PullImageFormWidget.types'; + +export function PullImageForm({ + onRateLimit, + isLoading, + isNodeVisible, +}: { + onRateLimit: (limited?: boolean) => void; + isLoading: boolean; + isNodeVisible: boolean; +}) { + const { values, setFieldValue, errors, isValid } = + useFormikContext(); + + return ( +
+ + setFieldValue(`config.${field}`, value) + } + errors={errors.config} + onRateLimit={onRateLimit} + > + {isNodeVisible && ( + + setFieldValue('node', node)} + error={errors.node} + /> + + )} + + + +
+ ); +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.test.tsx b/app/react/docker/images/ListView/PullImageFormWidget.test.tsx new file mode 100644 index 0000000000..3f014c5315 --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.test.tsx @@ -0,0 +1,175 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withTestRouter } from '@/react/test-utils/withRouter'; +import { useAuthorizations } from '@/react/hooks/useUser'; + +import { usePullImageMutation } from '../queries/usePullImageMutation'; + +import { PullImageFormWidget } from './PullImageFormWidget'; + +// Mocks +vi.mock( + '@/react/hooks/useUser', + async (importOriginal: () => Promise) => { + const actual = await importOriginal(); + return { + ...actual, + useAuthorizations: vi.fn(), + }; + } +); + +vi.mock('@/react/hooks/useEnvironmentId', () => ({ + useEnvironmentId: () => 1, +})); + +vi.mock('@/portainer/services/notifications', () => ({ + notifySuccess: vi.fn(), +})); + +vi.mock('../queries/usePullImageMutation', () => ({ + usePullImageMutation: vi.fn(() => ({ + mutate: vi.fn(), + isLoading: false, + })), +})); + +vi.mock('@@/ImageConfigFieldset/getImageConfig', () => ({ + getDefaultImageConfig: () => ({ + image: '', + registryId: 0, + useRegistry: false, + }), +})); + +// Mock child components to simplify tests +vi.mock('./PullImageFormWidget.Form', () => ({ + PullImageForm: ({ + isNodeVisible, + isLoading, + }: { + isNodeVisible: boolean; + isLoading: boolean; + }) => ( +
+ + {isNodeVisible && ( + + )} + +
+ ), +})); + +describe('PullImageFormWidget', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('Authorization', () => { + it('should render widget when user has DockerImageCreate authorization', () => { + vi.mocked(useAuthorizations).mockReturnValue({ + authorized: true, + isLoading: false, + }); + + renderComponent({ isNodeVisible: false }); + + expect( + screen.getByRole('heading', { name: /pull image/i }) + ).toBeVisible(); + expect(screen.getByTestId('pull-image-form')).toBeVisible(); + }); + + it('should not render when user lacks DockerImageCreate authorization', () => { + vi.mocked(useAuthorizations).mockReturnValue({ + authorized: false, + isLoading: false, + }); + + renderComponent({ isNodeVisible: false }); + + expect( + screen.queryByRole('heading', { name: /pull image/i }) + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('pull-image-form')).not.toBeInTheDocument(); + }); + }); + + describe('Form Rendering', () => { + beforeEach(() => { + vi.mocked(useAuthorizations).mockReturnValue({ + authorized: true, + isLoading: false, + }); + }); + + it('should render widget with title "Pull image"', () => { + renderComponent({ isNodeVisible: false }); + + expect( + screen.getByRole('heading', { name: /pull image/i }) + ).toBeVisible(); + }); + + it('should not render NodeSelector when isNodeVisible=false', () => { + renderComponent({ isNodeVisible: false }); + + expect(screen.queryByLabelText(/node/i)).not.toBeInTheDocument(); + expect(screen.queryByTestId('node-selector')).not.toBeInTheDocument(); + }); + + it('should render NodeSelector when isNodeVisible=true', () => { + renderComponent({ isNodeVisible: true }); + + expect(screen.getByLabelText(/node/i)).toBeVisible(); + expect(screen.getByTestId('node-selector')).toBeVisible(); + }); + }); + + describe('Mutation Hook Setup', () => { + beforeEach(() => { + vi.mocked(useAuthorizations).mockReturnValue({ + authorized: true, + isLoading: false, + }); + }); + + it('should initialize usePullImageMutation with correct environment ID', async () => { + const mockUsePullImageMutation = vi.mocked(usePullImageMutation); + + renderComponent({ isNodeVisible: false }); + + // Verify mutation hook was called with environment ID 1 + expect(mockUsePullImageMutation).toHaveBeenCalledWith(1); + }); + + it('should show loading state during pull operation', async () => { + vi.mocked(usePullImageMutation).mockReturnValue({ + mutate: vi.fn(), + isLoading: true, + } as unknown as ReturnType); + + renderComponent({ isNodeVisible: false }); + + const submitButton = screen.getByRole('button', { + name: /download in progress/i, + }); + expect(submitButton).toBeVisible(); + expect(submitButton).toBeDisabled(); + }); + }); +}); + +function renderComponent({ isNodeVisible }: { isNodeVisible: boolean }) { + const Wrapped = withTestQueryProvider(withTestRouter(PullImageFormWidget)); + return render(); +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.tsx b/app/react/docker/images/ListView/PullImageFormWidget.tsx new file mode 100644 index 0000000000..6eb3e8fa30 --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.tsx @@ -0,0 +1,77 @@ +import { DownloadIcon } from 'lucide-react'; +import { Formik } from 'formik'; +import { useState } from 'react'; + +import { useAuthorizations } from '@/react/hooks/useUser'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { getDefaultImageConfig } from '@@/ImageConfigFieldset/getImageConfig'; +import { Widget } from '@@/Widget'; + +import { usePullImageMutation } from '../queries/usePullImageMutation'; + +import { FormValues } from './PullImageFormWidget.types'; +import { PullImageForm } from './PullImageFormWidget.Form'; +import { useValidation } from './PullImageFormWidget.validation'; + +export function PullImageFormWidget({ + isNodeVisible, +}: { + isNodeVisible: boolean; +}) { + const envId = useEnvironmentId(); + const mutation = usePullImageMutation(envId); + const authorizedQuery = useAuthorizations('DockerImageCreate'); + const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false); + + const validation = useValidation(isDockerhubRateLimited, isNodeVisible); + + if (!authorizedQuery.authorized) { + return null; + } + + const initialValues: FormValues = { + node: '', + config: getDefaultImageConfig(), + }; + + return ( + + + + + + setIsDockerhubRateLimited(limited) + } + isLoading={mutation.isLoading} + isNodeVisible={isNodeVisible} + /> + + + + ); + + function handleSubmit({ config, node }: FormValues) { + mutation.mutate( + { + environmentId: envId, + image: config.image, + nodeName: node, + registryId: config.registryId, + ignoreErrors: false, + }, + { + onSuccess() { + notifySuccess('Image successfully pulled', config.image); + }, + } + ); + } +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.types.tsx b/app/react/docker/images/ListView/PullImageFormWidget.types.tsx new file mode 100644 index 0000000000..3e4f3e6285 --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.types.tsx @@ -0,0 +1,6 @@ +import { ImageConfigValues } from '@@/ImageConfigFieldset'; + +export interface FormValues { + config: ImageConfigValues; + node: string; +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx b/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx new file mode 100644 index 0000000000..d00ab00de4 --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx @@ -0,0 +1,30 @@ +import { render } from '@testing-library/react'; + +import { FormValues } from './PullImageFormWidget.types'; +import { useValidation } from './PullImageFormWidget.validation'; + +function setup(...args: Parameters) { + const returnVal: { schema?: ReturnType } = { + schema: undefined, + }; + function TestComponent() { + Object.assign(returnVal, { schema: useValidation(...args) }); + return null; + } + render(); + return returnVal; +} + +test('image is required', async () => { + const { schema } = setup(false, false); + const object: FormValues = { + config: { image: '', registryId: 0, useRegistry: true }, + node: '', + }; + + await expect( + schema?.validate(object, { strict: true }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[ValidationError: Image is required]` + ); +}); diff --git a/app/react/docker/images/ListView/PullImageFormWidget.validation.ts b/app/react/docker/images/ListView/PullImageFormWidget.validation.ts new file mode 100644 index 0000000000..3d95ffc090 --- /dev/null +++ b/app/react/docker/images/ListView/PullImageFormWidget.validation.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react'; +import { SchemaOf, object, string } from 'yup'; + +import { imageConfigValidation } from '@@/ImageConfigFieldset'; + +import { FormValues } from './PullImageFormWidget.types'; + +export function useValidation( + isDockerhubRateLimited: boolean, + isNodeVisible: boolean +): SchemaOf { + return useMemo( + () => + object({ + config: imageConfigValidation().test( + 'rate-limits', + 'Rate limit exceeded', + () => !isDockerhubRateLimited + ), + node: isNodeVisible + ? string().required('Node is required') + : string().default(''), + }), + [isDockerhubRateLimited, isNodeVisible] + ); +} diff --git a/app/react/docker/images/common/ConfirmExportModal.tsx b/app/react/docker/images/common/ConfirmExportModal.tsx index 67b456bda6..74e106b52d 100644 --- a/app/react/docker/images/common/ConfirmExportModal.tsx +++ b/app/react/docker/images/common/ConfirmExportModal.tsx @@ -1,15 +1,13 @@ import { ModalType } from '@@/modals'; -import { ConfirmCallback, openConfirm } from '@@/modals/confirm'; +import { openConfirm } from '@@/modals/confirm'; import { buildConfirmButton } from '@@/modals/utils'; -export async function confirmImageExport(callback: ConfirmCallback) { - const result = await openConfirm({ +export async function confirmImageExport() { + return openConfirm({ modalType: ModalType.Warn, title: 'Caution', message: 'The export may take several minutes, do not navigate away whilst the export is in progress.', confirmButton: buildConfirmButton('Continue'), }); - - callback(result); } diff --git a/app/react/docker/images/queries/queryKeys.ts b/app/react/docker/images/queries/queryKeys.ts index b14dd091f4..79db4a0a2e 100644 --- a/app/react/docker/images/queries/queryKeys.ts +++ b/app/react/docker/images/queries/queryKeys.ts @@ -4,7 +4,7 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils'; export const queryKeys = { base: (environmentId: EnvironmentId) => - [dockerQueryKeys.root(environmentId), 'images'] as const, + [...dockerQueryKeys.root(environmentId), 'images'] as const, list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) => [...queryKeys.base(environmentId), options] as const, }; diff --git a/app/react/docker/images/queries/useDeleteImageMutation.ts b/app/react/docker/images/queries/useDeleteImageMutation.ts new file mode 100644 index 0000000000..2fc84fd12e --- /dev/null +++ b/app/react/docker/images/queries/useDeleteImageMutation.ts @@ -0,0 +1,46 @@ +import { RawAxiosRequestHeaders } from 'axios'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { withInvalidate } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +import { queryKeys } from './queryKeys'; + +export function useDeleteImageMutation(envId: EnvironmentId) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteImage, + ...withInvalidate(queryClient, [queryKeys.base(envId)]), + }); +} + +export async function deleteImage({ + environmentId, + imageId, + nodeName, + force, +}: { + environmentId: EnvironmentId; + imageId: string; + nodeName?: string; + force?: boolean; +}) { + const headers: RawAxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + try { + await axios.delete(buildDockerProxyUrl(environmentId, 'images', imageId), { + headers, + params: { force }, + }); + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to delete image'); + } +} diff --git a/app/react/docker/images/queries/useExportImageMutation.test.ts b/app/react/docker/images/queries/useExportImageMutation.test.ts new file mode 100644 index 0000000000..8598d1440c --- /dev/null +++ b/app/react/docker/images/queries/useExportImageMutation.test.ts @@ -0,0 +1,353 @@ +import { waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { http, HttpResponse } from 'msw'; +import { saveAs } from 'file-saver'; +import { createElement, Fragment } from 'react'; + +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { server } from '@/setup-tests/server'; + +import { + useExportMutation, + exportImage, + getImagesNamesForDownload, +} from './useExportImageMutation'; + +function renderMutationHook() { + const Wrapper = withTestQueryProvider(({ children }) => + createElement(Fragment, null, children) + ); + + return renderHook(() => useExportMutation(), { + wrapper: Wrapper, + }); +} + +vi.mock('file-saver', () => ({ + saveAs: vi.fn(), +})); + +vi.mock('@uirouter/react', async (importOriginal: () => Promise) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: '1' }, + })), +})); + +describe('getImagesNamesForDownload', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return tag names when images have tags', () => { + const images = [ + { id: 'sha256:abc123', tags: ['nginx:latest', 'nginx:1.21'] }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + ]; + + const result = getImagesNamesForDownload(images); + + expect(result.names).toEqual(['nginx:latest', 'redis:alpine']); + }); + + it('should return image id when tags are undefined', () => { + const images = [ + { id: 'sha256:abc123', tags: undefined }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + ]; + + const result = getImagesNamesForDownload(images); + + expect(result.names).toEqual(['sha256:abc123', 'redis:alpine']); + }); + + it('should return image id when tag is :', () => { + const images = [ + { id: 'sha256:abc123', tags: [':'] }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + ]; + + const result = getImagesNamesForDownload(images); + + expect(result.names).toEqual(['sha256:abc123', 'redis:alpine']); + }); + + it('should return image id when tags array is empty', () => { + const images = [ + { id: 'sha256:abc123', tags: [] }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + ]; + + const result = getImagesNamesForDownload(images); + + expect(result.names).toEqual(['sha256:abc123', 'redis:alpine']); + }); +}); + +describe('exportImage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should export image and save file with correct filename', async () => { + const mockBlob = new Blob(['image data'], { type: 'application/x-tar' }); + + server.use( + http.get( + '/api/endpoints/:envId/docker/images/get', + () => + new Response(mockBlob, { + headers: { + 'content-disposition': 'attachment; filename=nginx-latest.tar', + }, + }) + ) + ); + + await exportImage({ + environmentId: 1, + nodeName: undefined, + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + }); + + expect(saveAs).toHaveBeenCalledWith(mockBlob, 'nginx-latest.tar'); + }); + + it('should include X-PortainerAgent-Target header when nodeName is provided', async () => { + let requestHeaders: Record = {}; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestHeaders = Object.fromEntries(request.headers.entries()); + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + await exportImage({ + environmentId: 1, + nodeName: 'worker-node-1', + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + }); + + expect(requestHeaders['x-portaineragent-target']).toBe('worker-node-1'); + }); + + it('should not include X-PortainerAgent-Target header when nodeName is undefined', async () => { + let requestHeaders: Record = {}; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestHeaders = Object.fromEntries(request.headers.entries()); + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + await exportImage({ + environmentId: 1, + nodeName: undefined, + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + }); + + expect(requestHeaders['x-portaineragent-target']).toBeUndefined(); + }); + + it('should send correct image names as query params', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestUrl = request.url; + + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + await exportImage({ + environmentId: 1, + nodeName: undefined, + images: [ + { id: 'sha256:abc123', tags: ['nginx:latest'] }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + ], + }); + + const url = new URL(requestUrl); + // Axios serializes array params with brackets: names[]=value1&names[]=value2 + const names = url.searchParams.getAll('names[]'); + expect(names).toEqual(['nginx:latest', 'redis:alpine']); + expect(saveAs).toHaveBeenCalled(); + }); + + it('should send mix of tags and IDs in query params for images without tags', async () => { + let requestUrl = ''; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestUrl = request.url; + return new Response(new Blob(['image data']), { + headers: { + 'content-disposition': 'attachment; filename=test.tar', + }, + }); + }) + ); + + await exportImage({ + environmentId: 1, + nodeName: undefined, + images: [ + { id: 'sha256:abc123', tags: ['nginx:latest'] }, + { id: 'sha256:def456', tags: undefined }, + { id: 'sha256:ghi789', tags: [':'] }, + { id: 'sha256:jkl012', tags: [] }, + ], + }); + + const url = new URL(requestUrl); + const names = url.searchParams.getAll('names[]'); + expect(names).toEqual([ + 'nginx:latest', + 'sha256:def456', + 'sha256:ghi789', + 'sha256:jkl012', + ]); + expect(saveAs).toHaveBeenCalled(); + }); + + it('should throw error when export fails', async () => { + server.use( + http.get('/api/endpoints/:envId/docker/images/get', () => + HttpResponse.json({ message: 'Image not found' }, { status: 404 }) + ) + ); + + await expect( + exportImage({ + environmentId: 1, + nodeName: undefined, + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + }) + ).rejects.toThrow('Unable to export image'); + }); + + it('should handle filename without content-disposition header', async () => { + const mockBlob = new Blob(['image data'], { type: 'application/x-tar' }); + + server.use( + http.get( + '/api/endpoints/:envId/docker/images/get', + () => new Response(mockBlob) + ) + ); + + await exportImage({ + environmentId: 1, + nodeName: undefined, + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + }); + + expect(saveAs).toHaveBeenCalledWith(mockBlob, ''); + }); +}); + +describe('useExportMutation', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should successfully export images', async () => { + const mockBlob = new Blob(['image data'], { type: 'application/x-tar' }); + + server.use( + http.get( + '/api/endpoints/:envId/docker/images/get', + () => + new Response(mockBlob, { + headers: { + 'content-disposition': 'attachment; filename=nginx-latest.tar', + }, + }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + nodeName: undefined, + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(saveAs).toHaveBeenCalledWith(mockBlob, 'nginx-latest.tar'); + }); + + it('should handle export error', async () => { + server.use( + http.get('/api/endpoints/:envId/docker/images/get', () => + HttpResponse.json({ message: 'Internal server error' }, { status: 500 }) + ) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + images: [{ id: 'sha256:abc123', tags: ['nginx:latest'] }], + nodeName: undefined, + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + }); + + it('should export multiple images with node name', async () => { + const mockBlob = new Blob(['image data'], { type: 'application/x-tar' }); + let requestHeaders: Record = {}; + + server.use( + http.get('/api/endpoints/:envId/docker/images/get', ({ request }) => { + requestHeaders = Object.fromEntries(request.headers.entries()); + return new Response(mockBlob, { + headers: { + 'content-disposition': 'attachment; filename=images.tar', + }, + }); + }) + ); + + const { result } = renderMutationHook(); + + result.current.mutate({ + images: [ + { id: 'sha256:abc123', tags: ['nginx:latest'] }, + { id: 'sha256:def456', tags: ['redis:alpine'] }, + { id: 'sha256:ghi789', tags: undefined }, + ], + nodeName: 'worker-node-1', + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(saveAs).toHaveBeenCalledWith(mockBlob, 'images.tar'); + expect(requestHeaders['x-portaineragent-target']).toBe('worker-node-1'); + }); +}); diff --git a/app/react/docker/images/queries/useExportImageMutation.ts b/app/react/docker/images/queries/useExportImageMutation.ts new file mode 100644 index 0000000000..938be4b053 --- /dev/null +++ b/app/react/docker/images/queries/useExportImageMutation.ts @@ -0,0 +1,71 @@ +import { RawAxiosRequestHeaders } from 'axios'; +import { useMutation } from '@tanstack/react-query'; +import { saveAs } from 'file-saver'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; + +export function useExportMutation() { + const environmentId = useEnvironmentId(); + return useMutation({ + mutationFn: ( + args: Omit[0], 'environmentId'> + ) => exportImage({ ...args, environmentId }), + }); +} + +export async function exportImage({ + environmentId, + nodeName, + images, +}: { + environmentId: EnvironmentId; + nodeName?: string; + images: Array<{ tags?: Array; id: string }>; +}) { + const { names } = getImagesNamesForDownload(images); + + const headers: RawAxiosRequestHeaders = {}; + + if (nodeName) { + headers['X-PortainerAgent-Target'] = nodeName; + } + + try { + const { headers: responseHeaders, data } = await axios.get( + buildDockerProxyUrl(environmentId, 'images', 'get'), + { + headers, + responseType: 'blob', + params: { + names, + }, + } + ); + + const contentDispositionHeader = + responseHeaders['content-disposition'] || ''; + const filename = contentDispositionHeader + .replace('attachment; filename=', '') + .trim(); + saveAs(data, filename); + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to export image'); + } +} + +export function getImagesNamesForDownload( + images: Array<{ tags?: Array; id: string }> +) { + const names = images.map((image) => + image.tags?.length && image.tags[0] !== ':' + ? image.tags[0] + : image.id + ); + return { + names, + }; +} diff --git a/app/react/docker/images/queries/usePullImageMutation.ts b/app/react/docker/images/queries/usePullImageMutation.ts index f99f996a44..549693e1f0 100644 --- a/app/react/docker/images/queries/usePullImageMutation.ts +++ b/app/react/docker/images/queries/usePullImageMutation.ts @@ -1,6 +1,10 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { Registry } from '@/react/portainer/registries/types/registry'; +import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; +import { withGlobalError, withInvalidate } from '@/react-tools/react-query'; import { buildImageFullURI } from '../utils'; import { @@ -9,6 +13,33 @@ import { } from '../../proxy/queries/utils'; import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { queryKeys } from './queryKeys'; + +type UsePullImageMutation = Omit & { + registryId?: Registry['Id']; +}; + +export function usePullImageMutation(envId: EnvironmentId) { + const queryClient = useQueryClient(); + const registriesQuery = useEnvironmentRegistries(envId); + + return useMutation({ + mutationFn: (args: UsePullImageMutation) => + pullImage({ + ...args, + registry: getRegistry(registriesQuery.data || [], args.registryId), + }), + ...withGlobalError('Failure', 'Failed pulling image'), + ...withInvalidate(queryClient, [queryKeys.base(envId)]), + }); +} + +function getRegistry(registries: Registry[], registryId?: Registry['Id']) { + return registryId + ? registries.find((registry) => registry.Id === registryId) + : undefined; +} + interface PullImageOptions { environmentId: EnvironmentId; image: string; diff --git a/app/react/docker/images/utils.ts b/app/react/docker/images/utils.ts index e9aeaf6612..a04fd713bd 100644 --- a/app/react/docker/images/utils.ts +++ b/app/react/docker/images/utils.ts @@ -55,6 +55,10 @@ export function buildImageFullURIFromModel(imageModel: ImageModel) { * builds the complete uri for an image based on its registry */ export function buildImageFullURI(image: string, registry?: Registry) { + if (!image) { + throw new Error('Missing image'); + } + if (!registry) { return ensureTag(image); } diff --git a/app/react/docker/proxy/queries/useIsSwarmAgent.ts b/app/react/docker/proxy/queries/useIsSwarmAgent.ts new file mode 100644 index 0000000000..b092f25713 --- /dev/null +++ b/app/react/docker/proxy/queries/useIsSwarmAgent.ts @@ -0,0 +1,17 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { isAgentEnvironment } from '@/react/portainer/environments/utils'; + +import { useIsSwarm } from './useInfo'; + +export function useIsSwarmAgent() { + const envId = useEnvironmentId(); + const isSwarm = useIsSwarm(envId); + const envQuery = useCurrentEnvironment(); + + if (!envQuery.isSuccess) { + return false; + } + + return isSwarm && isAgentEnvironment(envQuery.data.Type); +} diff --git a/app/setup-tests/setup-handlers/docker.ts b/app/setup-tests/setup-handlers/docker.ts index b97413d926..21d2dc518e 100644 --- a/app/setup-tests/setup-handlers/docker.ts +++ b/app/setup-tests/setup-handlers/docker.ts @@ -1,7 +1,10 @@ import { http, HttpResponse } from 'msw'; import { SystemInfo, SystemVersion } from 'docker-types/generated/1.44'; +import { dockerImagesHandlers } from './docker/images'; + export const dockerHandlers = [ + ...dockerImagesHandlers, http.get( '/api/endpoints/:endpointId/docker/info', () => diff --git a/app/setup-tests/setup-handlers/docker/images.ts b/app/setup-tests/setup-handlers/docker/images.ts new file mode 100644 index 0000000000..23c12323e6 --- /dev/null +++ b/app/setup-tests/setup-handlers/docker/images.ts @@ -0,0 +1,5 @@ +import { http, HttpResponse } from 'msw'; + +export const dockerImagesHandlers = [ + http.get('/api/docker/:envId/images', () => HttpResponse.json([])), +]; diff --git a/app/setup-tests/setup-handlers/endpoints.ts b/app/setup-tests/setup-handlers/endpoints.ts index cc17e50289..cf69636b71 100644 --- a/app/setup-tests/setup-handlers/endpoints.ts +++ b/app/setup-tests/setup-handlers/endpoints.ts @@ -2,7 +2,13 @@ import { http, HttpResponse } from 'msw'; export const endpointsHandlers = [ http.get('/api/endpoints/agent_versions', () => HttpResponse.json([])), - http.get('/api/endpoints/:endpointId', () => HttpResponse.json({})), + http.get('/api/endpoints/:endpointId', ({ params }) => + HttpResponse.json({ + Id: Number(params.endpointId), + Name: `test-environment-${params.endpointId}`, + Type: 1, // Docker standalone + }) + ), http.get('/api/endpoints/:endpointId/registries', () => HttpResponse.json([]) ),