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"
|
"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,43 +46,34 @@ 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
|
<RemoveButtonMenu selectedItems={selectedItems} onRemove={onRemove} />
|
||||||
selectedItems={selectedItems}
|
|
||||||
onRemove={onRemove}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImportExportButtons
|
<ImportExportButtons
|
||||||
isExportInProgress={isExportInProgress}
|
isExportInProgress={isExportInProgress}
|
||||||
|
@ -104,7 +93,8 @@ export function ImagesDatatable({
|
||||||
</Authorized>
|
</Authorized>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
dataset={dataset}
|
dataset={imagesQuery.data || []}
|
||||||
|
isLoading={imagesQuery.isLoading}
|
||||||
settingsManager={tableState}
|
settingsManager={tableState}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
emptyContentLabel="No images found"
|
emptyContentLabel="No images found"
|
||||||
|
@ -117,7 +107,6 @@ export function ImagesDatatable({
|
||||||
</TableSettingsMenu>
|
</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
|
* Used is true if the image is used by at least one container.
|
||||||
* (number of seconds sinds EPOCH).
|
* supplied only when withUsage is true
|
||||||
*
|
|
||||||
* Required: true
|
|
||||||
*/
|
*/
|
||||||
Created: number;
|
used: boolean;
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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…
Reference in New Issue