diff --git a/app/assets/css/vendor-override.css b/app/assets/css/vendor-override.css
index 248d759cc..e513afc04 100644
--- a/app/assets/css/vendor-override.css
+++ b/app/assets/css/vendor-override.css
@@ -5,6 +5,15 @@
color: var(--text-form-control-color);
}
+.form-control:focus,
+.form-control:focus-within {
+ border-color: #66afe9;
+ outline: 0;
+ box-shadow:
+ inset 0 1px 1px rgba(0, 0, 0, 0.075),
+ 0 0 8px rgba(102, 175, 233, 0.6);
+}
+
.text-muted {
color: var(--text-muted-color);
}
diff --git a/app/docker/__module.js b/app/docker/__module.js
index 12a66d44e..f472b47fa 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -200,8 +200,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/images',
views: {
'content@': {
- templateUrl: './views/images/images.html',
- controller: 'ImagesController',
+ component: 'imagesListView',
},
},
data: {
diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts
index d1116bda3..e05af4a44 100644
--- a/app/docker/react/components/index.ts
+++ b/app/docker/react/components/index.ts
@@ -11,7 +11,6 @@ import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
-import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
import { EventsDatatable } from '@/react/docker/events/EventsDatatables';
import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatable';
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
@@ -69,16 +68,6 @@ const ngModule = angular
])
)
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
- .component(
- 'dockerImagesDatatable',
- r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [
- 'onRemove',
- 'isExportInProgress',
- 'isHostColumnVisible',
- 'onDownload',
- 'onRemove',
- ])
- )
.component(
'dockerConfigsDatatable',
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
diff --git a/app/docker/react/views/images.ts b/app/docker/react/views/images.ts
new file mode 100644
index 000000000..c486c111a
--- /dev/null
+++ b/app/docker/react/views/images.ts
@@ -0,0 +1,13 @@
+import angular from 'angular';
+
+import { r2a } from '@/react-tools/react2angular';
+import { withCurrentUser } from '@/react-tools/withCurrentUser';
+import { withUIRouter } from '@/react-tools/withUIRouter';
+import { ListView } from '@/react/docker/images/ListView/ListView';
+
+export const imagesModule = angular
+ .module('portainer.docker.react.views.images', [])
+ .component(
+ 'imagesListView',
+ r2a(withUIRouter(withCurrentUser(ListView)), [])
+ ).name;
diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts
index 6205f0b22..75cf6f27b 100644
--- a/app/docker/react/views/index.ts
+++ b/app/docker/react/views/index.ts
@@ -7,9 +7,10 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { DashboardView } from '@/react/docker/DashboardView/DashboardView';
import { containersModule } from './containers';
+import { imagesModule } from './images';
export const viewsModule = angular
- .module('portainer.docker.react.views', [containersModule])
+ .module('portainer.docker.react.views', [containersModule, imagesModule])
.component(
'dockerDashboardView',
r2a(withUIRouter(withCurrentUser(DashboardView)), [])
diff --git a/app/docker/views/images/edit/imageController.js b/app/docker/views/images/edit/imageController.js
index a461d7bb1..30c24aa9c 100644
--- a/app/docker/views/images/edit/imageController.js
+++ b/app/docker/views/images/edit/imageController.js
@@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [
return;
}
- confirmImageExport(function (confirmed) {
+ confirmImageExport().then(function (confirmed) {
if (!confirmed) {
return;
}
diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html
deleted file mode 100644
index c92ffc787..000000000
--- a/app/docker/views/images/images.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js
deleted file mode 100644
index faf48b8c6..000000000
--- a/app/docker/views/images/imagesController.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import _ from 'lodash-es';
-import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
-import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
-import { confirmDestructive } from '@@/modals/confirm';
-import { buildConfirmButton } from '@@/modals/utils';
-import { processItemsInBatches } from '@/react/common/processItemsInBatches';
-
-angular.module('portainer.docker').controller('ImagesController', [
- '$scope',
- '$state',
- 'Authentication',
- 'ImageService',
- 'Notifications',
- 'HttpRequestHelper',
- 'FileSaver',
- 'Blob',
- 'endpoint',
- '$async',
- function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint) {
- $scope.endpoint = endpoint;
- $scope.isAdmin = Authentication.isAdmin();
-
- $scope.state = {
- actionInProgress: false,
- exportInProgress: false,
- pullRateValid: false,
- };
-
- $scope.formValues = {
- RegistryModel: new PorImageRegistryModel(),
- NodeName: null,
- };
-
- $scope.pullImage = function () {
- const registryModel = $scope.formValues.RegistryModel;
-
- var nodeName = $scope.formValues.NodeName;
-
- $scope.state.actionInProgress = true;
- ImageService.pullImage(registryModel, nodeName)
- .then(function success() {
- Notifications.success('Image successfully pulled', registryModel.Image);
- $state.reload();
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to pull image');
- })
- .finally(function final() {
- $scope.state.actionInProgress = false;
- });
- };
-
- function confirmImageForceRemoval() {
- return confirmDestructive({
- title: 'Are you sure?',
- message:
- "Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
- confirmButton: buildConfirmButton('Remove the image', 'danger'),
- });
- }
-
- function confirmRegularRemove() {
- return confirmDestructive({
- title: 'Are you sure?',
- message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
- confirmButton: buildConfirmButton('Remove the image', 'danger'),
- });
- }
-
- /**
- *
- * @param {Array} selectedItems
- * @param {boolean} force
- */
- $scope.confirmRemovalAction = async function (selectedItems, force) {
- const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove());
-
- if (!confirmed) {
- return;
- }
-
- $scope.removeAction(selectedItems, force);
- };
-
- /**
- *
- * @param {Array} selectedItems
- */
- function isAuthorizedToDownload(selectedItems) {
- for (var i = 0; i < selectedItems.length; i++) {
- var image = selectedItems[i];
-
- var untagged = _.find(image.tags, function (item) {
- return item.indexOf('') > -1;
- });
-
- if (untagged) {
- Notifications.warning('', 'Cannot download a untagged image');
- return false;
- }
- }
-
- if (_.uniqBy(selectedItems, 'NodeName').length > 1) {
- Notifications.warning('', 'Cannot download images from different nodes at the same time');
- return false;
- }
-
- return true;
- }
-
- /**
- *
- * @param {Array} images
- */
- function exportImages(images) {
- HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName);
- $scope.state.exportInProgress = true;
- ImageService.downloadImages(images)
- .then(function success(data) {
- var downloadData = new Blob([data.file], { type: 'application/x-tar' });
- FileSaver.saveAs(downloadData, 'images.tar');
- Notifications.success('Success', 'Image(s) successfully downloaded');
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to download image(s)');
- })
- .finally(function final() {
- $scope.state.exportInProgress = false;
- });
- }
-
- /**
- *
- * @param {Array} selectedItems
- */
- $scope.downloadAction = function (selectedItems) {
- if (!isAuthorizedToDownload(selectedItems)) {
- return;
- }
-
- confirmImageExport(function (confirmed) {
- if (!confirmed) {
- return;
- }
- exportImages(selectedItems);
- });
- };
-
- $scope.removeAction = removeAction;
-
- /**
- *
- * @param {Array} selectedItems
- * @param {boolean} force
- */
- async function removeAction(selectedItems, force) {
- async function doRemove(image) {
- HttpRequestHelper.setPortainerAgentTargetHeader(image.nodeName);
- return ImageService.deleteImage(image.id, force)
- .then(function success() {
- Notifications.success('Image successfully removed', image.id);
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to remove image');
- });
- }
-
- await processItemsInBatches(selectedItems, doRemove);
- $state.reload();
- }
-
- $scope.setPullImageValidity = setPullImageValidity;
- function setPullImageValidity(validity) {
- $scope.state.pullRateValid = validity;
- }
- },
-]);
diff --git a/app/react/components/ImageConfigFieldset/SimpleForm.tsx b/app/react/components/ImageConfigFieldset/SimpleForm.tsx
index 94d0840ab..ab1344055 100644
--- a/app/react/components/ImageConfigFieldset/SimpleForm.tsx
+++ b/app/react/components/ImageConfigFieldset/SimpleForm.tsx
@@ -62,7 +62,12 @@ export function SimpleForm({
/>
-
+
{registryUrl}
@@ -92,6 +97,7 @@ export function SimpleForm({
}}
icon={DockerIcon}
data-cy="component-dockerHubSearchButton"
+ size="medium"
>
Search
diff --git a/app/react/portainer/registries/utils/getImageConfig.ts b/app/react/components/ImageConfigFieldset/getImageConfig.ts
similarity index 79%
rename from app/react/portainer/registries/utils/getImageConfig.ts
rename to app/react/components/ImageConfigFieldset/getImageConfig.ts
index 3beeab9ea..fb3da89cf 100644
--- a/app/react/portainer/registries/utils/getImageConfig.ts
+++ b/app/react/components/ImageConfigFieldset/getImageConfig.ts
@@ -2,10 +2,12 @@ import { imageContainsURL } from '@/react/docker/images/utils';
import { ImageConfigValues } from '@@/ImageConfigFieldset';
-import { Registry, RegistryId } from '../types/registry';
-
-import { findBestMatchRegistry } from './findRegistryMatch';
-import { getURL } from './getUrl';
+import {
+ Registry,
+ RegistryId,
+} from '../../portainer/registries/types/registry';
+import { findBestMatchRegistry } from '../../portainer/registries/utils/findRegistryMatch';
+import { getURL } from '../../portainer/registries/utils/getUrl';
export function getDefaultImageConfig(): ImageConfigValues {
return {
diff --git a/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx b/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx
index 07fc5def0..460775848 100644
--- a/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx
+++ b/app/react/components/form-components/AutocompleteSelect/AutocompleteSelect.tsx
@@ -43,14 +43,14 @@ export function AutocompleteSelect({
return (
void;
+ error?: FormikErrors;
}) {
const environmentId = useEnvironmentId();
@@ -36,7 +39,7 @@ export function NodeSelector({
}, [nodesQuery.data, onChange, value]);
return (
-
+
(
export function ImagesDatatable({
isHostColumnVisible,
- isExportInProgress,
- onDownload,
- onRemove,
}: {
isHostColumnVisible: boolean;
-
- onDownload: (images: Array) => void;
- onRemove: (images: Array, force: true) => void;
- isExportInProgress: boolean;
}) {
const environmentId = useEnvironmentId();
const tableState = useTableState(settingsStore, tableKey);
@@ -76,13 +67,9 @@ export function ImagesDatatable({
)}
renderTableActions={(selectedItems) => (
-
+
-
+
);
}
-
-function RemoveButtonMenu({
- onRemove,
- selectedItems,
-}: {
- selectedItems: Array;
- onRemove(selectedItems: Array, force: boolean): void;
-}) {
- return (
-
-
-
-
-
-
- );
-}
-
-function ImportExportButtons({
- isExportInProgress,
- selectedItems,
- onExportClick,
-}: {
- isExportInProgress: boolean;
- selectedItems: Array;
- onExportClick(selectedItems: Array): void;
-}) {
- return (
-
-
-
-
-
- onExportClick(selectedItems)}
- disabled={selectedItems.length === 0}
- >
- Export
-
-
-
- );
-}
diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx
new file mode 100644
index 000000000..5c380ae1d
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.tsx
@@ -0,0 +1,95 @@
+import { Download, Upload } from 'lucide-react';
+import _ from 'lodash';
+
+import { Authorized } from '@/react/hooks/useUser';
+import { notifyWarning } from '@/portainer/services/notifications';
+
+import { Button, ButtonGroup, LoadingButton } from '@@/buttons';
+import { Link } from '@@/Link';
+
+import { ImagesListResponse } from '../../queries/useImages';
+import { useExportMutation } from '../../queries/useExportMutation';
+import { confirmImageExport } from '../../common/ConfirmExportModal';
+
+export function ImportExportButtons({
+ selectedItems,
+}: {
+ selectedItems: Array;
+}) {
+ const exportMutation = useExportMutation();
+
+ return (
+
+
+
+
+
+ handleExport()}
+ disabled={selectedItems.length === 0}
+ >
+ Export
+
+
+
+ );
+
+ async function handleExport() {
+ if (!isValidToDownload(selectedItems)) {
+ return;
+ }
+
+ const confirmed = await confirmImageExport();
+
+ if (!confirmed) {
+ return;
+ }
+
+ exportMutation.mutate({
+ images: selectedItems,
+ nodeName: selectedItems[0].nodeName,
+ });
+ }
+}
+
+function isValidToDownload(selectedItems: Array) {
+ for (let i = 0; i < selectedItems.length; i++) {
+ const image = selectedItems[i];
+
+ const untagged = image.tags?.find((item) => item.includes(''));
+
+ if (untagged) {
+ notifyWarning('', 'Cannot download a untagged image');
+ return false;
+ }
+ }
+
+ if (_.uniqBy(selectedItems, 'NodeName').length > 1) {
+ notifyWarning(
+ '',
+ 'Cannot download images from different nodes at the same time'
+ );
+ return false;
+ }
+
+ return true;
+}
diff --git a/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx b/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx
new file mode 100644
index 000000000..98c23e2da
--- /dev/null
+++ b/app/react/docker/images/ListView/ImagesDatatable/RemoveButtonMenu.tsx
@@ -0,0 +1,128 @@
+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 { promiseSequence } from '@/portainer/helpers/promise-utils';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { Button, ButtonGroup } from '@@/buttons';
+import { ButtonWithRef } from '@@/buttons/Button';
+import { confirmDestructive } from '@@/modals/confirm';
+import { buildConfirmButton } from '@@/modals/utils';
+
+import { ImagesListResponse } from '../../queries/useImages';
+import { queryKeys } from '../../queries/queryKeys';
+import { deleteImage } from '../../queries/useDeleteImageMutation';
+
+export function RemoveButtonMenu({
+ selectedItems,
+}: {
+ selectedItems: Array;
+}) {
+ const deleteImageListMutation = useDeleteImageListMutation();
+
+ return (
+
+
+
+
+
+
+ );
+
+ 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,
+ },
+ { onSuccess() {} }
+ );
+ }
+}
+
+function useDeleteImageListMutation() {
+ const environmentId = useEnvironmentId();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({
+ imageIds,
+ ...args
+ }: {
+ imageIds: Array;
+ } & Omit[0], 'imageId' | 'environmentId'>) =>
+ promiseSequence(
+ imageIds.map(
+ (imageId) => () =>
+ deleteImage({ ...args, environmentId, imageId }).then(() =>
+ notifySuccess('Image successfully removed', imageId)
+ )
+ )
+ ),
+ ...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
+ });
+}
diff --git a/app/react/docker/images/ListView/ListView.tsx b/app/react/docker/images/ListView/ListView.tsx
new file mode 100644
index 000000000..87e13c6d3
--- /dev/null
+++ b/app/react/docker/images/ListView/ListView.tsx
@@ -0,0 +1,24 @@
+import { PageHeader } from '@@/PageHeader';
+
+import { useIsSwarmAgent } from '../../proxy/queries/useIsSwarmAgent';
+
+import { PullImageFormWidget } from './PullImageFormWidget';
+import { ImagesDatatable } from './ImagesDatatable/ImagesDatatable';
+
+export function ListView() {
+ const isSwarmAgent = useIsSwarmAgent();
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}
diff --git a/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx b/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx
new file mode 100644
index 000000000..3f912309a
--- /dev/null
+++ b/app/react/docker/images/ListView/PullImageFormWidget.Form.tsx
@@ -0,0 +1,54 @@
+import { Form, useFormikContext } from 'formik';
+
+import { ImageConfigFieldset } from '@@/ImageConfigFieldset';
+import { FormSection } from '@@/form-components/FormSection';
+import { FormActions } from '@@/form-components/FormActions';
+
+import { NodeSelector } from '../../agent/NodeSelector';
+
+import { FormValues } from './PullImageFormWidget.types';
+
+export function PullImageForm({
+ onRateLimit,
+ isLoading,
+ isNodeVisible,
+}: {
+ onRateLimit: (limited?: boolean) => void;
+ isLoading: boolean;
+ isNodeVisible: boolean;
+}) {
+ const { values, setFieldValue, errors, isValid } =
+ useFormikContext();
+
+ return (
+
+ );
+}
diff --git a/app/react/docker/images/ListView/PullImageFormWidget.tsx b/app/react/docker/images/ListView/PullImageFormWidget.tsx
new file mode 100644
index 000000000..5468632a1
--- /dev/null
+++ b/app/react/docker/images/ListView/PullImageFormWidget.tsx
@@ -0,0 +1,77 @@
+import { DownloadIcon } from 'lucide-react';
+import { Formik } from 'formik';
+import { useState } from 'react';
+
+import { useAuthorizations } from '@/react/hooks/useUser';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { notifySuccess } from '@/portainer/services/notifications';
+
+import { Widget } from '@@/Widget';
+import { getDefaultImageConfig } from '@@/ImageConfigFieldset/getImageConfig';
+
+import { usePullImageMutation } from '../queries/usePullImageMutation';
+
+import { FormValues } from './PullImageFormWidget.types';
+import { PullImageForm } from './PullImageFormWidget.Form';
+import { useValidation } from './PullImageFormWidget.validation';
+
+export function PullImageFormWidget({
+ isNodeVisible,
+}: {
+ isNodeVisible: boolean;
+}) {
+ const envId = useEnvironmentId();
+ const mutation = usePullImageMutation(envId);
+ const authorizedQuery = useAuthorizations('DockerImageCreate');
+ const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
+
+ const validation = useValidation(isDockerhubRateLimited, isNodeVisible);
+
+ if (!authorizedQuery.authorized) {
+ return null;
+ }
+
+ const initialValues: FormValues = {
+ node: '',
+ config: getDefaultImageConfig(),
+ };
+
+ return (
+
+
+
+
+
+ setIsDockerhubRateLimited(limited)
+ }
+ isLoading={mutation.isLoading}
+ isNodeVisible={isNodeVisible}
+ />
+
+
+
+ );
+
+ function handleSubmit({ config, node }: FormValues) {
+ mutation.mutate(
+ {
+ environmentId: envId,
+ image: config.image,
+ nodeName: node,
+ registryId: config.registryId,
+ ignoreErrors: false,
+ },
+ {
+ onSuccess() {
+ notifySuccess('Image successfully pulled', config.image);
+ },
+ }
+ );
+ }
+}
diff --git a/app/react/docker/images/ListView/PullImageFormWidget.types.ts b/app/react/docker/images/ListView/PullImageFormWidget.types.ts
new file mode 100644
index 000000000..3e4f3e628
--- /dev/null
+++ b/app/react/docker/images/ListView/PullImageFormWidget.types.ts
@@ -0,0 +1,6 @@
+import { ImageConfigValues } from '@@/ImageConfigFieldset';
+
+export interface FormValues {
+ config: ImageConfigValues;
+ node: string;
+}
diff --git a/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx b/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx
new file mode 100644
index 000000000..d00ab00de
--- /dev/null
+++ b/app/react/docker/images/ListView/PullImageFormWidget.validation.test.tsx
@@ -0,0 +1,30 @@
+import { render } from '@testing-library/react';
+
+import { FormValues } from './PullImageFormWidget.types';
+import { useValidation } from './PullImageFormWidget.validation';
+
+function setup(...args: Parameters) {
+ const returnVal: { schema?: ReturnType } = {
+ schema: undefined,
+ };
+ function TestComponent() {
+ Object.assign(returnVal, { schema: useValidation(...args) });
+ return null;
+ }
+ render();
+ return returnVal;
+}
+
+test('image is required', async () => {
+ const { schema } = setup(false, false);
+ const object: FormValues = {
+ config: { image: '', registryId: 0, useRegistry: true },
+ node: '',
+ };
+
+ await expect(
+ schema?.validate(object, { strict: true })
+ ).rejects.toThrowErrorMatchingInlineSnapshot(
+ `[ValidationError: Image is required]`
+ );
+});
diff --git a/app/react/docker/images/ListView/PullImageFormWidget.validation.tsx b/app/react/docker/images/ListView/PullImageFormWidget.validation.tsx
new file mode 100644
index 000000000..3d95ffc09
--- /dev/null
+++ b/app/react/docker/images/ListView/PullImageFormWidget.validation.tsx
@@ -0,0 +1,26 @@
+import { useMemo } from 'react';
+import { SchemaOf, object, string } from 'yup';
+
+import { imageConfigValidation } from '@@/ImageConfigFieldset';
+
+import { FormValues } from './PullImageFormWidget.types';
+
+export function useValidation(
+ isDockerhubRateLimited: boolean,
+ isNodeVisible: boolean
+): SchemaOf {
+ return useMemo(
+ () =>
+ object({
+ config: imageConfigValidation().test(
+ 'rate-limits',
+ 'Rate limit exceeded',
+ () => !isDockerhubRateLimited
+ ),
+ node: isNodeVisible
+ ? string().required('Node is required')
+ : string().default(''),
+ }),
+ [isDockerhubRateLimited, isNodeVisible]
+ );
+}
diff --git a/app/react/docker/images/common/ConfirmExportModal.tsx b/app/react/docker/images/common/ConfirmExportModal.tsx
index 67b456bda..74e106b52 100644
--- a/app/react/docker/images/common/ConfirmExportModal.tsx
+++ b/app/react/docker/images/common/ConfirmExportModal.tsx
@@ -1,15 +1,13 @@
import { ModalType } from '@@/modals';
-import { ConfirmCallback, openConfirm } from '@@/modals/confirm';
+import { openConfirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
-export async function confirmImageExport(callback: ConfirmCallback) {
- const result = await openConfirm({
+export async function confirmImageExport() {
+ return openConfirm({
modalType: ModalType.Warn,
title: 'Caution',
message:
'The export may take several minutes, do not navigate away whilst the export is in progress.',
confirmButton: buildConfirmButton('Continue'),
});
-
- callback(result);
}
diff --git a/app/react/docker/images/queries/queryKeys.ts b/app/react/docker/images/queries/queryKeys.ts
index b14dd091f..79db4a0a2 100644
--- a/app/react/docker/images/queries/queryKeys.ts
+++ b/app/react/docker/images/queries/queryKeys.ts
@@ -4,7 +4,7 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
- [dockerQueryKeys.root(environmentId), 'images'] as const,
+ [...dockerQueryKeys.root(environmentId), 'images'] as const,
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
[...queryKeys.base(environmentId), options] as const,
};
diff --git a/app/react/docker/images/queries/useDeleteImageMutation.ts b/app/react/docker/images/queries/useDeleteImageMutation.ts
new file mode 100644
index 000000000..d536a9962
--- /dev/null
+++ b/app/react/docker/images/queries/useDeleteImageMutation.ts
@@ -0,0 +1,46 @@
+import { RawAxiosRequestHeaders } from 'axios';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+
+import { withInvalidate } from '@/react-tools/react-query';
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
+
+import { queryKeys } from './queryKeys';
+
+export function useDeleteImageMutation(envId: EnvironmentId) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: deleteImage,
+ ...withInvalidate(queryClient, [queryKeys.base(envId)]),
+ });
+}
+
+export async function deleteImage({
+ environmentId,
+ imageId,
+ nodeName,
+ force,
+}: {
+ environmentId: EnvironmentId;
+ imageId: string;
+ nodeName?: string;
+ force?: boolean;
+}) {
+ const headers: RawAxiosRequestHeaders = {};
+
+ if (nodeName) {
+ headers['X-PortainerAgent-Target'] = nodeName;
+ }
+
+ try {
+ await axios.delete(buildDockerProxyUrl(environmentId, 'images', imageId), {
+ headers,
+ params: { force },
+ });
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Unable to pull image');
+ }
+}
diff --git a/app/react/docker/images/queries/useExportMutation.ts b/app/react/docker/images/queries/useExportMutation.ts
new file mode 100644
index 000000000..9a06df17f
--- /dev/null
+++ b/app/react/docker/images/queries/useExportMutation.ts
@@ -0,0 +1,70 @@
+import { RawAxiosRequestHeaders } from 'axios';
+import { useMutation } from '@tanstack/react-query';
+import { saveAs } from 'file-saver';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
+
+export function useExportMutation() {
+ const environmentId = useEnvironmentId();
+ return useMutation({
+ mutationFn: (
+ args: Omit[0], 'environmentId'>
+ ) => exportImage({ ...args, environmentId }),
+ });
+}
+
+export async function exportImage({
+ environmentId,
+ nodeName,
+ images,
+}: {
+ environmentId: EnvironmentId;
+ nodeName?: string;
+ images: Array<{ tags?: Array; id: string }>;
+}) {
+ const { names } = getImagesNamesForDownload(images);
+
+ const headers: RawAxiosRequestHeaders = {};
+
+ if (nodeName) {
+ headers['X-PortainerAgent-Target'] = nodeName;
+ }
+
+ try {
+ const { headers: responseHeaders, data } = await axios.get(
+ buildDockerProxyUrl(environmentId, 'images', 'get'),
+ {
+ headers,
+ responseType: 'blob',
+ params: {
+ names,
+ },
+ }
+ );
+
+ const contentDispositionHeader = responseHeaders['content-disposition'];
+ const filename = contentDispositionHeader
+ .replace('attachment; filename=', '')
+ .trim();
+ saveAs(data, filename);
+ } catch (err) {
+ throw parseAxiosError(err as Error, 'Unable to pull image');
+ }
+}
+
+export function getImagesNamesForDownload(
+ images: Array<{ tags?: Array; id: string }>
+) {
+ const names = images.map((image) =>
+ image.tags?.length && image.tags[0] !== ':'
+ ? image.tags[0]
+ : image.id
+ );
+ return {
+ names,
+ };
+}
diff --git a/app/react/docker/images/queries/usePullImageMutation.ts b/app/react/docker/images/queries/usePullImageMutation.ts
index f99f996a4..b398b9311 100644
--- a/app/react/docker/images/queries/usePullImageMutation.ts
+++ b/app/react/docker/images/queries/usePullImageMutation.ts
@@ -1,13 +1,43 @@
+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 { withInvalidate } from '@/react-tools/react-query';
import { buildImageFullURI } from '../utils';
-import {
- withRegistryAuthHeader,
- withAgentTargetHeader,
-} from '../../proxy/queries/utils';
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
+import {
+ withAgentTargetHeader,
+ withRegistryAuthHeader,
+} from '../../proxy/queries/utils';
+
+import { queryKeys } from './queryKeys';
+
+type UsePullImageMutation = Omit & {
+ registryId?: Registry['Id'];
+};
+
+export function usePullImageMutation(envId: EnvironmentId) {
+ const queryClient = useQueryClient();
+ const registriesQuery = useEnvironmentRegistries(envId);
+
+ return useMutation({
+ mutationFn: (args: UsePullImageMutation) =>
+ pullImage({
+ ...args,
+ registry: getRegistry(registriesQuery.data || [], args.registryId),
+ }),
+ ...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;
diff --git a/app/react/docker/proxy/queries/useIsSwarmAgent.tsx b/app/react/docker/proxy/queries/useIsSwarmAgent.tsx
new file mode 100644
index 000000000..b092f2571
--- /dev/null
+++ b/app/react/docker/proxy/queries/useIsSwarmAgent.tsx
@@ -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);
+}