mirror of https://github.com/portainer/portainer
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
parent
7549b6cf3f
commit
611a91077c
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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)), [
|
||||
|
|
|
@ -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;
|
|
@ -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)), [])
|
||||
|
|
|
@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
|||
return;
|
||||
}
|
||||
|
||||
confirmImageExport(function (confirmed) {
|
||||
confirmImageExport().then(function (confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
<page-header title="'Image list'" breadcrumbs="['Images']" reload="true"> </page-header>
|
||||
|
||||
<div class="row" authorization="DockerImageCreate">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="download" title-text="Pull image "> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<!-- image-and-registry -->
|
||||
<por-image-registry
|
||||
model="formValues.RegistryModel"
|
||||
auto-complete="true"
|
||||
label-class="col-sm-1"
|
||||
input-class="col-sm-11"
|
||||
endpoint="endpoint"
|
||||
is-admin="isAdmin"
|
||||
set-validity="setPullImageValidity"
|
||||
check-rate-limits="true"
|
||||
>
|
||||
<div ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
|
||||
<div class="col-sm-12 form-section-title"> Deployment </div>
|
||||
<!-- node-selection -->
|
||||
<node-selector model="formValues.NodeName" endpoint-id="endpoint.Id"> </node-selector>
|
||||
<!-- !node-selection -->
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
ng-disabled="state.actionInProgress || !formValues.RegistryModel.Image || !state.pullRateValid"
|
||||
ng-click="pullImage()"
|
||||
button-spinner="state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="state.actionInProgress">Pull the image</span>
|
||||
<span ng-show="state.actionInProgress">Download in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</por-image-registry>
|
||||
<!-- !image-and-registry -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<docker-images-datatable
|
||||
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
on-download="(downloadAction)"
|
||||
on-remove="(confirmRemovalAction)"
|
||||
is-export-in-progress="state.exportInProgress"
|
||||
environment="endpoint"
|
||||
></docker-images-datatable>
|
|
@ -1,177 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
||||
import { confirmDestructive } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { processItemsInBatches } from '@/react/common/processItemsInBatches';
|
||||
|
||||
angular.module('portainer.docker').controller('ImagesController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
'Authentication',
|
||||
'ImageService',
|
||||
'Notifications',
|
||||
'HttpRequestHelper',
|
||||
'FileSaver',
|
||||
'Blob',
|
||||
'endpoint',
|
||||
'$async',
|
||||
function ($scope, $state, Authentication, ImageService, Notifications, HttpRequestHelper, FileSaver, Blob, endpoint) {
|
||||
$scope.endpoint = endpoint;
|
||||
$scope.isAdmin = Authentication.isAdmin();
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
exportInProgress: false,
|
||||
pullRateValid: false,
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
RegistryModel: new PorImageRegistryModel(),
|
||||
NodeName: null,
|
||||
};
|
||||
|
||||
$scope.pullImage = function () {
|
||||
const registryModel = $scope.formValues.RegistryModel;
|
||||
|
||||
var nodeName = $scope.formValues.NodeName;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
ImageService.pullImage(registryModel, nodeName)
|
||||
.then(function success() {
|
||||
Notifications.success('Image successfully pulled', registryModel.Image);
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to pull image');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
function confirmImageForceRemoval() {
|
||||
return confirmDestructive({
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
|
||||
confirmButton: buildConfirmButton('Remove the image', 'danger'),
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRegularRemove() {
|
||||
return confirmDestructive({
|
||||
title: 'Are you sure?',
|
||||
message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
|
||||
confirmButton: buildConfirmButton('Remove the image', 'danger'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
|
||||
* @param {boolean} force
|
||||
*/
|
||||
$scope.confirmRemovalAction = async function (selectedItems, force) {
|
||||
const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove());
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.removeAction(selectedItems, force);
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
|
||||
*/
|
||||
function isAuthorizedToDownload(selectedItems) {
|
||||
for (var i = 0; i < selectedItems.length; i++) {
|
||||
var image = selectedItems[i];
|
||||
|
||||
var untagged = _.find(image.tags, function (item) {
|
||||
return item.indexOf('<none>') > -1;
|
||||
});
|
||||
|
||||
if (untagged) {
|
||||
Notifications.warning('', 'Cannot download a untagged image');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (_.uniqBy(selectedItems, 'NodeName').length > 1) {
|
||||
Notifications.warning('', 'Cannot download images from different nodes at the same time');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} images
|
||||
*/
|
||||
function exportImages(images) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName);
|
||||
$scope.state.exportInProgress = true;
|
||||
ImageService.downloadImages(images)
|
||||
.then(function success(data) {
|
||||
var downloadData = new Blob([data.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;
|
||||
}
|
||||
},
|
||||
]);
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
|
@ -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}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from 'react';
|
||||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
|
@ -11,9 +12,11 @@ import { useAgentNodes } from './queries/useAgentNodes';
|
|||
export function NodeSelector({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: FormikErrors<string>;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
|
@ -36,7 +39,7 @@ export function NodeSelector({
|
|||
}, [nodesQuery.data, onChange, value]);
|
||||
|
||||
return (
|
||||
<FormControl label="Node" inputId="node-selector">
|
||||
<FormControl label="Node" inputId="node-selector" errors={error}>
|
||||
<PortainerSelect
|
||||
inputId="node-selector"
|
||||
value={value}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { getDefaultImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
|
||||
|
||||
import { getDefaultImageConfig } from '@@/ImageConfigFieldset/getImageConfig';
|
||||
|
||||
import { ContainerDetailsResponse } from '../../queries/useContainer';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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)]),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { useIsSwarmAgent } from '../../proxy/queries/useIsSwarmAgent';
|
||||
|
||||
import { PullImageFormWidget } from './PullImageFormWidget';
|
||||
import { ImagesDatatable } from './ImagesDatatable/ImagesDatatable';
|
||||
|
||||
export function ListView() {
|
||||
const isSwarmAgent = useIsSwarmAgent();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Image list" breadcrumbs="Images" reload />
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<PullImageFormWidget isNodeVisible={isSwarmAgent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImagesDatatable isHostColumnVisible={isSwarmAgent} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { Form, useFormikContext } from 'formik';
|
||||
|
||||
import { ImageConfigFieldset } from '@@/ImageConfigFieldset';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
|
||||
import { NodeSelector } from '../../agent/NodeSelector';
|
||||
|
||||
import { FormValues } from './PullImageFormWidget.types';
|
||||
|
||||
export function PullImageForm({
|
||||
onRateLimit,
|
||||
isLoading,
|
||||
isNodeVisible,
|
||||
}: {
|
||||
onRateLimit: (limited?: boolean) => void;
|
||||
isLoading: boolean;
|
||||
isNodeVisible: boolean;
|
||||
}) {
|
||||
const { values, setFieldValue, errors, isValid } =
|
||||
useFormikContext<FormValues>();
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<ImageConfigFieldset
|
||||
autoComplete
|
||||
values={values.config}
|
||||
setFieldValue={(field, value) =>
|
||||
setFieldValue(`config.${field}`, value)
|
||||
}
|
||||
errors={errors.config}
|
||||
onRateLimit={onRateLimit}
|
||||
>
|
||||
{isNodeVisible && (
|
||||
<FormSection title="Deployment">
|
||||
<NodeSelector
|
||||
value={values.node}
|
||||
onChange={(node) => setFieldValue('node', node)}
|
||||
error={errors.node}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<FormActions
|
||||
isLoading={isLoading}
|
||||
isValid={isValid}
|
||||
loadingText="Download in progress..."
|
||||
submitLabel="Pull the image"
|
||||
data-cy="pull-image-button"
|
||||
/>
|
||||
</ImageConfigFieldset>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,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);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { ImageConfigValues } from '@@/ImageConfigFieldset';
|
||||
|
||||
export interface FormValues {
|
||||
config: ImageConfigValues;
|
||||
node: string;
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import { render } from '@testing-library/react';
|
||||
|
||||
import { FormValues } from './PullImageFormWidget.types';
|
||||
import { useValidation } from './PullImageFormWidget.validation';
|
||||
|
||||
function setup(...args: Parameters<typeof useValidation>) {
|
||||
const returnVal: { schema?: ReturnType<typeof useValidation> } = {
|
||||
schema: undefined,
|
||||
};
|
||||
function TestComponent() {
|
||||
Object.assign(returnVal, { schema: useValidation(...args) });
|
||||
return null;
|
||||
}
|
||||
render(<TestComponent />);
|
||||
return returnVal;
|
||||
}
|
||||
|
||||
test('image is required', async () => {
|
||||
const { schema } = setup(false, false);
|
||||
const object: FormValues = {
|
||||
config: { image: '', registryId: 0, useRegistry: true },
|
||||
node: '',
|
||||
};
|
||||
|
||||
await expect(
|
||||
schema?.validate(object, { strict: true })
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`[ValidationError: Image is required]`
|
||||
);
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
import { useMemo } from 'react';
|
||||
import { SchemaOf, object, string } from 'yup';
|
||||
|
||||
import { imageConfigValidation } from '@@/ImageConfigFieldset';
|
||||
|
||||
import { FormValues } from './PullImageFormWidget.types';
|
||||
|
||||
export function useValidation(
|
||||
isDockerhubRateLimited: boolean,
|
||||
isNodeVisible: boolean
|
||||
): SchemaOf<FormValues> {
|
||||
return useMemo(
|
||||
() =>
|
||||
object({
|
||||
config: imageConfigValidation().test(
|
||||
'rate-limits',
|
||||
'Rate limit exceeded',
|
||||
() => !isDockerhubRateLimited
|
||||
),
|
||||
node: isNodeVisible
|
||||
? string().required('Node is required')
|
||||
: string().default(''),
|
||||
}),
|
||||
[isDockerhubRateLimited, isNodeVisible]
|
||||
);
|
||||
}
|
|
@ -1,15 +1,13 @@
|
|||
import { ModalType } from '@@/modals';
|
||||
import { ConfirmCallback, openConfirm } from '@@/modals/confirm';
|
||||
import { openConfirm } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
export async function confirmImageExport(callback: ConfirmCallback) {
|
||||
const result = await openConfirm({
|
||||
export async function confirmImageExport() {
|
||||
return openConfirm({
|
||||
modalType: ModalType.Warn,
|
||||
title: 'Caution',
|
||||
message:
|
||||
'The export may take several minutes, do not navigate away whilst the export is in progress.',
|
||||
confirmButton: buildConfirmButton('Continue'),
|
||||
});
|
||||
|
||||
callback(result);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils';
|
|||
|
||||
export const queryKeys = {
|
||||
base: (environmentId: EnvironmentId) =>
|
||||
[dockerQueryKeys.root(environmentId), 'images'] as const,
|
||||
[...dockerQueryKeys.root(environmentId), 'images'] as const,
|
||||
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
|
||||
[...queryKeys.base(environmentId), options] as const,
|
||||
};
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
import { RawAxiosRequestHeaders } from 'axios';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { withInvalidate } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useDeleteImageMutation(envId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteImage,
|
||||
...withInvalidate(queryClient, [queryKeys.base(envId)]),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteImage({
|
||||
environmentId,
|
||||
imageId,
|
||||
nodeName,
|
||||
force,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
imageId: string;
|
||||
nodeName?: string;
|
||||
force?: boolean;
|
||||
}) {
|
||||
const headers: RawAxiosRequestHeaders = {};
|
||||
|
||||
if (nodeName) {
|
||||
headers['X-PortainerAgent-Target'] = nodeName;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.delete(buildDockerProxyUrl(environmentId, 'images', imageId), {
|
||||
headers,
|
||||
params: { force },
|
||||
});
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to pull image');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue