feat(docker/images): show used tag correctly [EE-5396] (#10305)

pull/10410/head
Chaim Lev-Ari 1 year ago committed by GitHub
parent b895e88075
commit 9bf2957ea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client" 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/containers"
"github.com/portainer/portainer/api/http/handler/docker/images"
"github.com/portainer/portainer/api/http/middlewares" "github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "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) containersHandler := containers.NewHandler("/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler) endpointRouter.PathPrefix("/containers").Handler(containersHandler)
imagesHandler := images.NewHandler("/{id}/images", bouncer, dockerClientFactory)
endpointRouter.PathPrefix("/images").Handler(imagesHandler)
return h 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); return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g);
} }
/**
*
* @param {import('@/react/docker/images/queries/useImages').ImagesListResponse[]} images
* @returns {{names: string[]}}}
*/
function getImagesNamesForDownload(images) { function getImagesNamesForDownload(images) {
var names = images.map(function (image) { 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 { return {
names: names, names,
}; };
} }

@ -74,13 +74,10 @@ const ngModule = angular
.component( .component(
'dockerImagesDatatable', 'dockerImagesDatatable',
r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [ r2a(withUIRouter(withCurrentUser(ImagesDatatable)), [
'dataset',
'environment',
'onRemove', 'onRemove',
'isExportInProgress', 'isExportInProgress',
'isHostColumnVisible', 'isHostColumnVisible',
'onDownload', 'onDownload',
'onRefresh',
'onRemove', 'onRemove',
]) ])
) )

@ -46,15 +46,9 @@
</div> </div>
<docker-images-datatable <docker-images-datatable
ng-if="images"
dataset="images"
is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'" is-host-column-visible="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
on-download="(downloadAction)" on-download="(downloadAction)"
on-remove="(confirmRemovalAction)" on-remove="(confirmRemovalAction)"
on-refresh="(getImages)"
is-export-in-progress="state.exportInProgress" is-export-in-progress="state.exportInProgress"
storage-key="images"
environment="endpoint" environment="endpoint"
settings-store="settingsStore"
containers="containers"
></docker-images-datatable> ></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) { $scope.confirmRemovalAction = async function (selectedItems, force) {
const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove()); const confirmed = await (force ? confirmImageForceRemoval() : confirmRegularRemove());
@ -81,11 +86,15 @@ angular.module('portainer.docker').controller('ImagesController', [
$scope.removeAction(selectedItems, force); $scope.removeAction(selectedItems, force);
}; };
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} selectedItems
*/
function isAuthorizedToDownload(selectedItems) { function isAuthorizedToDownload(selectedItems) {
for (var i = 0; i < selectedItems.length; i++) { for (var i = 0; i < selectedItems.length; i++) {
var image = selectedItems[i]; var image = selectedItems[i];
var untagged = _.find(image.RepoTags, function (item) { var untagged = _.find(image.tags, function (item) {
return item.indexOf('<none>') > -1; return item.indexOf('<none>') > -1;
}); });
@ -103,8 +112,12 @@ angular.module('portainer.docker').controller('ImagesController', [
return true; return true;
} }
/**
*
* @param {Array<import('@/react/docker/images/queries/useImages').ImagesListResponse>} images
*/
function exportImages(images) { function exportImages(images) {
HttpRequestHelper.setPortainerAgentTargetHeader(images[0].NodeName); HttpRequestHelper.setPortainerAgentTargetHeader(images[0].nodeName);
$scope.state.exportInProgress = true; $scope.state.exportInProgress = true;
ImageService.downloadImages(images) ImageService.downloadImages(images)
.then(function success(data) { .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) { $scope.downloadAction = function (selectedItems) {
if (!isAuthorizedToDownload(selectedItems)) { if (!isAuthorizedToDownload(selectedItems)) {
return; 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; var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (image) { angular.forEach(selectedItems, function (image) {
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName); HttpRequestHelper.setPortainerAgentTargetHeader(image.nodeName);
ImageService.deleteImage(image.Id, force) ImageService.deleteImage(image.id, force)
.then(function success() { .then(function success() {
Notifications.success('Image successfully removed', image.Id); Notifications.success('Image successfully removed', image.id);
var index = $scope.images.indexOf(image);
$scope.images.splice(index, 1);
}) })
.catch(function error(err) { .catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove image'); 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; $scope.setPullImageValidity = setPullImageValidity;
function setPullImageValidity(validity) { function setPullImageValidity(validity) {
$scope.state.pullRateValid = validity; $scope.state.pullRateValid = validity;
} }
function initView() {
getImages();
}
initView();
}, },
]); ]);

@ -3,7 +3,7 @@ import _ from 'lodash';
import { useMemo } from 'react'; import { useMemo } from 'react';
import DockerIcon from '@/assets/ico/vendor/docker.svg?c'; 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 { import {
imageContainsURL, imageContainsURL,
getUniqueTagListFromImages, getUniqueTagListFromImages,

@ -1,17 +1,14 @@
import { useQuery } from 'react-query';
import { Loader } from 'lucide-react'; import { Loader } from 'lucide-react';
import {
getContainerImagesStatus,
getServiceImagesStatus,
} from '@/react/docker/images/image.service';
import { useEnvironment } from '@/react/portainer/environments/queries'; import { useEnvironment } from '@/react/portainer/environments/queries';
import { statusIcon } from '@/react/docker/components/ImageStatus/helpers'; import { statusIcon } from '@/react/docker/components/ImageStatus/helpers';
import { ResourceID, ResourceType } from '@/react/docker/images/types';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import { ResourceID, ResourceType } from './types';
import { useImageNotification } from './useImageNotification';
export interface Props { export interface Props {
environmentId: EnvironmentId; environmentId: EnvironmentId;
resourceId: ResourceID; resourceId: ResourceID;
@ -56,30 +53,3 @@ export function ImageStatus({
<Icon icon={statusIcon(data)} size="sm" className="!mr-1 align-middle" /> <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 UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
import UpdatesUnknown from '@/assets/ico/icon_updates-unknown.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) { export function statusIcon(status: ImageStatus) {
switch (status.Status) { 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 { EnvironmentId } from '@/react/portainer/environments/types';
import axios from '@/portainer/services/axios'; import axios from '@/portainer/services/axios';
import { ServiceId } from '@/react/docker/services/types'; 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, environmentId: EnvironmentId,
containerID: ContainerId, containerID: ContainerId,
nodeName: string nodeName: string
@ -29,7 +57,7 @@ export async function getContainerImagesStatus(
} }
} }
export async function getServiceImagesStatus( async function getServiceImagesStatus(
environmentId: EnvironmentId, environmentId: EnvironmentId,
serviceID: ServiceId serviceID: ServiceId
) { ) {

@ -10,8 +10,8 @@ import { Menu, MenuButton, MenuItem, MenuPopover } from '@reach/menu-button';
import { positionRight } from '@reach/popover'; import { positionRight } from '@reach/popover';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Environment } from '@/react/portainer/environments/types';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Datatable, TableSettingsMenu } from '@@/datatables'; import { Datatable, TableSettingsMenu } from '@@/datatables';
import { import {
@ -24,14 +24,12 @@ import { useTableState } from '@@/datatables/useTableState';
import { Button, ButtonGroup, LoadingButton } from '@@/buttons'; import { Button, ButtonGroup, LoadingButton } from '@@/buttons';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { ButtonWithRef } from '@@/buttons/Button'; import { ButtonWithRef } from '@@/buttons/Button';
import { useRepeater } from '@@/datatables/useRepeater';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh'; import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { DockerImage } from '../../types'; import { ImagesListResponse, 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 { RowProvider } from './RowContext';
const tableKey = 'images'; const tableKey = 'images';
@ -48,76 +46,67 @@ const settingsStore = createPersistedStore<TableSettings>(
); );
export function ImagesDatatable({ export function ImagesDatatable({
dataset,
environment,
isHostColumnVisible, isHostColumnVisible,
isExportInProgress, isExportInProgress,
onDownload, onDownload,
onRefresh,
onRemove, onRemove,
}: { }: {
dataset: Array<DockerImage>;
environment: Environment;
isHostColumnVisible: boolean; isHostColumnVisible: boolean;
onDownload: (images: Array<DockerImage>) => void; onDownload: (images: Array<ImagesListResponse>) => void;
onRemove: (images: Array<DockerImage>, force: true) => void; onRemove: (images: Array<ImagesListResponse>, force: true) => void;
onRefresh: () => Promise<void>;
isExportInProgress: boolean; isExportInProgress: boolean;
}) { }) {
const environmentId = useEnvironmentId();
const tableState = useTableState(settingsStore, tableKey); const tableState = useTableState(settingsStore, tableKey);
const columns = useMemo( const columns = useMemo(
() => (isHostColumnVisible ? [...defColumns, hostColumn] : defColumns), () => (isHostColumnVisible ? [...defColumns, hostColumn] : defColumns),
[isHostColumnVisible] [isHostColumnVisible]
); );
const imagesQuery = useImages(environmentId, true, {
useRepeater(tableState.autoRefreshRate, onRefresh); refetchInterval: tableState.autoRefreshRate * 1000,
});
return ( return (
<RowProvider context={{ environment }}> <Datatable
<Datatable title="Images"
title="Images" titleIcon={List}
titleIcon={List} 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
onRemove={onRemove} isExportInProgress={isExportInProgress}
/> onExportClick={onDownload}
selectedItems={selectedItems}
<ImportExportButtons />
isExportInProgress={isExportInProgress}
onExportClick={onDownload} <Authorized authorizations="DockerImageBuild">
selectedItems={selectedItems} <Button
/> as={Link}
props={{ to: 'docker.images.build' }}
<Authorized authorizations="DockerImageBuild"> data-cy="image-buildImageButton"
<Button icon={Plus}
as={Link} >
props={{ to: 'docker.images.build' }} Build a new image
data-cy="image-buildImageButton" </Button>
icon={Plus} </Authorized>
> </div>
Build a new image )}
</Button> dataset={imagesQuery.data || []}
</Authorized> isLoading={imagesQuery.isLoading}
</div> settingsManager={tableState}
)} columns={columns}
dataset={dataset} emptyContentLabel="No images found"
settingsManager={tableState} renderTableSettings={() => (
columns={columns} <TableSettingsMenu>
emptyContentLabel="No images found" <TableSettingsMenuAutoRefresh
renderTableSettings={() => ( value={tableState.autoRefreshRate}
<TableSettingsMenu> onChange={(value) => tableState.setAutoRefreshRate(value)}
<TableSettingsMenuAutoRefresh />
value={tableState.autoRefreshRate} </TableSettingsMenu>
onChange={(value) => tableState.setAutoRefreshRate(value)} )}
/> />
</TableSettingsMenu>
)}
/>
</RowProvider>
); );
} }
@ -125,8 +114,8 @@ function RemoveButtonMenu({
onRemove, onRemove,
selectedItems, selectedItems,
}: { }: {
selectedItems: Array<DockerImage>; selectedItems: Array<ImagesListResponse>;
onRemove(selectedItems: Array<DockerImage>, force: boolean): void; onRemove(selectedItems: Array<ImagesListResponse>, force: boolean): void;
}) { }) {
return ( return (
<Authorized authorizations="DockerImageDelete"> <Authorized authorizations="DockerImageDelete">
@ -176,8 +165,8 @@ function ImportExportButtons({
onExportClick, onExportClick,
}: { }: {
isExportInProgress: boolean; isExportInProgress: boolean;
selectedItems: Array<DockerImage>; selectedItems: Array<ImagesListResponse>;
onExportClick(selectedItems: Array<DockerImage>): void; onExportClick(selectedItems: Array<ImagesListResponse>): void;
}) { }) {
return ( return (
<ButtonGroup> <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'; import { columnHelper } from './helper';
export const created = columnHelper.accessor('Created', { export const created = columnHelper.accessor('created', {
id: 'created', id: 'created',
header: 'Created', header: 'Created',
cell: ({ getValue }) => { cell: ({ getValue }) => {

@ -1,16 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table'; 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< export const columnHelper = createColumnHelper<ImagesListResponse>();
DockerImage & { NodeName?: string }
>();
/**
* Docker response from proxy (with added portainer metadata)
* images view model
* images snapshot
* snapshots view model
*
*
*/

@ -1,6 +1,6 @@
import { columnHelper } from './helper'; import { columnHelper } from './helper';
export const host = columnHelper.accessor('NodeName', { export const host = columnHelper.accessor('nodeName', {
header: 'Host', header: 'Host',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue(); const value = getValue();

@ -1,21 +1,21 @@
import { CellContext, Column } from '@tanstack/react-table'; import { CellContext, Column } from '@tanstack/react-table';
import { useSref } from '@uirouter/react'; import { useSref } from '@uirouter/react';
import { DockerImage } from '@/react/docker/images/types';
import { truncate } from '@/portainer/filters/filters'; import { truncate } from '@/portainer/filters/filters';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array'; import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
import { MultipleSelectionFilter } from '@@/datatables/Filter'; import { MultipleSelectionFilter } from '@@/datatables/Filter';
import { columnHelper } from './helper'; import { columnHelper } from './helper';
export const id = columnHelper.accessor('Id', { export const id = columnHelper.accessor('id', {
id: 'id', id: 'id',
header: 'Id', header: 'Id',
cell: Cell, cell: Cell,
enableColumnFilter: true, enableColumnFilter: true,
filterFn: ( filterFn: (
{ original: { Used } }, { original: { used } },
columnId, columnId,
filterValue: Array<'Used' | 'Unused'> filterValue: Array<'Used' | 'Unused'>
) => { ) => {
@ -23,11 +23,11 @@ export const id = columnHelper.accessor('Id', {
return true; return true;
} }
if (filterValue.includes('Used') && Used) { if (filterValue.includes('Used') && used) {
return true; return true;
} }
if (filterValue.includes('Unused') && !Used) { if (filterValue.includes('Unused') && !used) {
return true; return true;
} }
@ -63,12 +63,12 @@ function FilterByUsage<TData extends { Used: boolean }>({
function Cell({ function Cell({
getValue, getValue,
row: { original: image }, row: { original: image },
}: CellContext<DockerImage, string>) { }: CellContext<ImagesListResponse, string>) {
const name = getValue(); const name = getValue();
const linkProps = useSref('.image', { const linkProps = useSref('.image', {
id: image.Id, id: image.id,
imageId: image.Id, imageId: image.id,
}); });
return ( return (
@ -76,7 +76,7 @@ function Cell({
<a href={linkProps.href} onClick={linkProps.onClick} title={name}> <a href={linkProps.href} onClick={linkProps.onClick} title={name}>
{truncate(name, 40)} {truncate(name, 40)}
</a> </a>
{!image.Used && ( {!image.used && (
<span className="label label-warning image-tag ml-2">Unused</span> <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'; import { columnHelper } from './helper';
export const size = columnHelper.accessor('VirtualSize', { export const size = columnHelper.accessor('size', {
id: 'size', id: 'size',
header: 'Size', header: 'Size',
cell: ({ getValue }) => { cell: ({ getValue }) => {

@ -1,16 +1,16 @@
import { CellContext } from '@tanstack/react-table'; 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'; import { columnHelper } from './helper';
export const tags = columnHelper.accessor('RepoTags', { export const tags = columnHelper.accessor('tags', {
id: 'tags', id: 'tags',
header: 'Tags', header: 'Tags',
cell: Cell, cell: Cell,
}); });
function Cell({ getValue }: CellContext<DockerImage, string[]>) { function Cell({ getValue }: CellContext<ImagesListResponse, string[]>) {
const repoTags = getValue(); const repoTags = getValue();
return ( 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 = { export const queryKeys = {
base: (environmentId: EnvironmentId) => base: (environmentId: EnvironmentId) =>
[dockerQueryKeys.root(environmentId), 'images'] as const, [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 axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildUrl } from '../../proxy/queries/build-url'; import { buildUrl } from './build-url';
import { queryKeys } from './queryKeys'; import { queryKeys } from './queryKeys';
interface ImageSummary { export interface ImagesListResponse {
/** created: number;
* Number of containers using this image. Includes both stopped and running containers. nodeName?: string;
* id: string;
* This size is not calculated by default, and depends on which API endpoint is used. size: number;
* `-1` indicates that the value has not been set / calculated. tags: string[];
*
* 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[];
/** /**
* Total size of image layers that are shared between this image and other * Used is true if the image is used by at least one container.
* images. * supplied only when withUsage is true
*
* This size is not calculated by default. `-1` indicates that the value
* has not been set / calculated.
*
* Required: true
*/ */
SharedSize: number; used: boolean;
Size: number;
VirtualSize: number;
} }
type ImagesListResponse = ImageSummary[]; export function useImages<T = Array<ImagesListResponse>>(
export function useImages<T = ImagesListResponse>(
environmentId: EnvironmentId, environmentId: EnvironmentId,
withUsage = false,
{ {
select, select,
enabled, enabled,
}: { select?(data: ImagesListResponse): T; enabled?: boolean } = {} refetchInterval,
}: {
select?(data: Array<ImagesListResponse>): T;
enabled?: boolean;
refetchInterval?: number;
} = {}
) { ) {
return useQuery( return useQuery(
queryKeys.list(environmentId), queryKeys.list(environmentId, { withUsage }),
() => getImages(environmentId), () => getImages(environmentId, { withUsage }),
{ select, enabled } { select, enabled, refetchInterval }
); );
} }
async function getImages(environmentId: EnvironmentId) { async function getImages(
environmentId: EnvironmentId,
{ withUsage }: { withUsage?: boolean } = {}
) {
try { try {
const { data } = await axios.get<ImagesListResponse>( const { data } = await axios.get<Array<ImagesListResponse>>(
buildUrl(environmentId, 'images', 'json') buildUrl(environmentId),
{ params: { withUsage } }
); );
return data; return data;
} catch (err) { } catch (err) {

@ -1,19 +1,5 @@
import { DockerImageResponse } from './types/response'; 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 = { type DecoratedDockerImage = {
Used: boolean; 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 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) { export async function getStackImagesStatus(id: number) {
try { try {

Loading…
Cancel
Save