mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:20:11 +00:00
refactor(docker/images): migrate list view to react [BE-6562] (#1451)
This commit is contained in:
@@ -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,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)), [
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
]);
|
||||
@@ -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(
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import { Registry, RegistryId, RegistryTypes } from '../types/registry';
|
||||
import {
|
||||
Registry,
|
||||
RegistryId,
|
||||
RegistryTypes,
|
||||
} from '../../portainer/registries/types/registry';
|
||||
|
||||
import { findBestMatchRegistry } from './findRegistryMatch';
|
||||
|
||||
+6
-3
@@ -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
|
||||
+5
-3
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([])),
|
||||
];
|
||||
@@ -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([])
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user