refactor(docker/images): migrate list view to react [BE-6562] (#1451)

This commit is contained in:
Chaim Lev-Ari
2025-12-09 15:27:20 +02:00
committed by GitHub
parent ecac526810
commit 79e6271041
39 changed files with 2164 additions and 389 deletions
+1 -2
View File
@@ -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: {
-11
View File
@@ -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)), [
+13
View File
@@ -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;
+6 -1
View File
@@ -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)), [])
@@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [
return;
}
confirmImageExport(function (confirmed) {
confirmImageExport().then(function (confirmed) {
if (!confirmed) {
return;
}
-54
View File
@@ -1,54 +0,0 @@
<page-header title="'Image list'" breadcrumbs="['Images']" reload="true"> </page-header>
<div class="row" authorization="DockerImageCreate">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="download" title-text="Pull image "> </rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- image-and-registry -->
<por-image-registry
model="formValues.RegistryModel"
auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint"
is-admin="isAdmin"
set-validity="setPullImageValidity"
check-rate-limits="true"
>
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title"> Deployment </div>
<!-- node-selection -->
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
<!-- !node-selection -->
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || !state.pullRateValid"
ng-click="pullImage()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Pull the image</span>
<span ng-show="state.actionInProgress">Download in progress...</span>
</button>
</div>
</div>
</por-image-registry>
<!-- !image-and-registry -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<docker-images-datatable
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
on-download="(downloadAction)"
on-remove="(confirmRemovalAction)"
is-export-in-progress="state.exportInProgress"
environment="endpoint"
></docker-images-datatable>
-177
View File
@@ -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<import('@/react/docker/images/queries/useImages').ImagesListResponse>} 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<import('@/react/docker/images/queries/useImages').ImagesListResponse>} 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('<none>') > -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<import('@/react/docker/images/queries/useImages').ImagesListResponse>} 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<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
*/
$scope.downloadAction = function (selectedItems) {
if (!isAuthorizedToDownload(selectedItems)) {
return;
}
confirmImageExport(function (confirmed) {
if (!confirmed) {
return;
}
exportImages(selectedItems);
});
};
$scope.removeAction = removeAction;
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} 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;
}
},
]);
+21 -9
View File
@@ -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<User> = {}) {
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(
@@ -1,4 +1,8 @@
import { Registry, RegistryId, RegistryTypes } from '../types/registry';
import {
Registry,
RegistryId,
RegistryTypes,
} from '../../portainer/registries/types/registry';
import { findBestMatchRegistry } from './findRegistryMatch';
@@ -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
@@ -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 {
@@ -43,14 +43,13 @@ export function AutocompleteSelect({
return (
<Combobox
className={styles.root}
aria-label="compose"
className={clsx(styles.root, 'form-control')}
onSelect={onSelect}
data-cy="component-gitComposeInput"
>
<ComboboxInput
value={searchTerm}
className="form-control"
className="w-full bg-transparent border-none outline-none"
onChange={handleChange}
placeholder={placeholder}
readOnly={readOnly}
+4 -1
View File
@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { FormikErrors } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@@ -11,9 +12,11 @@ import { useAgentNodes } from './queries/useAgentNodes';
export function NodeSelector({
value,
onChange,
error,
}: {
value: string;
onChange: (value: string) => void;
error?: FormikErrors<string>;
}) {
const environmentId = useEnvironmentId();
@@ -36,7 +39,7 @@ export function NodeSelector({
}, [nodesQuery.data, onChange, value]);
return (
<FormControl label="Node" inputId="node-selector">
<FormControl label="Node" inputId="node-selector" errors={error}>
<PortainerSelect
inputId="node-selector"
value={value}
@@ -1,7 +1,8 @@
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
import { UserId } from '@/portainer/users/types';
import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
import { getDefaultImageConfig } from '@@/ImageConfigFieldset/getImageConfig';
import { ContainerDetailsResponse } from '../../queries/useContainer';
@@ -35,12 +35,12 @@ import {
} from '@/react/docker/containers/CreateView/VolumesTab';
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
import { UserId } from '@/portainer/users/types';
import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { getImageConfig } from '@@/ImageConfigFieldset/getImageConfig';
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
import { useNetworksForSelector } from '../components/NetworkSelector';
@@ -0,0 +1,329 @@
import { render, screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } 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, User } from '@/portainer/users/types';
import { ImagesListResponse } from '../../queries/useImages';
import { ImagesDatatable } from './ImagesDatatable';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(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
<a data-cy={dataCy}>{children}</a>
),
}));
// Mock child components to simplify testing
vi.mock('./RemoveButtonMenu', () => ({
RemoveButtonMenu: ({
selectedItems,
}: {
selectedItems: ImagesListResponse[];
}) => (
<button
type="button"
data-cy="remove-button-menu"
disabled={selectedItems.length === 0}
>
Remove ({selectedItems.length})
</button>
),
}));
vi.mock('./ImportExportButtons', () => ({
ImportExportButtons: ({
selectedItems,
}: {
selectedItems: ImagesListResponse[];
}) => (
<div data-cy="import-export-buttons">
Import/Export ({selectedItems.length})
</div>
),
}));
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>
): 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(
<Wrapped isHostColumnVisible={isHostColumnVisible} />
);
expect(screen.getByTestId('docker-images-datatable')).toBeVisible();
return rendered;
}
@@ -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<TableSettings>(
export function ImagesDatatable({
isHostColumnVisible,
isExportInProgress,
onDownload,
onRemove,
}: {
isHostColumnVisible: boolean;
onDownload: (images: Array<ImagesListResponse>) => void;
onRemove: (images: Array<ImagesListResponse>, force: true) => void;
isExportInProgress: boolean;
}) {
const environmentId = useEnvironmentId();
const tableState = useTableState(settingsStore, tableKey);
@@ -76,13 +67,9 @@ export function ImagesDatatable({
)}
renderTableActions={(selectedItems) => (
<div className="flex items-center gap-2">
<RemoveButtonMenu selectedItems={selectedItems} onRemove={onRemove} />
<RemoveButtonMenu selectedItems={selectedItems} />
<ImportExportButtons
isExportInProgress={isExportInProgress}
onExportClick={onDownload}
selectedItems={selectedItems}
/>
<ImportExportButtons selectedItems={selectedItems} />
<Authorized authorizations="DockerImageBuild">
<AddButton
@@ -109,97 +96,3 @@ export function ImagesDatatable({
/>
);
}
function RemoveButtonMenu({
onRemove,
selectedItems,
}: {
selectedItems: Array<ImagesListResponse>;
onRemove(selectedItems: Array<ImagesListResponse>, force: boolean): void;
}) {
return (
<Authorized authorizations="DockerImageDelete">
<ButtonGroup>
<Button
size="small"
color="dangerlight"
icon={Trash2}
disabled={selectedItems.length === 0}
data-cy="image-removeImageButton"
onClick={() => {
onRemove(selectedItems, false);
}}
>
Remove
</Button>
<Menu>
<MenuButton
as={ButtonWithRef}
size="small"
color="dangerlight"
disabled={selectedItems.length === 0}
icon={ChevronDown}
data-cy="image-toggleRemoveButtonMenu"
>
<span className="sr-only">Toggle Dropdown</span>
</MenuButton>
<MenuPopover position={positionRight}>
<div className="mt-3 bg-white th-highcontrast:bg-black th-dark:bg-black">
<MenuItem
onSelect={() => {
onRemove(selectedItems, true);
}}
>
Force Remove
</MenuItem>
</div>
</MenuPopover>
</Menu>
</ButtonGroup>
</Authorized>
);
}
function ImportExportButtons({
isExportInProgress,
selectedItems,
onExportClick,
}: {
isExportInProgress: boolean;
selectedItems: Array<ImagesListResponse>;
onExportClick(selectedItems: Array<ImagesListResponse>): void;
}) {
return (
<ButtonGroup>
<Authorized authorizations="DockerImageLoad">
<Button
size="small"
color="light"
as={Link}
data-cy="image-importImageButton"
icon={Upload}
disabled={isExportInProgress}
props={{
to: 'docker.images.import',
}}
>
Import
</Button>
</Authorized>
<Authorized authorizations="DockerImageGet">
<LoadingButton
size="small"
color="light"
icon={Download}
isLoading={isExportInProgress}
loadingText="Export in progress..."
data-cy="image-exportImageButton"
onClick={() => onExportClick(selectedItems)}
disabled={selectedItems.length === 0}
>
Export
</LoadingButton>
</Authorized>
</ButtonGroup>
);
}
@@ -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<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: '1' },
})),
}));
vi.mock('@@/Link', () => ({
Link: ({
children,
'data-cy': dataCy,
}: {
children: React.ReactNode;
'data-cy'?: string;
}) => (
<a data-cy={dataCy} href="/mock-link">
{children}
</a>
),
}));
// 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: ['<none>'],
}),
];
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<string, string> = {};
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>
): 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(<Wrapped selectedItems={selectedItems} />);
}
@@ -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<ImagesListResponse>;
}) {
const exportMutation = useExportMutation();
return (
<ButtonGroup>
<Authorized authorizations="DockerImageLoad">
<Button
size="small"
color="light"
as={Link}
data-cy="image-importImageButton"
icon={Upload}
disabled={exportMutation.isLoading}
props={{
to: 'docker.images.import',
}}
aria-disabled={exportMutation.isLoading}
>
Import
</Button>
</Authorized>
<Authorized authorizations="DockerImageGet">
<LoadingButton
size="small"
color="light"
icon={Download}
isLoading={exportMutation.isLoading}
loadingText="Export in progress..."
data-cy="image-exportImageButton"
onClick={() => handleExport()}
disabled={selectedItems.length === 0}
>
Export
</LoadingButton>
</Authorized>
</ButtonGroup>
);
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<ImagesListResponse>) {
for (let i = 0; i < selectedItems.length; i++) {
const image = selectedItems[i];
const untagged = image.tags?.find((item) => item.includes('<none>'));
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;
}
@@ -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<ImagesListResponse>;
}) {
const deleteImageListMutation = useDeleteImageListMutation();
return (
<Authorized authorizations="DockerImageDelete">
<ButtonGroup>
<Button
size="small"
color="dangerlight"
icon={Trash2}
disabled={selectedItems.length === 0}
data-cy="image-removeImageButton"
onClick={() => {
handleRemove(false);
}}
>
Remove
</Button>
<Menu>
<MenuButton
as={ButtonWithRef}
size="small"
color="dangerlight"
disabled={selectedItems.length === 0}
icon={ChevronDown}
data-cy="image-toggleRemoveButtonMenu"
>
<span className="sr-only">Toggle Dropdown</span>
</MenuButton>
<MenuPopover position={positionRight}>
<div className="mt-3 bg-white th-highcontrast:bg-black th-dark:bg-black">
<MenuItem
onSelect={() => {
handleRemove(true);
}}
>
Force Remove
</MenuItem>
</div>
</MenuPopover>
</Menu>
</ButtonGroup>
</Authorized>
);
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<string>;
} & Omit<Parameters<typeof deleteImage>[0], 'imageId' | 'environmentId'>) =>
processItemsInBatches(imageIds, (imageId) =>
deleteImage({ ...args, environmentId, imageId }).then(() =>
notifySuccess('Image successfully removed', imageId)
)
),
...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
});
}
@@ -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 }) => (
<div data-cy="pull-image-form-widget">
PullImageFormWidget - isNodeVisible: {String(isNodeVisible)}
</div>
),
}));
vi.mock('./ImagesDatatable/ImagesDatatable', () => ({
ImagesDatatable: ({
isHostColumnVisible,
}: {
isHostColumnVisible: boolean;
}) => (
<div data-cy="images-datatable">
ImagesDatatable - isHostColumnVisible: {String(isHostColumnVisible)}
</div>
),
}));
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(<Wrapped />);
}
@@ -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 (
<>
<PageHeader title="Image list" breadcrumbs="Images" reload />
<div className="row">
<div className="col-sm-12">
<PullImageFormWidget isNodeVisible={isSwarmAgent} />
</div>
</div>
<ImagesDatatable isHostColumnVisible={isSwarmAgent} />
</>
);
}
@@ -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<FormValues>();
return (
<Form className="form-horizontal">
<ImageConfigFieldset
autoComplete
values={values.config}
setFieldValue={(field, value) =>
setFieldValue(`config.${field}`, value)
}
errors={errors.config}
onRateLimit={onRateLimit}
>
{isNodeVisible && (
<FormSection title="Deployment">
<NodeSelector
value={values.node}
onChange={(node) => setFieldValue('node', node)}
error={errors.node}
/>
</FormSection>
)}
<FormActions
isLoading={isLoading}
isValid={isValid}
loadingText="Download in progress..."
submitLabel="Pull the image"
data-cy="pull-image-button"
/>
</ImageConfigFieldset>
</Form>
);
}
@@ -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<object>) => {
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;
}) => (
<form data-cy="pull-image-form">
<input aria-label="Image name" name="image" data-cy="image-input" />
{isNodeVisible && (
<select aria-label="Node" name="node" data-cy="node-selector">
<option value="">Select a node</option>
<option value="node1">Node 1</option>
<option value="node2">Node 2</option>
</select>
)}
<button type="submit" disabled={isLoading} data-cy="pull-image-button">
{isLoading ? 'Download in progress...' : 'Pull the image'}
</button>
</form>
),
}));
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<typeof usePullImageMutation>);
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(<Wrapped isNodeVisible={isNodeVisible} />);
}
@@ -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 (
<Widget>
<Widget.Title icon={DownloadIcon} title="Pull image" />
<Widget.Body>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
>
<PullImageForm
onRateLimit={(limited = false) =>
setIsDockerhubRateLimited(limited)
}
isLoading={mutation.isLoading}
isNodeVisible={isNodeVisible}
/>
</Formik>
</Widget.Body>
</Widget>
);
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);
},
}
);
}
}
@@ -0,0 +1,6 @@
import { ImageConfigValues } from '@@/ImageConfigFieldset';
export interface FormValues {
config: ImageConfigValues;
node: string;
}
@@ -0,0 +1,30 @@
import { render } from '@testing-library/react';
import { FormValues } from './PullImageFormWidget.types';
import { useValidation } from './PullImageFormWidget.validation';
function setup(...args: Parameters<typeof useValidation>) {
const returnVal: { schema?: ReturnType<typeof useValidation> } = {
schema: undefined,
};
function TestComponent() {
Object.assign(returnVal, { schema: useValidation(...args) });
return null;
}
render(<TestComponent />);
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]`
);
});
@@ -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<FormValues> {
return useMemo(
() =>
object({
config: imageConfigValidation().test(
'rate-limits',
'Rate limit exceeded',
() => !isDockerhubRateLimited
),
node: isNodeVisible
? string().required('Node is required')
: string().default(''),
}),
[isDockerhubRateLimited, isNodeVisible]
);
}
@@ -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);
}
+1 -1
View File
@@ -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,
};
@@ -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');
}
}
@@ -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<object>) => ({
...(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 <none>:<none>', () => {
const images = [
{ id: 'sha256:abc123', tags: ['<none>:<none>'] },
{ 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<string, string | undefined> = {};
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<string, string | undefined> = {};
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: ['<none>:<none>'] },
{ 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<string, string | undefined> = {};
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');
});
});
@@ -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<Parameters<typeof exportImage>[0], 'environmentId'>
) => exportImage({ ...args, environmentId }),
});
}
export async function exportImage({
environmentId,
nodeName,
images,
}: {
environmentId: EnvironmentId;
nodeName?: string;
images: Array<{ tags?: Array<string>; 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<string>; id: string }>
) {
const names = images.map((image) =>
image.tags?.length && image.tags[0] !== '<none>:<none>'
? image.tags[0]
: image.id
);
return {
names,
};
}
@@ -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<PullImageOptions, 'registry'> & {
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;
+4
View File
@@ -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);
}
@@ -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);
}
+3
View File
@@ -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<never, never, SystemInfo>(
'/api/endpoints/:endpointId/docker/info',
() =>
@@ -0,0 +1,5 @@
import { http, HttpResponse } from 'msw';
export const dockerImagesHandlers = [
http.get('/api/docker/:envId/images', () => HttpResponse.json([])),
];
+7 -1
View File
@@ -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([])
),