From 9bf2957ea7a2d12a583eb5731064744838fe9dc3 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Tue, 3 Oct 2023 15:55:23 +0300 Subject: [PATCH] feat(docker/images): show used tag correctly [EE-5396] (#10305) --- api/http/handler/docker/handler.go | 4 + api/http/handler/docker/images/handler.go | 32 +++++ api/http/handler/docker/images/images_list.go | 79 +++++++++++ api/http/handler/docker/utils/get_client.go | 28 ++++ app/docker/helpers/imageHelper.js | 9 +- app/docker/react/components/index.ts | 3 - app/docker/views/images/images.html | 6 - app/docker/views/images/imagesController.js | 56 ++++---- .../ImageConfigFieldset/SimpleForm.tsx | 2 +- .../components/ImageStatus/ImageStatus.tsx | 36 +---- .../docker/components/ImageStatus/helpers.ts | 2 +- .../docker/components/ImageStatus/types.ts | 13 ++ .../ImageStatus/useImageNotification.ts} | 36 ++++- .../ImagesDatatable/ImagesDatatable.tsx | 113 +++++++--------- .../ListView/ImagesDatatable/RowContext.ts | 11 -- .../ImagesDatatable/columns/created.tsx | 2 +- .../ImagesDatatable/columns/helper.ts | 15 +-- .../ListView/ImagesDatatable/columns/host.tsx | 2 +- .../ListView/ImagesDatatable/columns/id.tsx | 18 +-- .../ListView/ImagesDatatable/columns/size.tsx | 2 +- .../ListView/ImagesDatatable/columns/tags.tsx | 6 +- app/react/docker/images/queries/build-url.ts | 6 + app/react/docker/images/queries/queryKeys.ts | 3 +- app/react/docker/images/queries/useImages.ts | 124 ++++-------------- app/react/docker/images/types.ts | 14 -- .../docker/proxy/queries/images/queryKeys.ts | 9 ++ .../docker/proxy/queries/images/useImages.ts | 36 +++++ app/react/docker/queries/utils/build-url.ts | 5 + .../columns/getStackImagesStatus.ts | 2 +- 29 files changed, 385 insertions(+), 289 deletions(-) create mode 100644 api/http/handler/docker/images/handler.go create mode 100644 api/http/handler/docker/images/images_list.go create mode 100644 api/http/handler/docker/utils/get_client.go create mode 100644 app/react/docker/components/ImageStatus/types.ts rename app/react/docker/{images/image.service.ts => components/ImageStatus/useImageNotification.ts} (57%) delete mode 100644 app/react/docker/images/ListView/ImagesDatatable/RowContext.ts create mode 100644 app/react/docker/images/queries/build-url.ts create mode 100644 app/react/docker/proxy/queries/images/queryKeys.ts create mode 100644 app/react/docker/proxy/queries/images/useImages.ts create mode 100644 app/react/docker/queries/utils/build-url.ts diff --git a/api/http/handler/docker/handler.go b/api/http/handler/docker/handler.go index 3ea61ce12..487611e63 100644 --- a/api/http/handler/docker/handler.go +++ b/api/http/handler/docker/handler.go @@ -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 } diff --git a/api/http/handler/docker/images/handler.go b/api/http/handler/docker/images/handler.go new file mode 100644 index 000000000..a6ff2fd16 --- /dev/null +++ b/api/http/handler/docker/images/handler.go @@ -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 +} diff --git a/api/http/handler/docker/images/images_list.go b/api/http/handler/docker/images/images_list.go new file mode 100644 index 000000000..33d8d40c7 --- /dev/null +++ b/api/http/handler/docker/images/images_list.go @@ -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) +} diff --git a/api/http/handler/docker/utils/get_client.go b/api/http/handler/docker/utils/get_client.go new file mode 100644 index 000000000..90bb18618 --- /dev/null +++ b/api/http/handler/docker/utils/get_client.go @@ -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 +} diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index 7b6969faa..9326bd1f9 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -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] !== ':' ? image.RepoTags[0] : image.Id; + return image.tags[0] !== ':' ? image.tags[0] : image.id; }); return { - names: names, + names, }; } diff --git a/app/docker/react/components/index.ts b/app/docker/react/components/index.ts index 583955818..c4163f15e 100644 --- a/app/docker/react/components/index.ts +++ b/app/docker/react/components/index.ts @@ -74,13 +74,10 @@ const ngModule = angular .component( 'dockerImagesDatatable', r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [ - 'dataset', - 'environment', 'onRemove', 'isExportInProgress', 'isHostColumnVisible', 'onDownload', - 'onRefresh', 'onRemove', ]) ) diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 5d6dc784e..c92ffc787 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -46,15 +46,9 @@ diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index 32893892a..3250301e8 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -71,6 +71,11 @@ angular.module('portainer.docker').controller('ImagesController', [ }); } + /** + * + * @param {Array} 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} 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('') > -1; }); @@ -103,8 +112,12 @@ angular.module('portainer.docker').controller('ImagesController', [ return true; } + /** + * + * @param {Array} 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} 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} 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(); }, ]); diff --git a/app/react/components/ImageConfigFieldset/SimpleForm.tsx b/app/react/components/ImageConfigFieldset/SimpleForm.tsx index a3329e0ae..6e13bc223 100644 --- a/app/react/components/ImageConfigFieldset/SimpleForm.tsx +++ b/app/react/components/ImageConfigFieldset/SimpleForm.tsx @@ -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, diff --git a/app/react/docker/components/ImageStatus/ImageStatus.tsx b/app/react/docker/components/ImageStatus/ImageStatus.tsx index eabc8213c..5dda8e0c6 100644 --- a/app/react/docker/components/ImageStatus/ImageStatus.tsx +++ b/app/react/docker/components/ImageStatus/ImageStatus.tsx @@ -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({ ); } - -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, - } - ); -} diff --git a/app/react/docker/components/ImageStatus/helpers.ts b/app/react/docker/components/ImageStatus/helpers.ts index bc7238967..e11e82a89 100644 --- a/app/react/docker/components/ImageStatus/helpers.ts +++ b/app/react/docker/components/ImageStatus/helpers.ts @@ -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) { diff --git a/app/react/docker/components/ImageStatus/types.ts b/app/react/docker/components/ImageStatus/types.ts new file mode 100644 index 000000000..c8bde18b1 --- /dev/null +++ b/app/react/docker/components/ImageStatus/types.ts @@ -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; diff --git a/app/react/docker/images/image.service.ts b/app/react/docker/components/ImageStatus/useImageNotification.ts similarity index 57% rename from app/react/docker/images/image.service.ts rename to app/react/docker/components/ImageStatus/useImageNotification.ts index 51f5e3d71..0286623cf 100644 --- a/app/react/docker/images/image.service.ts +++ b/app/react/docker/components/ImageStatus/useImageNotification.ts @@ -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 ) { diff --git a/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx index 502c5a512..ee408df3f 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/ImagesDatatable.tsx @@ -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( ); export function ImagesDatatable({ - dataset, - - environment, isHostColumnVisible, isExportInProgress, onDownload, - onRefresh, onRemove, }: { - dataset: Array; - environment: Environment; isHostColumnVisible: boolean; - onDownload: (images: Array) => void; - onRemove: (images: Array, force: true) => void; - onRefresh: () => Promise; + onDownload: (images: Array) => void; + onRemove: (images: Array, 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 ( - - ( -
- - - - - - - -
- )} - dataset={dataset} - settingsManager={tableState} - columns={columns} - emptyContentLabel="No images found" - renderTableSettings={() => ( - - tableState.setAutoRefreshRate(value)} - /> - - )} - /> -
+ ( +
+ + + + + + + +
+ )} + dataset={imagesQuery.data || []} + isLoading={imagesQuery.isLoading} + settingsManager={tableState} + columns={columns} + emptyContentLabel="No images found" + renderTableSettings={() => ( + + tableState.setAutoRefreshRate(value)} + /> + + )} + /> ); } @@ -125,8 +114,8 @@ function RemoveButtonMenu({ onRemove, selectedItems, }: { - selectedItems: Array; - onRemove(selectedItems: Array, force: boolean): void; + selectedItems: Array; + onRemove(selectedItems: Array, force: boolean): void; }) { return ( @@ -176,8 +165,8 @@ function ImportExportButtons({ onExportClick, }: { isExportInProgress: boolean; - selectedItems: Array; - onExportClick(selectedItems: Array): void; + selectedItems: Array; + onExportClick(selectedItems: Array): void; }) { return ( diff --git a/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts b/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts deleted file mode 100644 index 66af35873..000000000 --- a/app/react/docker/images/ListView/ImagesDatatable/RowContext.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Environment } from '@/react/portainer/environments/types'; - -import { createRowContext } from '@@/datatables/RowContext'; - -interface RowContextState { - environment: Environment; -} - -const { RowProvider, useRowContext } = createRowContext(); - -export { RowProvider, useRowContext }; diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx index 338fddba7..cf102e09d 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/created.tsx @@ -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 }) => { diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts b/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts index 4bf279376..e7957b4fa 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/helper.ts @@ -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(); diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx index 8728f53c4..13772ff22 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/host.tsx @@ -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(); diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx index f6f2940bd..116781978 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/id.tsx @@ -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({ function Cell({ getValue, row: { original: image }, -}: CellContext) { +}: CellContext) { 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({ {truncate(name, 40)} - {!image.Used && ( + {!image.used && ( Unused )} diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx index db687d576..adabd3427 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/size.tsx @@ -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 }) => { diff --git a/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx index 9e0a3da4d..7764930b7 100644 --- a/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx +++ b/app/react/docker/images/ListView/ImagesDatatable/columns/tags.tsx @@ -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) { +function Cell({ getValue }: CellContext) { const repoTags = getValue(); return ( diff --git a/app/react/docker/images/queries/build-url.ts b/app/react/docker/images/queries/build-url.ts new file mode 100644 index 000000000..71d6df3a0 --- /dev/null +++ b/app/react/docker/images/queries/build-url.ts @@ -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'); +} diff --git a/app/react/docker/images/queries/queryKeys.ts b/app/react/docker/images/queries/queryKeys.ts index 158cd6191..b14dd091f 100644 --- a/app/react/docker/images/queries/queryKeys.ts +++ b/app/react/docker/images/queries/queryKeys.ts @@ -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, }; diff --git a/app/react/docker/images/queries/useImages.ts b/app/react/docker/images/queries/useImages.ts index 9580f78be..18a0807e7 100644 --- a/app/react/docker/images/queries/useImages.ts +++ b/app/react/docker/images/queries/useImages.ts @@ -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; - - /** - * Date and time at which the image was created as a Unix timestamp - * (number of seconds sinds EPOCH). - * - * Required: 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[]; +export interface ImagesListResponse { + created: number; + nodeName?: string; + id: string; + size: number; + tags: 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 + * Used is true if the image is used by at least one container. + * supplied only when withUsage is true */ - SharedSize: number; - Size: number; - VirtualSize: number; + used: boolean; } -type ImagesListResponse = ImageSummary[]; - -export function useImages( +export function useImages>( environmentId: EnvironmentId, + withUsage = false, { select, enabled, - }: { select?(data: ImagesListResponse): T; enabled?: boolean } = {} + refetchInterval, + }: { + select?(data: Array): 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( - buildUrl(environmentId, 'images', 'json') + const { data } = await axios.get>( + buildUrl(environmentId), + { params: { withUsage } } ); return data; } catch (err) { diff --git a/app/react/docker/images/types.ts b/app/react/docker/images/types.ts index adcd9ed74..43b61f4bc 100644 --- a/app/react/docker/images/types.ts +++ b/app/react/docker/images/types.ts @@ -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; }; diff --git a/app/react/docker/proxy/queries/images/queryKeys.ts b/app/react/docker/proxy/queries/images/queryKeys.ts new file mode 100644 index 000000000..2daddae1a --- /dev/null +++ b/app/react/docker/proxy/queries/images/queryKeys.ts @@ -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), +}; diff --git a/app/react/docker/proxy/queries/images/useImages.ts b/app/react/docker/proxy/queries/images/useImages.ts new file mode 100644 index 000000000..60ddd194e --- /dev/null +++ b/app/react/docker/proxy/queries/images/useImages.ts @@ -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; + +export function useImages( + 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( + buildUrl(environmentId, 'images', 'json') + ); + return data; + } catch (err) { + throw parseAxiosError(err as Error, 'Unable to retrieve images'); + } +} diff --git a/app/react/docker/queries/utils/build-url.ts b/app/react/docker/queries/utils/build-url.ts new file mode 100644 index 000000000..5f49a4e42 --- /dev/null +++ b/app/react/docker/queries/utils/build-url.ts @@ -0,0 +1,5 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +export function buildUrl(environmentId: EnvironmentId, path: string) { + return `/docker/${environmentId}/${path}`; +} diff --git a/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts b/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts index 4362d62ba..2d0d7c0c7 100644 --- a/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts +++ b/app/react/docker/stacks/ListView/StacksDatatable/columns/getStackImagesStatus.ts @@ -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 {