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 @@ - - -
-
- - - -
- - -
-
Deployment
- - - -
-
-
- -
-
-
- -
-
-
-
-
- - 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 ( - - - - - - Toggle Dropdown - - -
- { - onRemove(selectedItems, true); - }} - > - Force Remove - -
-
-
-
-
- ); -} - -function ImportExportButtons({ - isExportInProgress, - selectedItems, - onExportClick, -}: { - isExportInProgress: boolean; - selectedItems: Array; - onExportClick(selectedItems: Array): void; -}) { - return ( - - - - - - onExportClick(selectedItems)} - disabled={selectedItems.length === 0} - > - Export - - - - ); -} diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImportExportButtons.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 ( + + + + + + Toggle Dropdown + + +
+ { + handleRemove(true); + }} + > + Force Remove + +
+
+
+
+
+ ); + + function confirmForceRemove() { + return confirmDestructive({ + title: 'Are you sure?', + message: + "Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?", + confirmButton: buildConfirmButton('Remove the image', 'danger'), + }); + } + + function confirmRegularRemove() { + return confirmDestructive({ + title: 'Are you sure?', + message: + 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?', + confirmButton: buildConfirmButton('Remove the image', 'danger'), + }); + } + + async function handleRemove(force: boolean) { + const confirmed = await (force + ? confirmForceRemove() + : confirmRegularRemove()); + + if (!confirmed) { + return; + } + + deleteImageListMutation.mutate( + { + imageIds: selectedItems.map((image) => image.id), + force, + }, + { 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 ( +
+ + setFieldValue(`config.${field}`, value) + } + errors={errors.config} + onRateLimit={onRateLimit} + > + {isNodeVisible && ( + + setFieldValue('node', node)} + error={errors.node} + /> + + )} + + + +
+ ); +} diff --git a/app/react/docker/images/ListView/PullImageFormWidget.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); +}