mirror of https://github.com/portainer/portainer
feat(docker/images): show used tag correctly [EE-5396] (#10305)
parent
b895e88075
commit
9bf2957ea7
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/containers"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/images"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
|
@ -45,6 +46,9 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
|
||||
containersHandler := containers.NewHandler("/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
|
||||
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
|
||||
|
||||
imagesHandler := images.NewHandler("/{id}/images", bouncer, dockerClientFactory)
|
||||
endpointRouter.PathPrefix("/images").Handler(imagesHandler)
|
||||
return h
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package images
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
dockerClientFactory *client.ClientFactory
|
||||
bouncer security.BouncerService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process non-proxied requests to docker APIs directly.
|
||||
func NewHandler(routePrefix string, bouncer security.BouncerService, dockerClientFactory *client.ClientFactory) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
dockerClientFactory: dockerClientFactory,
|
||||
bouncer: bouncer,
|
||||
}
|
||||
|
||||
router := h.PathPrefix(routePrefix).Subrouter()
|
||||
router.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
router.Handle("", httperror.LoggerHandler(h.imagesList)).Methods(http.MethodGet)
|
||||
return h
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
package images
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/portainer/portainer/api/http/handler/docker/utils"
|
||||
"github.com/portainer/portainer/api/internal/set"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type ImageResponse struct {
|
||||
Created int64 `json:"created"`
|
||||
NodeName string `json:"nodeName"`
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
// Used is true if the image is used by at least one container
|
||||
// supplied only when withUsage is true
|
||||
Used bool `json:"used"`
|
||||
}
|
||||
|
||||
// @id dockerImagesList
|
||||
// @summary Fetch images
|
||||
// @description
|
||||
// @description **Access policy**:
|
||||
// @tags docker
|
||||
// @security jwt
|
||||
// @param environmentId path int true "Environment identifier"
|
||||
// @param withUsage query boolean false "Include image usage information"
|
||||
// @produce json
|
||||
// @success 200 {array} ImageResponse "Success"
|
||||
// @failure 400 "Bad request"
|
||||
// @failure 500 "Internal server error"
|
||||
// @router /docker/{environmentId}/images [get]
|
||||
func (handler *Handler) imagesList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli, httpErr := utils.GetClient(r, handler.dockerClientFactory)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
images, err := cli.ImageList(r.Context(), types.ImageListOptions{})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker images", err)
|
||||
}
|
||||
|
||||
withUsage, err := request.RetrieveBooleanQueryParameter(r, "withUsage", true)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: withUsage", err)
|
||||
}
|
||||
|
||||
imageUsageSet := set.Set[string]{}
|
||||
if withUsage {
|
||||
containers, err := cli.ContainerList(r.Context(), types.ContainerListOptions{})
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve Docker containers", err)
|
||||
}
|
||||
|
||||
for _, container := range containers {
|
||||
imageUsageSet.Add(container.ImageID)
|
||||
}
|
||||
}
|
||||
|
||||
imagesList := make([]ImageResponse, len(images))
|
||||
for i, image := range images {
|
||||
imagesList[i] = ImageResponse{
|
||||
Created: image.Created,
|
||||
ID: image.ID,
|
||||
Size: image.Size,
|
||||
Tags: image.RepoTags,
|
||||
Used: imageUsageSet.Contains(image.ID),
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, imagesList)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
prclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
)
|
||||
|
||||
// GetClient returns a Docker client based on the request context
|
||||
func GetClient(r *http.Request, dockerClientFactory *prclient.ClientFactory) (*dockerclient.Client, *httperror.HandlerError) {
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return nil, httperror.NotFound("Unable to find an environment on request context", err)
|
||||
}
|
||||
|
||||
agentTargetHeader := r.Header.Get(portainer.PortainerAgentTargetHeader)
|
||||
|
||||
cli, err := dockerClientFactory.CreateClient(endpoint, agentTargetHeader, nil)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to connect to the Docker daemon", err)
|
||||
}
|
||||
|
||||
return cli, nil
|
||||
}
|
|
@ -14,12 +14,17 @@ function ImageHelperFactory() {
|
|||
return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@/react/docker/images/queries/useImages').ImagesListResponse[]} images
|
||||
* @returns {{names: string[]}}}
|
||||
*/
|
||||
function getImagesNamesForDownload(images) {
|
||||
var names = images.map(function (image) {
|
||||
return image.RepoTags[0] !== '<none>:<none>' ? image.RepoTags[0] : image.Id;
|
||||
return image.tags[0] !== '<none>:<none>' ? image.tags[0] : image.id;
|
||||
});
|
||||
return {
|
||||
names: names,
|
||||
names,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -74,13 +74,10 @@ const ngModule = angular
|
|||
.component(
|
||||
'dockerImagesDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [
|
||||
'dataset',
|
||||
'environment',
|
||||
'onRemove',
|
||||
'isExportInProgress',
|
||||
'isHostColumnVisible',
|
||||
'onDownload',
|
||||
'onRefresh',
|
||||
'onRemove',
|
||||
])
|
||||
)
|
||||
|
|
|
@ -46,15 +46,9 @@
|
|||
</div>
|
||||
|
||||
<docker-images-datatable
|
||||
ng-if="images"
|
||||
dataset="images"
|
||||
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
|
||||
on-download="(downloadAction)"
|
||||
on-remove="(confirmRemovalAction)"
|
||||
on-refresh="(getImages)"
|
||||
is-export-in-progress="state.exportInProgress"
|
||||
storage-key="images"
|
||||
environment="endpoint"
|
||||
settings-store="settingsStore"
|
||||
containers="containers"
|
||||
></docker-images-datatable>
|
||||
|
|
|
@ -71,6 +71,11 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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());
|
||||
|
||||
|
@ -81,11 +86,15 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
$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.RepoTags, function (item) {
|
||||
var untagged = _.find(image.tags, function (item) {
|
||||
return item.indexOf('<none>') > -1;
|
||||
});
|
||||
|
||||
|
@ -103,8 +112,12 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} images
|
||||
*/
|
||||
function exportImages(images) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(images[0].NodeName);
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName);
|
||||
$scope.state.exportInProgress = true;
|
||||
ImageService.downloadImages(images)
|
||||
.then(function success(data) {
|
||||
|
@ -120,6 +133,10 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
|
||||
*/
|
||||
$scope.downloadAction = function (selectedItems) {
|
||||
if (!isAuthorizedToDownload(selectedItems)) {
|
||||
return;
|
||||
|
@ -133,15 +150,20 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
});
|
||||
};
|
||||
|
||||
$scope.removeAction = function (selectedItems, force) {
|
||||
$scope.removeAction = removeAction;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
|
||||
* @param {boolean} force
|
||||
*/
|
||||
function removeAction(selectedItems, force) {
|
||||
var actionCount = selectedItems.length;
|
||||
angular.forEach(selectedItems, function (image) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName);
|
||||
ImageService.deleteImage(image.Id, force)
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(image.nodeName);
|
||||
ImageService.deleteImage(image.id, force)
|
||||
.then(function success() {
|
||||
Notifications.success('Image successfully removed', image.Id);
|
||||
var index = $scope.images.indexOf(image);
|
||||
$scope.images.splice(index, 1);
|
||||
Notifications.success('Image successfully removed', image.id);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove image');
|
||||
|
@ -153,29 +175,11 @@ angular.module('portainer.docker').controller('ImagesController', [
|
|||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.getImages = getImages;
|
||||
function getImages() {
|
||||
ImageService.images(true)
|
||||
.then(function success(data) {
|
||||
$scope.images = data;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve images');
|
||||
$scope.images = [];
|
||||
});
|
||||
}
|
||||
|
||||
$scope.setPullImageValidity = setPullImageValidity;
|
||||
function setPullImageValidity(validity) {
|
||||
$scope.state.pullRateValid = validity;
|
||||
}
|
||||
|
||||
function initView() {
|
||||
getImages();
|
||||
}
|
||||
|
||||
initView();
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -3,7 +3,7 @@ import _ from 'lodash';
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import DockerIcon from '@/assets/ico/vendor/docker.svg?c';
|
||||
import { useImages } from '@/react/docker/images/queries/useImages';
|
||||
import { useImages } from '@/react/docker/proxy/queries/images/useImages';
|
||||
import {
|
||||
imageContainsURL,
|
||||
getUniqueTagListFromImages,
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import {
|
||||
getContainerImagesStatus,
|
||||
getServiceImagesStatus,
|
||||
} from '@/react/docker/images/image.service';
|
||||
import { useEnvironment } from '@/react/portainer/environments/queries';
|
||||
import { statusIcon } from '@/react/docker/components/ImageStatus/helpers';
|
||||
import { ResourceID, ResourceType } from '@/react/docker/images/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { ResourceID, ResourceType } from './types';
|
||||
import { useImageNotification } from './useImageNotification';
|
||||
|
||||
export interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
resourceId: ResourceID;
|
||||
|
@ -56,30 +53,3 @@ export function ImageStatus({
|
|||
<Icon icon={statusIcon(data)} size="sm" className="!mr-1 align-middle" />
|
||||
);
|
||||
}
|
||||
|
||||
export function useImageNotification(
|
||||
environmentId: number,
|
||||
resourceId: ResourceID,
|
||||
resourceType: ResourceType,
|
||||
nodeName: string,
|
||||
enabled = false
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'docker',
|
||||
'images',
|
||||
resourceType,
|
||||
resourceId,
|
||||
'status',
|
||||
],
|
||||
() =>
|
||||
resourceType === ResourceType.SERVICE
|
||||
? getServiceImagesStatus(environmentId, resourceId)
|
||||
: getContainerImagesStatus(environmentId, resourceId, nodeName),
|
||||
{
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
|
|||
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
|
||||
import UpdatesUnknown from '@/assets/ico/icon_updates-unknown.svg?c';
|
||||
|
||||
import { ImageStatus } from '../../images/types';
|
||||
import { ImageStatus } from './types';
|
||||
|
||||
export function statusIcon(status: ImageStatus) {
|
||||
switch (status.Status) {
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
type Status = 'outdated' | 'updated' | 'inprocess' | string;
|
||||
|
||||
export enum ResourceType {
|
||||
CONTAINER,
|
||||
SERVICE,
|
||||
}
|
||||
|
||||
export interface ImageStatus {
|
||||
Status: Status;
|
||||
Message: string;
|
||||
}
|
||||
|
||||
export type ResourceID = string;
|
|
@ -1,12 +1,40 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { ServiceId } from '@/react/docker/services/types';
|
||||
import { ContainerId } from '@/react/docker/containers/types';
|
||||
|
||||
import { ContainerId } from '../containers/types';
|
||||
import { ImageStatus, ResourceID, ResourceType } from './types';
|
||||
|
||||
import { ImageStatus } from './types';
|
||||
export function useImageNotification(
|
||||
environmentId: number,
|
||||
resourceId: ResourceID,
|
||||
resourceType: ResourceType,
|
||||
nodeName: string,
|
||||
enabled = false
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'docker',
|
||||
'images',
|
||||
resourceType,
|
||||
resourceId,
|
||||
'status',
|
||||
],
|
||||
() =>
|
||||
resourceType === ResourceType.SERVICE
|
||||
? getServiceImagesStatus(environmentId, resourceId)
|
||||
: getContainerImagesStatus(environmentId, resourceId, nodeName),
|
||||
{
|
||||
enabled,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getContainerImagesStatus(
|
||||
async function getContainerImagesStatus(
|
||||
environmentId: EnvironmentId,
|
||||
containerID: ContainerId,
|
||||
nodeName: string
|
||||
|
@ -29,7 +57,7 @@ export async function getContainerImagesStatus(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getServiceImagesStatus(
|
||||
async function getServiceImagesStatus(
|
||||
environmentId: EnvironmentId,
|
||||
serviceID: ServiceId
|
||||
) {
|
|
@ -10,8 +10,8 @@ import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button';
|
|||
import { positionRight } from '@reach/popover';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import {
|
||||
|
@ -24,14 +24,12 @@ import { useTableState } from '@@/datatables/useTableState';
|
|||
import { Button, ButtonGroup, LoadingButton } from '@@/buttons';
|
||||
import { Link } from '@@/Link';
|
||||
import { ButtonWithRef } from '@@/buttons/Button';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
|
||||
import { DockerImage } from '../../types';
|
||||
import { ImagesListResponse, useImages } from '../../queries/useImages';
|
||||
|
||||
import { columns as defColumns } from './columns';
|
||||
import { host as hostColumn } from './columns/host';
|
||||
import { RowProvider } from './RowContext';
|
||||
|
||||
const tableKey = 'images';
|
||||
|
||||
|
@ -48,76 +46,67 @@ const settingsStore = createPersistedStore<TableSettings>(
|
|||
);
|
||||
|
||||
export function ImagesDatatable({
|
||||
dataset,
|
||||
|
||||
environment,
|
||||
isHostColumnVisible,
|
||||
isExportInProgress,
|
||||
onDownload,
|
||||
onRefresh,
|
||||
onRemove,
|
||||
}: {
|
||||
dataset: Array<DockerImage>;
|
||||
environment: Environment;
|
||||
isHostColumnVisible: boolean;
|
||||
|
||||
onDownload: (images: Array<DockerImage>) => void;
|
||||
onRemove: (images: Array<DockerImage>, force: true) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onDownload: (images: Array<ImagesListResponse>) => void;
|
||||
onRemove: (images: Array<ImagesListResponse>, force: true) => void;
|
||||
isExportInProgress: boolean;
|
||||
}) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useTableState(settingsStore, tableKey);
|
||||
const columns = useMemo(
|
||||
() => (isHostColumnVisible ? [...defColumns, hostColumn] : defColumns),
|
||||
[isHostColumnVisible]
|
||||
);
|
||||
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
const imagesQuery = useImages(environmentId, true, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<RowProvider context={{ environment }}>
|
||||
<Datatable
|
||||
title="Images"
|
||||
titleIcon={List}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<RemoveButtonMenu
|
||||
selectedItems={selectedItems}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
<Datatable
|
||||
title="Images"
|
||||
titleIcon={List}
|
||||
renderTableActions={(selectedItems) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<RemoveButtonMenu selectedItems={selectedItems} onRemove={onRemove} />
|
||||
|
||||
<ImportExportButtons
|
||||
isExportInProgress={isExportInProgress}
|
||||
onExportClick={onDownload}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
<ImportExportButtons
|
||||
isExportInProgress={isExportInProgress}
|
||||
onExportClick={onDownload}
|
||||
selectedItems={selectedItems}
|
||||
/>
|
||||
|
||||
<Authorized authorizations="DockerImageBuild">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{ to: 'docker.images.build' }}
|
||||
data-cy="image-buildImageButton"
|
||||
icon={Plus}
|
||||
>
|
||||
Build a new image
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
dataset={dataset}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
emptyContentLabel="No images found"
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={(value) => tableState.setAutoRefreshRate(value)}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
/>
|
||||
</RowProvider>
|
||||
<Authorized authorizations="DockerImageBuild">
|
||||
<Button
|
||||
as={Link}
|
||||
props={{ to: 'docker.images.build' }}
|
||||
data-cy="image-buildImageButton"
|
||||
icon={Plus}
|
||||
>
|
||||
Build a new image
|
||||
</Button>
|
||||
</Authorized>
|
||||
</div>
|
||||
)}
|
||||
dataset={imagesQuery.data || []}
|
||||
isLoading={imagesQuery.isLoading}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
emptyContentLabel="No images found"
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={tableState.autoRefreshRate}
|
||||
onChange={(value) => tableState.setAutoRefreshRate(value)}
|
||||
/>
|
||||
</TableSettingsMenu>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -125,8 +114,8 @@ function RemoveButtonMenu({
|
|||
onRemove,
|
||||
selectedItems,
|
||||
}: {
|
||||
selectedItems: Array<DockerImage>;
|
||||
onRemove(selectedItems: Array<DockerImage>, force: boolean): void;
|
||||
selectedItems: Array<ImagesListResponse>;
|
||||
onRemove(selectedItems: Array<ImagesListResponse>, force: boolean): void;
|
||||
}) {
|
||||
return (
|
||||
<Authorized authorizations="DockerImageDelete">
|
||||
|
@ -176,8 +165,8 @@ function ImportExportButtons({
|
|||
onExportClick,
|
||||
}: {
|
||||
isExportInProgress: boolean;
|
||||
selectedItems: Array<DockerImage>;
|
||||
onExportClick(selectedItems: Array<DockerImage>): void;
|
||||
selectedItems: Array<ImagesListResponse>;
|
||||
onExportClick(selectedItems: Array<ImagesListResponse>): void;
|
||||
}) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -2,7 +2,7 @@ import { isoDateFromTimestamp } from '@/portainer/filters/filters';
|
|||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const created = columnHelper.accessor('Created', {
|
||||
export const created = columnHelper.accessor('created', {
|
||||
id: 'created',
|
||||
header: 'Created',
|
||||
cell: ({ getValue }) => {
|
||||
|
|
|
@ -1,16 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
||||
|
||||
export const columnHelper = createColumnHelper<
|
||||
DockerImage & { NodeName?: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Docker response from proxy (with added portainer metadata)
|
||||
* images view model
|
||||
* images snapshot
|
||||
* snapshots view model
|
||||
*
|
||||
*
|
||||
*/
|
||||
export const columnHelper = createColumnHelper<ImagesListResponse>();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const host = columnHelper.accessor('NodeName', {
|
||||
export const host = columnHelper.accessor('nodeName', {
|
||||
header: 'Host',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
|
|
|
@ -1,21 +1,21 @@
|
|||
import { CellContext, Column } from '@tanstack/react-table';
|
||||
import { useSref } from '@uirouter/react';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
import { truncate } from '@/portainer/filters/filters';
|
||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
||||
|
||||
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const id = columnHelper.accessor('Id', {
|
||||
export const id = columnHelper.accessor('id', {
|
||||
id: 'id',
|
||||
header: 'Id',
|
||||
cell: Cell,
|
||||
enableColumnFilter: true,
|
||||
filterFn: (
|
||||
{ original: { Used } },
|
||||
{ original: { used } },
|
||||
columnId,
|
||||
filterValue: Array<'Used' | 'Unused'>
|
||||
) => {
|
||||
|
@ -23,11 +23,11 @@ export const id = columnHelper.accessor('Id', {
|
|||
return true;
|
||||
}
|
||||
|
||||
if (filterValue.includes('Used') && Used) {
|
||||
if (filterValue.includes('Used') && used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterValue.includes('Unused') && !Used) {
|
||||
if (filterValue.includes('Unused') && !used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -63,12 +63,12 @@ function FilterByUsage<TData extends { Used: boolean }>({
|
|||
function Cell({
|
||||
getValue,
|
||||
row: { original: image },
|
||||
}: CellContext<DockerImage, string>) {
|
||||
}: CellContext<ImagesListResponse, string>) {
|
||||
const name = getValue();
|
||||
|
||||
const linkProps = useSref('.image', {
|
||||
id: image.Id,
|
||||
imageId: image.Id,
|
||||
id: image.id,
|
||||
imageId: image.id,
|
||||
});
|
||||
|
||||
return (
|
||||
|
@ -76,7 +76,7 @@ function Cell({
|
|||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
||||
{truncate(name, 40)}
|
||||
</a>
|
||||
{!image.Used && (
|
||||
{!image.used && (
|
||||
<span className="label label-warning image-tag ml-2">Unused</span>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { humanize } from '@/portainer/filters/filters';
|
|||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const size = columnHelper.accessor('VirtualSize', {
|
||||
export const size = columnHelper.accessor('size', {
|
||||
id: 'size',
|
||||
header: 'Size',
|
||||
cell: ({ getValue }) => {
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { DockerImage } from '@/react/docker/images/types';
|
||||
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const tags = columnHelper.accessor('RepoTags', {
|
||||
export const tags = columnHelper.accessor('tags', {
|
||||
id: 'tags',
|
||||
header: 'Tags',
|
||||
cell: Cell,
|
||||
});
|
||||
|
||||
function Cell({ getValue }: CellContext<DockerImage, string[]>) {
|
||||
function Cell({ getValue }: CellContext<ImagesListResponse, string[]>) {
|
||||
const repoTags = getValue();
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { buildUrl as buildDockerUrl } from '@/react/docker/queries/utils/build-url';
|
||||
|
||||
export function buildUrl(environmentId: EnvironmentId) {
|
||||
return buildDockerUrl(environmentId, 'images');
|
||||
}
|
|
@ -5,5 +5,6 @@ import { queryKeys as dockerQueryKeys } from '../../queries/utils';
|
|||
export const queryKeys = {
|
||||
base: (environmentId: EnvironmentId) =>
|
||||
[dockerQueryKeys.root(environmentId), 'images'] as const,
|
||||
list: (environmentId: EnvironmentId) => queryKeys.base(environmentId),
|
||||
list: (environmentId: EnvironmentId, options: { withUsage?: boolean } = {}) =>
|
||||
[...queryKeys.base(environmentId), options] as const,
|
||||
};
|
||||
|
|
|
@ -3,119 +3,51 @@ import { useQuery } from 'react-query';
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildUrl } from '../../proxy/queries/build-url';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
interface ImageSummary {
|
||||
/**
|
||||
* Number of containers using this image. Includes both stopped and running containers.
|
||||
*
|
||||
* This size is not calculated by default, and depends on which API endpoint is used.
|
||||
* `-1` indicates that the value has not been set / calculated.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
Containers: number;
|
||||
export interface ImagesListResponse {
|
||||
created: number;
|
||||
nodeName?: string;
|
||||
id: string;
|
||||
size: number;
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* Date and time at which the image was created as a Unix timestamp
|
||||
* (number of seconds sinds EPOCH).
|
||||
*
|
||||
* Required: true
|
||||
* Used is true if the image is used by at least one container.
|
||||
* supplied only when withUsage is true
|
||||
*/
|
||||
Created: number;
|
||||
|
||||
/**
|
||||
* ID is the content-addressable ID of an image.
|
||||
*
|
||||
* This identifier is a content-addressable digest calculated from the
|
||||
* image's configuration (which includes the digests of layers used by
|
||||
* the image).
|
||||
*
|
||||
* Note that this digest differs from the `RepoDigests` below, which
|
||||
* holds digests of image manifests that reference the image.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
Id: string;
|
||||
|
||||
/**
|
||||
* User-defined key/value metadata.
|
||||
* Required: true
|
||||
*/
|
||||
Labels: { [key: string]: string };
|
||||
|
||||
/**
|
||||
* ID of the parent image.
|
||||
*
|
||||
* Depending on how the image was created, this field may be empty and
|
||||
* is only set for images that were built/created locally. This field
|
||||
* is empty if the image was pulled from an image registry.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
ParentId: string;
|
||||
|
||||
/**
|
||||
* List of content-addressable digests of locally available image manifests
|
||||
* that the image is referenced from. Multiple manifests can refer to the
|
||||
* same image.
|
||||
*
|
||||
* These digests are usually only available if the image was either pulled
|
||||
* from a registry, or if the image was pushed to a registry, which is when
|
||||
* the manifest is generated and its digest calculated.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
RepoDigests: string[];
|
||||
|
||||
/**
|
||||
* List of image names/tags in the local image cache that reference this
|
||||
* image.
|
||||
*
|
||||
* Multiple image tags can refer to the same image, and this list may be
|
||||
* empty if no tags reference the image, in which case the image is
|
||||
* "untagged", in which case it can still be referenced by its ID.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
RepoTags: string[];
|
||||
|
||||
/**
|
||||
* Total size of image layers that are shared between this image and other
|
||||
* images.
|
||||
*
|
||||
* This size is not calculated by default. `-1` indicates that the value
|
||||
* has not been set / calculated.
|
||||
*
|
||||
* Required: true
|
||||
*/
|
||||
SharedSize: number;
|
||||
Size: number;
|
||||
VirtualSize: number;
|
||||
used: boolean;
|
||||
}
|
||||
|
||||
type ImagesListResponse = ImageSummary[];
|
||||
|
||||
export function useImages<T = ImagesListResponse>(
|
||||
export function useImages<T = Array<ImagesListResponse>>(
|
||||
environmentId: EnvironmentId,
|
||||
withUsage = false,
|
||||
{
|
||||
select,
|
||||
enabled,
|
||||
}: { select?(data: ImagesListResponse): T; enabled?: boolean } = {}
|
||||
refetchInterval,
|
||||
}: {
|
||||
select?(data: Array<ImagesListResponse>): T;
|
||||
enabled?: boolean;
|
||||
refetchInterval?: number;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.list(environmentId),
|
||||
() => getImages(environmentId),
|
||||
{ select, enabled }
|
||||
queryKeys.list(environmentId, { withUsage }),
|
||||
() => getImages(environmentId, { withUsage }),
|
||||
{ select, enabled, refetchInterval }
|
||||
);
|
||||
}
|
||||
|
||||
async function getImages(environmentId: EnvironmentId) {
|
||||
async function getImages(
|
||||
environmentId: EnvironmentId,
|
||||
{ withUsage }: { withUsage?: boolean } = {}
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ImagesListResponse>(
|
||||
buildUrl(environmentId, 'images', 'json')
|
||||
const { data } = await axios.get<Array<ImagesListResponse>>(
|
||||
buildUrl(environmentId),
|
||||
{ params: { withUsage } }
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
|
|
@ -1,19 +1,5 @@
|
|||
import { DockerImageResponse } from './types/response';
|
||||
|
||||
type Status = 'outdated' | 'updated' | 'inprocess' | string;
|
||||
|
||||
export enum ResourceType {
|
||||
CONTAINER,
|
||||
SERVICE,
|
||||
}
|
||||
|
||||
export interface ImageStatus {
|
||||
Status: Status;
|
||||
Message: string;
|
||||
}
|
||||
|
||||
export type ResourceID = string;
|
||||
|
||||
type DecoratedDockerImage = {
|
||||
Used: boolean;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { queryKeys as proxyQueryKeys } from '../query-keys';
|
||||
|
||||
export const queryKeys = {
|
||||
base: (environmentId: EnvironmentId) =>
|
||||
[proxyQueryKeys.base(environmentId), 'images'] as const,
|
||||
list: (environmentId: EnvironmentId) => queryKeys.base(environmentId),
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { ImageSummary } from 'docker-types/generated/1.41';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildUrl } from '../build-url';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
type ImagesListResponse = Array<ImageSummary>;
|
||||
|
||||
export function useImages<T = ImagesListResponse>(
|
||||
environmentId: EnvironmentId,
|
||||
{
|
||||
select,
|
||||
enabled,
|
||||
}: { select?(data: ImagesListResponse): T; enabled?: boolean } = {}
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.list(environmentId),
|
||||
() => getImages(environmentId),
|
||||
{ select, enabled }
|
||||
);
|
||||
}
|
||||
|
||||
async function getImages(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<ImagesListResponse>(
|
||||
buildUrl(environmentId, 'images', 'json')
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to retrieve images');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export function buildUrl(environmentId: EnvironmentId, path: string) {
|
||||
return `/docker/${environmentId}/${path}`;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import axios from '@/portainer/services/axios';
|
||||
import { ImageStatus } from '@/react/docker/images/types';
|
||||
import { ImageStatus } from '@/react/docker/components/ImageStatus/types';
|
||||
|
||||
export async function getStackImagesStatus(id: number) {
|
||||
try {
|
||||
|
|
Loading…
Reference in New Issue