refactor(docker/images): migrate list view to react [EE-2229]

fix [EE-2229]

fix(images): add data-cy

refactor(docker/images): migrate list view to react [EE-2229]

fix [EE-2229]
pull/11579/head
Chaim Lev-Ari 2024-03-18 09:18:40 +02:00 committed by Chaim Lev-Ari
parent 7549b6cf3f
commit 611a91077c
29 changed files with 666 additions and 381 deletions

View File

@ -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);
}

View File

@ -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: {

View File

@ -11,7 +11,6 @@ import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
import { InsightsBox } from '@/react/components/InsightsBox';
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
import { ImagesDatatable } from '@/react/docker/images/ListView/ImagesDatatable/ImagesDatatable';
import { EventsDatatable } from '@/react/docker/events/EventsDatatables';
import { 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)), [

View File

@ -0,0 +1,13 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/docker/images/ListView/ListView';
export const imagesModule = angular
.module('portainer.docker.react.views.images', [])
.component(
'imagesListView',
r2a(withUIRouter(withCurrentUser(ListView)), [])
).name;

View File

@ -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)), [])

View File

@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [
return;
}
confirmImageExport(function (confirmed) {
confirmImageExport().then(function (confirmed) {
if (!confirmed) {
return;
}

View File

@ -1,54 +0,0 @@
<page-header title="'Image list'" breadcrumbs="['Images']" reload="true"> </page-header>
<div class="row" authorization="DockerImageCreate">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="download" title-text="Pull image "> </rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- image-and-registry -->
<por-image-registry
model="formValues.RegistryModel"
auto-complete="true"
label-class="col-sm-1"
input-class="col-sm-11"
endpoint="endpoint"
is-admin="isAdmin"
set-validity="setPullImageValidity"
check-rate-limits="true"
>
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12 form-section-title"> Deployment </div>
<!-- node-selection -->
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
<!-- !node-selection -->
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || !state.pullRateValid"
ng-click="pullImage()"
button-spinner="state.actionInProgress"
>
<span ng-hide="state.actionInProgress">Pull the image</span>
<span ng-show="state.actionInProgress">Download in progress...</span>
</button>
</div>
</div>
</por-image-registry>
<!-- !image-and-registry -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<docker-images-datatable
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
on-download="(downloadAction)"
on-remove="(confirmRemovalAction)"
is-export-in-progress="state.exportInProgress"
environment="endpoint"
></docker-images-datatable>

View File

@ -1,177 +0,0 @@
import _ from 'lodash-es';
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
import { confirmDestructive } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
angular.module('portainer.docker').controller('ImagesController', [
'$scope',
'$state',
'Authentication',
'ImageService',
'Notifications',
'HttpRequestHelper',
'FileSaver',
'Blob',
'endpoint',
'$async',
function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint) {
$scope.endpoint = endpoint;
$scope.isAdmin = Authentication.isAdmin();
$scope.state = {
actionInProgress: false,
exportInProgress: false,
pullRateValid: false,
};
$scope.formValues = {
RegistryModel: new PorImageRegistryModel(),
NodeName: null,
};
$scope.pullImage = function () {
const registryModel = $scope.formValues.RegistryModel;
var nodeName = $scope.formValues.NodeName;
$scope.state.actionInProgress = true;
ImageService.pullImage(registryModel, nodeName)
.then(function success() {
Notifications.success('Image successfully pulled', registryModel.Image);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to pull image');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function confirmImageForceRemoval() {
return confirmDestructive({
title: 'Are you sure?',
message:
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}
function confirmRegularRemove() {
return confirmDestructive({
title: 'Are you sure?',
message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
confirmButton: buildConfirmButton('Remove the image', 'danger'),
});
}
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
* @param {boolean} force
*/
$scope.confirmRemovalAction = async function (selectedItems, force) {
const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove());
if (!confirmed) {
return;
}
$scope.removeAction(selectedItems, force);
};
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
*/
function isAuthorizedToDownload(selectedItems) {
for (var i = 0; i < selectedItems.length; i++) {
var image = selectedItems[i];
var untagged = _.find(image.tags, function (item) {
return item.indexOf('<none>') > -1;
});
if (untagged) {
Notifications.warning('', 'Cannot download a untagged image');
return false;
}
}
if (_.uniqBy(selectedItems, 'NodeName').length > 1) {
Notifications.warning('', 'Cannot download images from different nodes at the same time');
return false;
}
return true;
}
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} images
*/
function exportImages(images) {
HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName);
$scope.state.exportInProgress = true;
ImageService.downloadImages(images)
.then(function success(data) {
var downloadData = new Blob([data.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<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;
}
},
]);

View File

@ -62,7 +62,12 @@ export function SimpleForm({
/>
</FormControl>
<FormControl label="Image" inputId="image-field" errors={errors?.image}>
<FormControl
label="Image"
inputId="image-field"
errors={errors?.image}
required
>
<InputGroup>
<InputGroup.Addon>{registryUrl}</InputGroup.Addon>
@ -92,6 +97,7 @@ export function SimpleForm({
}}
icon={DockerIcon}
data-cy="component-dockerHubSearchButton"
size="medium"
>
Search
</Button>

View File

@ -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 {

View File

@ -43,14 +43,14 @@ export function AutocompleteSelect({
return (
<Combobox
className={styles.root}
className={clsx(styles.root, 'form-control')}
aria-label="compose"
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}

View File

@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { FormikErrors } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@ -11,9 +12,11 @@ import { useAgentNodes } from './queries/useAgentNodes';
export function NodeSelector({
value,
onChange,
error,
}: {
value: string;
onChange: (value: string) => void;
error?: FormikErrors<string>;
}) {
const environmentId = useEnvironmentId();
@ -36,7 +39,7 @@ export function NodeSelector({
}, [nodesQuery.data, onChange, value]);
return (
<FormControl label="Node" inputId="node-selector">
<FormControl label="Node" inputId="node-selector" errors={error}>
<PortainerSelect
inputId="node-selector"
value={value}

View File

@ -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';

View File

@ -34,12 +34,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';

View File

@ -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,98 +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',
'data-cy': 'image-importImageLink',
}}
>
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>
);
}

View File

@ -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<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',
'data-cy': 'image-importImageButton',
}}
>
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 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;
}

View File

@ -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<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,
},
{ onSuccess() {} }
);
}
}
function useDeleteImageListMutation() {
const environmentId = useEnvironmentId();
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
imageIds,
...args
}: {
imageIds: Array<string>;
} & Omit<Parameters<typeof deleteImage>[0], 'imageId' | 'environmentId'>) =>
promiseSequence(
imageIds.map(
(imageId) => () =>
deleteImage({ ...args, environmentId, imageId }).then(() =>
notifySuccess('Image successfully removed', imageId)
)
)
),
...withInvalidate(queryClient, [queryKeys.base(environmentId)]),
});
}

View File

@ -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} />
</>
);
}

View File

@ -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>
);
}

View File

@ -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 (
<Widget>
<Widget.Title icon={DownloadIcon} title="Pull image" />
<Widget.Body>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validation={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);
},
}
);
}
}

View File

@ -0,0 +1,6 @@
import { ImageConfigValues } from '@@/ImageConfigFieldset';
export interface FormValues {
config: ImageConfigValues;
node: string;
}

View File

@ -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]`
);
});

View File

@ -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]
);
}

View File

@ -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);
}

View File

@ -4,7 +4,7 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils';
export const queryKeys = {
base: (environmentId: EnvironmentId) =>
[dockerQueryKeys.root(environmentId), 'images'] as const,
[...dockerQueryKeys.root(environmentId), 'images'] as const,
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
[...queryKeys.base(environmentId), options] as const,
};

View File

@ -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');
}
}

View File

@ -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<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 pull 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,
};
}

View File

@ -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<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),
}),
...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;

View File

@ -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);
}