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);
|
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 {
|
.text-muted {
|
||||||
color: var(--text-muted-color);
|
color: var(--text-muted-color);
|
||||||
}
|
}
|
||||||
|
|
|
@ -200,8 +200,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||||
url: '/images',
|
url: '/images',
|
||||||
views: {
|
views: {
|
||||||
'content@': {
|
'content@': {
|
||||||
templateUrl: './views/images/images.html',
|
component: 'imagesListView',
|
||||||
controller: 'ImagesController',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { HealthStatus } from '@/react/docker/containers/ItemView/HealthStatus';
|
||||||
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
|
import { GpusList } from '@/react/docker/host/SetupView/GpusList';
|
||||||
import { InsightsBox } from '@/react/components/InsightsBox';
|
import { InsightsBox } from '@/react/components/InsightsBox';
|
||||||
import { BetaAlert } from '@/react/portainer/environments/update-schedules/common/BetaAlert';
|
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 { EventsDatatable } from '@/react/docker/events/EventsDatatables';
|
||||||
import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatable';
|
import { ConfigsDatatable } from '@/react/docker/configs/ListView/ConfigsDatatable';
|
||||||
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
|
import { AgentHostBrowser } from '@/react/docker/host/BrowseView/AgentHostBrowser';
|
||||||
|
@ -69,16 +68,6 @@ const ngModule = angular
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
|
.component('betaAlert', r2a(BetaAlert, ['className', 'message', 'isHtml']))
|
||||||
.component(
|
|
||||||
'dockerImagesDatatable',
|
|
||||||
r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [
|
|
||||||
'onRemove',
|
|
||||||
'isExportInProgress',
|
|
||||||
'isHostColumnVisible',
|
|
||||||
'onDownload',
|
|
||||||
'onRemove',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
.component(
|
.component(
|
||||||
'dockerConfigsDatatable',
|
'dockerConfigsDatatable',
|
||||||
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
|
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 { DashboardView } from '@/react/docker/DashboardView/DashboardView';
|
||||||
|
|
||||||
import { containersModule } from './containers';
|
import { containersModule } from './containers';
|
||||||
|
import { imagesModule } from './images';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.docker.react.views', [containersModule])
|
.module('portainer.docker.react.views', [containersModule, imagesModule])
|
||||||
.component(
|
.component(
|
||||||
'dockerDashboardView',
|
'dockerDashboardView',
|
||||||
r2a(withUIRouter(withCurrentUser(DashboardView)), [])
|
r2a(withUIRouter(withCurrentUser(DashboardView)), [])
|
||||||
|
|
|
@ -182,7 +182,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
confirmImageExport(function (confirmed) {
|
confirmImageExport().then(function (confirmed) {
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
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>
|
||||||
|
|
||||||
<FormControl label="Image" inputId="image-field" errors={errors?.image}>
|
<FormControl
|
||||||
|
label="Image"
|
||||||
|
inputId="image-field"
|
||||||
|
errors={errors?.image}
|
||||||
|
required
|
||||||
|
>
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroup.Addon>{registryUrl}</InputGroup.Addon>
|
<InputGroup.Addon>{registryUrl}</InputGroup.Addon>
|
||||||
|
|
||||||
|
@ -92,6 +97,7 @@ export function SimpleForm({
|
||||||
}}
|
}}
|
||||||
icon={DockerIcon}
|
icon={DockerIcon}
|
||||||
data-cy="component-dockerHubSearchButton"
|
data-cy="component-dockerHubSearchButton"
|
||||||
|
size="medium"
|
||||||
>
|
>
|
||||||
Search
|
Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -2,10 +2,12 @@ import { imageContainsURL } from '@/react/docker/images/utils';
|
||||||
|
|
||||||
import { ImageConfigValues } from '@@/ImageConfigFieldset';
|
import { ImageConfigValues } from '@@/ImageConfigFieldset';
|
||||||
|
|
||||||
import { Registry, RegistryId } from '../types/registry';
|
import {
|
||||||
|
Registry,
|
||||||
import { findBestMatchRegistry } from './findRegistryMatch';
|
RegistryId,
|
||||||
import { getURL } from './getUrl';
|
} from '../../portainer/registries/types/registry';
|
||||||
|
import { findBestMatchRegistry } from '../../portainer/registries/utils/findRegistryMatch';
|
||||||
|
import { getURL } from '../../portainer/registries/utils/getUrl';
|
||||||
|
|
||||||
export function getDefaultImageConfig(): ImageConfigValues {
|
export function getDefaultImageConfig(): ImageConfigValues {
|
||||||
return {
|
return {
|
|
@ -43,14 +43,14 @@ export function AutocompleteSelect({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
className={styles.root}
|
className={clsx(styles.root, 'form-control')}
|
||||||
aria-label="compose"
|
aria-label="compose"
|
||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
data-cy="component-gitComposeInput"
|
data-cy="component-gitComposeInput"
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
className="form-control"
|
className="w-full bg-transparent border-none outline-none"
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { FormikErrors } from 'formik';
|
||||||
|
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
@ -11,9 +12,11 @@ import { useAgentNodes } from './queries/useAgentNodes';
|
||||||
export function NodeSelector({
|
export function NodeSelector({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
error,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
|
error?: FormikErrors<string>;
|
||||||
}) {
|
}) {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
@ -36,7 +39,7 @@ export function NodeSelector({
|
||||||
}, [nodesQuery.data, onChange, value]);
|
}, [nodesQuery.data, onChange, value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormControl label="Node" inputId="node-selector">
|
<FormControl label="Node" inputId="node-selector" errors={error}>
|
||||||
<PortainerSelect
|
<PortainerSelect
|
||||||
inputId="node-selector"
|
inputId="node-selector"
|
||||||
value={value}
|
value={value}
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||||
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
import { ResourceControlOwnership } from '@/react/portainer/access-control/types';
|
||||||
import { UserId } from '@/portainer/users/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';
|
import { ContainerDetailsResponse } from '../../queries/useContainer';
|
||||||
|
|
||||||
|
|
|
@ -34,12 +34,12 @@ import {
|
||||||
} from '@/react/docker/containers/CreateView/VolumesTab';
|
} from '@/react/docker/containers/CreateView/VolumesTab';
|
||||||
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
import { envVarsTabUtils } from '@/react/docker/containers/CreateView/EnvVarsTab';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
import { getImageConfig } from '@/react/portainer/registries/utils/getImageConfig';
|
|
||||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks';
|
import { useWebhooks } from '@/react/portainer/webhooks/useWebhooks';
|
||||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||||
|
|
||||||
|
import { getImageConfig } from '@@/ImageConfigFieldset/getImageConfig';
|
||||||
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
|
import { EnvVarValues } from '@@/form-components/EnvironmentVariablesFieldset';
|
||||||
|
|
||||||
import { useNetworksForSelector } from '../components/NetworkSelector';
|
import { useNetworksForSelector } from '../components/NetworkSelector';
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { ChevronDown, Download, List, Trash2, Upload } from 'lucide-react';
|
import { List } from 'lucide-react';
|
||||||
import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button';
|
|
||||||
import { positionRight } from '@reach/popover';
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Authorized } from '@/react/hooks/useUser';
|
import { Authorized } from '@/react/hooks/useUser';
|
||||||
|
@ -16,17 +14,17 @@ import {
|
||||||
RefreshableTableSettings,
|
RefreshableTableSettings,
|
||||||
} from '@@/datatables/types';
|
} from '@@/datatables/types';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { AddButton, Button, ButtonGroup, LoadingButton } from '@@/buttons';
|
import { AddButton } from '@@/buttons';
|
||||||
import { Link } from '@@/Link';
|
|
||||||
import { ButtonWithRef } from '@@/buttons/Button';
|
|
||||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||||
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
|
import { mergeOptions } from '@@/datatables/extend-options/mergeOptions';
|
||||||
import { withColumnFilters } from '@@/datatables/extend-options/withColumnFilters';
|
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 { columns as defColumns } from './columns';
|
||||||
import { host as hostColumn } from './columns/host';
|
import { host as hostColumn } from './columns/host';
|
||||||
|
import { RemoveButtonMenu } from './RemoveButtonMenu';
|
||||||
|
import { ImportExportButtons } from './ImportExportButtons';
|
||||||
|
|
||||||
const tableKey = 'images';
|
const tableKey = 'images';
|
||||||
|
|
||||||
|
@ -46,15 +44,8 @@ const settingsStore = createPersistedStore<TableSettings>(
|
||||||
|
|
||||||
export function ImagesDatatable({
|
export function ImagesDatatable({
|
||||||
isHostColumnVisible,
|
isHostColumnVisible,
|
||||||
isExportInProgress,
|
|
||||||
onDownload,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
}: {
|
||||||
isHostColumnVisible: boolean;
|
isHostColumnVisible: boolean;
|
||||||
|
|
||||||
onDownload: (images: Array<ImagesListResponse>) => void;
|
|
||||||
onRemove: (images: Array<ImagesListResponse>, force: true) => void;
|
|
||||||
isExportInProgress: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const environmentId = useEnvironmentId();
|
const environmentId = useEnvironmentId();
|
||||||
const tableState = useTableState(settingsStore, tableKey);
|
const tableState = useTableState(settingsStore, tableKey);
|
||||||
|
@ -76,13 +67,9 @@ export function ImagesDatatable({
|
||||||
)}
|
)}
|
||||||
renderTableActions={(selectedItems) => (
|
renderTableActions={(selectedItems) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RemoveButtonMenu selectedItems={selectedItems} onRemove={onRemove} />
|
<RemoveButtonMenu selectedItems={selectedItems} />
|
||||||
|
|
||||||
<ImportExportButtons
|
<ImportExportButtons selectedItems={selectedItems} />
|
||||||
isExportInProgress={isExportInProgress}
|
|
||||||
onExportClick={onDownload}
|
|
||||||
selectedItems={selectedItems}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Authorized authorizations="DockerImageBuild">
|
<Authorized authorizations="DockerImageBuild">
|
||||||
<AddButton
|
<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 { ModalType } from '@@/modals';
|
||||||
import { ConfirmCallback, openConfirm } from '@@/modals/confirm';
|
import { openConfirm } from '@@/modals/confirm';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
|
|
||||||
export async function confirmImageExport(callback: ConfirmCallback) {
|
export async function confirmImageExport() {
|
||||||
const result = await openConfirm({
|
return openConfirm({
|
||||||
modalType: ModalType.Warn,
|
modalType: ModalType.Warn,
|
||||||
title: 'Caution',
|
title: 'Caution',
|
||||||
message:
|
message:
|
||||||
'The export may take several minutes, do not navigate away whilst the export is in progress.',
|
'The export may take several minutes, do not navigate away whilst the export is in progress.',
|
||||||
confirmButton: buildConfirmButton('Continue'),
|
confirmButton: buildConfirmButton('Continue'),
|
||||||
});
|
});
|
||||||
|
|
||||||
callback(result);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils';
|
||||||
|
|
||||||
export const queryKeys = {
|
export const queryKeys = {
|
||||||
base: (environmentId: EnvironmentId) =>
|
base: (environmentId: EnvironmentId) =>
|
||||||
[dockerQueryKeys.root(environmentId), 'images'] as const,
|
[...dockerQueryKeys.root(environmentId), 'images'] as const,
|
||||||
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
|
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
|
||||||
[...queryKeys.base(environmentId), options] as const,
|
[...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 axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
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 { buildImageFullURI } from '../utils';
|
||||||
import {
|
|
||||||
withRegistryAuthHeader,
|
|
||||||
withAgentTargetHeader,
|
|
||||||
} from '../../proxy/queries/utils';
|
|
||||||
import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl';
|
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 {
|
interface PullImageOptions {
|
||||||
environmentId: EnvironmentId;
|
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