Merge remote-tracking branch 'origin/feat/10-update-now' into feat/3-auto-update

This commit is contained in:
claude code agent
2026-06-29 12:48:24 +03:00
27 changed files with 1443 additions and 24 deletions
+12
View File
@@ -134,6 +134,18 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
return Skipped, nil
}
// statusCache is the 24h cache keyed by imageID. Reading it here makes the
// long-lived cache effective on the input side for every caller (handler,
// ContainersImageStatus, the M4 auto-update job): a hit skips the expensive,
// rate-limited remote registry digest lookup below. The container/image
// inspects above are local Docker calls and cheap; the registry HEAD is the
// part worth avoiding. Only successful statuses are ever written (the error
// paths return early without caching), so a hit returns the same value the
// full computation would have produced.
if s, err := CachedResourceImageStatus(imageID); err == nil {
return s, nil
}
digs := make([]digest.Digest, 0)
images := make([]*Image, 0)
if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil {
@@ -34,6 +34,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
router.Handle("/{containerId}/image_status", httperror.LoggerHandler(h.imageStatus)).Methods(http.MethodGet)
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)
return h
@@ -0,0 +1,82 @@
package containers
import (
"net/http"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/http/middlewares"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// imageStatusResponse is the body returned by the image status endpoint.
type imageStatusResponse struct {
// Status of the running container image. One of:
// "outdated", "updated", "skipped", "processing", "preparing", "error".
Status string `json:"Status"`
// Message holds an optional human-readable detail, typically the detection error.
Message string `json:"Message,omitempty"`
}
// @id ContainerImageStatus
// @summary Fetch the image status of a container
// @description Detect whether a newer image is available for the running container by
// @description comparing the local image digest against the remote registry digest.
// @description This is a read-only operation: it never pulls or recreates anything.
// @description **Access policy**: authenticated
// @tags docker
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "Environment identifier"
// @param containerId path string true "Container identifier"
// @param nodeName query string false "Node name for a Swarm/agent endpoint"
// @description Engine-level issues (container not found, registry unreachable, auth
// @description failure, ...) are not treated as API errors: they degrade gracefully to a
// @description 200 response carrying a "skipped" or "error" status. HTTP errors are only
// @description returned for request/authorization problems.
// @success 200 {object} imageStatusResponse "Image status (also returned with a skipped/error status for engine-level issues)"
// @failure 400 "Invalid request: missing container identifier"
// @failure 403 "Permission denied to access the environment"
// @failure 404 "Environment not found"
// @router /docker/{id}/containers/{containerId}/image_status [get]
func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
containerID, err := request.RetrieveRouteVariableValue(r, "containerId")
if err != nil {
return httperror.BadRequest("Invalid containerId", err)
}
// nodeName is optional and only relevant for Swarm/agent endpoints.
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.NotFound("Unable to find an environment on request context", err)
}
if err := handler.bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
// The detection engine (zlib/CE) routes outbound registry calls through the
// RegistryClient, which honors the encrypted credential store and the outbound
// SSRF/AllowList. It caches results for 24h and skips digest-pinned/local-only images.
digestClient := images.NewClientWithRegistry(images.NewRegistryClient(handler.dataStore), handler.dockerClientFactory)
status, err := digestClient.ContainerImageStatus(r.Context(), containerID, endpoint, nodeName)
if err != nil {
// A detection failure (registry unreachable, auth failure, ...) is not an API
// failure: degrade gracefully with a 200 + "error" status so the UI can render a
// neutral badge instead of surfacing a hard error. The raw error is logged
// server-side only; the response carries a generic message to avoid leaking
// registry URLs or credential details to the client.
log.Warn().Err(err).Str("containerId", containerID).Msg("unable to determine container image status")
return response.JSON(w, &imageStatusResponse{Status: string(images.Error), Message: "unable to determine image status"})
}
return response.JSON(w, &imageStatusResponse{Status: string(status)})
}
@@ -0,0 +1,53 @@
package containers
import (
"encoding/json"
"testing"
"github.com/portainer/portainer/api/docker/images"
)
// TestImageStatusResponse_JSONShape verifies the wire format of the image status
// response: the status string is mapped from the engine enum and the message is
// omitted when empty, matching what the CE frontend badge expects.
func TestImageStatusResponse_JSONShape(t *testing.T) {
tests := []struct {
name string
resp imageStatusResponse
expected string
}{
{
name: "outdated without message",
resp: imageStatusResponse{Status: string(images.Outdated)},
expected: `{"Status":"outdated"}`,
},
{
name: "updated without message",
resp: imageStatusResponse{Status: string(images.Updated)},
expected: `{"Status":"updated"}`,
},
{
name: "skipped without message",
resp: imageStatusResponse{Status: string(images.Skipped)},
expected: `{"Status":"skipped"}`,
},
{
name: "error carries a message",
resp: imageStatusResponse{Status: string(images.Error), Message: "registry unreachable"},
expected: `{"Status":"error","Message":"registry unreachable"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := json.Marshal(tt.resp)
if err != nil {
t.Fatalf("unexpected marshal error: %v", err)
}
if string(got) != tt.expected {
t.Errorf("unexpected JSON\n got: %s\nwant: %s", got, tt.expected)
}
})
}
}
@@ -2,6 +2,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
import { isPartOfSwarmService } from '@/docker/helpers/containers';
import { trimContainerName } from '@/docker/filters/utils';
import { Widget, WidgetBody } from '@@/Widget';
@@ -57,6 +58,8 @@ export function ContainerActionsSection({
environmentId={environmentId}
containerId={container.Id}
containerImage={container.Config?.Image || ''}
containerName={trimContainerName(container.Name) || container.Id}
containerLabels={container.Config?.Labels ?? undefined}
containerAutoRemove={container.HostConfig?.AutoRemove}
nodeName={nodeName}
partOfSwarmService={isPartOfSwarmService(container)}
@@ -197,6 +197,7 @@ function renderComponent(
containerId: 'test-container-id',
nodeName: 'node1',
containerImage: 'nginx:latest',
containerName: 'test-container',
containerAutoRemove: false,
isPortainer: false,
partOfSwarmService: false,
@@ -6,6 +6,7 @@ import { ButtonGroup } from '@@/buttons';
import { ContainerId } from '../../types';
import { RecreateButton } from './SecondaryActions/RecreateButton';
import { UpdateNowButton } from './SecondaryActions/UpdateNowButton';
import { DuplicateEditButton } from './SecondaryActions/DuplicateEditButton';
import { useCanRecreateContainer } from './SecondaryActions/useCanRecreateContainer';
import { useCanDuplicateEditContainer } from './SecondaryActions/useCanDuplicateEditContainer';
@@ -15,6 +16,8 @@ interface Props {
containerId: ContainerId;
nodeName?: string;
containerImage: string;
containerName: string;
containerLabels?: Record<string, string>;
containerAutoRemove: boolean | undefined;
isPortainer: boolean;
partOfSwarmService: boolean;
@@ -25,6 +28,8 @@ export function SecondaryActions({
containerId,
nodeName,
containerImage,
containerName,
containerLabels,
containerAutoRemove = false,
isPortainer,
partOfSwarmService,
@@ -39,31 +44,43 @@ export function SecondaryActions({
partOfSwarmService,
});
if (!displayRecreateButton && !displayDuplicateEditButton) {
return null;
}
return (
<Authorized authorizations="DockerContainerCreate">
<ButtonGroup>
{displayRecreateButton && (
<RecreateButton
environmentId={environmentId}
containerId={containerId}
nodeName={nodeName}
containerImage={containerImage}
isPortainer={isPortainer}
/>
)}
{/*
Self-hides unless the image is outdated; renders its own ButtonGroup so
the recreate/duplicate group never shows up empty when both are hidden.
*/}
<UpdateNowButton
environmentId={environmentId}
containerId={containerId}
nodeName={nodeName}
containerImage={containerImage}
containerName={containerName}
labels={containerLabels}
isPortainer={isPortainer}
/>
{displayDuplicateEditButton && (
<DuplicateEditButton
containerId={containerId}
nodeName={nodeName}
isPortainer={isPortainer}
/>
)}
</ButtonGroup>
{(displayRecreateButton || displayDuplicateEditButton) && (
<ButtonGroup>
{displayRecreateButton && (
<RecreateButton
environmentId={environmentId}
containerId={containerId}
nodeName={nodeName}
containerImage={containerImage}
isPortainer={isPortainer}
/>
)}
{displayDuplicateEditButton && (
<DuplicateEditButton
containerId={containerId}
nodeName={nodeName}
isPortainer={isPortainer}
/>
)}
</ButtonGroup>
)}
</Authorized>
);
}
@@ -0,0 +1,117 @@
import { render, screen } from '@testing-library/react';
import { vi } from 'vitest';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { StackType } from '@/react/common/stacks/types';
import { ContainerImageStatusValue } from '@/react/docker/containers/queries/useContainerImageStatus';
import { UpdateNowButton } from './UpdateNowButton';
const mockUseImageStatus = vi.fn();
const mockUseStacks = vi.fn();
const mockUseAuthorizations = vi.fn();
const mockMutate = vi.fn();
vi.mock('@uirouter/react', () => ({
useRouter: () => ({ stateService: { go: vi.fn() } }),
}));
vi.mock('@/react/docker/containers/queries/useContainerImageStatus', () => ({
useContainerImageStatus: () => mockUseImageStatus(),
}));
vi.mock('@/react/common/stacks/queries/useStacks', () => ({
useStacks: () => mockUseStacks(),
}));
vi.mock('@/react/hooks/useUser', () => ({
useAuthorizations: () => mockUseAuthorizations(),
}));
const composeStack = {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
};
vi.mock(
'@/react/docker/containers/update',
async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
})
);
function setStatus(status?: ContainerImageStatusValue) {
mockUseImageStatus.mockReturnValue({ data: status ? { Status: status } : undefined });
}
function renderButton(props: Partial<React.ComponentProps<typeof UpdateNowButton>> = {}) {
return render(
<UpdateNowButton
environmentId={3}
containerId="abc"
containerImage="nginx:latest"
containerName="web"
isPortainer={false}
{...props}
/>
);
}
describe('UpdateNowButton', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseStacks.mockReturnValue({ data: [], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
});
it('renders when the image is outdated', () => {
setStatus('outdated');
renderButton();
expect(screen.getByTestId('update-now-button')).toBeInTheDocument();
});
it('is hidden when the image is up to date', () => {
setStatus('updated');
renderButton();
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
});
it('is hidden when the status is unknown', () => {
setStatus(undefined);
renderButton();
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
});
it('disables the action for externally-managed compose containers', () => {
setStatus('outdated');
renderButton({
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
});
expect(screen.getByTestId('update-now-button')).toBeDisabled();
});
it('enables a stack container when the user can update stacks', () => {
setStatus('outdated');
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
renderButton({
environmentId: 3,
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
});
expect(screen.getByTestId('update-now-button')).toBeEnabled();
});
it('disables a stack container when the user lacks PortainerStackUpdate', () => {
setStatus('outdated');
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: false });
renderButton({
environmentId: 3,
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
});
expect(screen.getByTestId('update-now-button')).toBeDisabled();
});
});
@@ -0,0 +1,161 @@
import { Download } from 'lucide-react';
import { useRouter } from '@uirouter/react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { confirmContainerRecreation } from '@/react/docker/containers/ItemView/ConfirmRecreationModal';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { useStacks } from '@/react/common/stacks/queries/useStacks';
import { notifySuccess } from '@/portainer/services/notifications';
import { useContainerImageStatus } from '@/react/docker/containers/queries/useContainerImageStatus';
import { useAuthorizations } from '@/react/hooks/useUser';
import {
resolveContainerUpdatePath,
useUpdateContainerImage,
ContainerUpdateContext,
} from '@/react/docker/containers/update';
import { ButtonGroup, LoadingButton } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { ContainerId } from '../../../types';
interface UpdateNowButtonProps {
environmentId: EnvironmentId;
containerId: ContainerId;
nodeName?: string;
containerImage: string;
containerName: string;
labels?: Record<string, string>;
isPortainer: boolean;
}
/**
* "Update now" surfaces a discoverable per-container apply action ONLY when the
* image is `outdated`. It routes through the shared update primitive:
* standalone -> recreate-with-pull, stack-managed -> stack redeploy-with-pull
* (container stays in its stack). Externally-managed compose containers are
* shown disabled with an explanatory tooltip, never recreated out-of-band.
*
* A stack redeploy is gated by `PortainerStackUpdate` (as everywhere else in the
* app): a user with container-create but without stack-update rights sees the
* button disabled with a tooltip rather than getting a 403 on click.
*/
export function UpdateNowButton({
environmentId,
containerId,
nodeName,
containerImage,
containerName,
labels,
isPortainer,
}: UpdateNowButtonProps) {
const router = useRouter();
const statusQuery = useContainerImageStatus(
environmentId,
containerId,
nodeName
);
const stacksQuery = useStacks();
const updateMutation = useUpdateContainerImage();
// A stack redeploy needs stack-update rights, not just container-create.
const { authorized: canUpdateStack } = useAuthorizations(
'PortainerStackUpdate',
environmentId
);
// Only meaningful when a newer image is actually available.
if (statusQuery.data?.Status !== 'outdated') {
return null;
}
const stacks = stacksQuery.data ?? [];
const path = resolveContainerUpdatePath({ labels, environmentId }, stacks);
const isExternal = path.kind === 'external';
// Stack-managed container the user isn't allowed to redeploy: disable rather
// than let the click 403.
const stackUpdateForbidden = path.kind === 'stack' && !canUpdateStack;
const button = (
<LoadingButton
color="primary"
size="small"
onClick={handleClick}
disabled={
isPortainer ||
isExternal ||
stackUpdateForbidden ||
stacksQuery.isLoading
}
isLoading={updateMutation.isLoading}
loadingText="Updating..."
data-cy="update-now-button"
icon={Download}
>
Update now
</LoadingButton>
);
if (isExternal) {
return (
<ButtonGroup>
<TooltipWithChildren message="This container belongs to a compose project that is managed outside Portainer, so it can't be updated from here.">
{button}
</TooltipWithChildren>
</ButtonGroup>
);
}
if (stackUpdateForbidden) {
return (
<ButtonGroup>
<TooltipWithChildren message="Updating this container redeploys its stack, which requires stack update permission you don't have.">
{button}
</TooltipWithChildren>
</ButtonGroup>
);
}
return <ButtonGroup>{button}</ButtonGroup>;
async function handleClick() {
const context: ContainerUpdateContext = {
id: containerId,
name: containerName,
image: containerImage,
labels,
environmentId,
nodeName,
};
let pullImage: boolean;
if (path.kind === 'stack') {
const result = await confirmStackUpdate(
'This will redeploy the stack pulling the latest images and may cause a service interruption. Do you wish to continue?',
true
);
if (!result) {
return;
}
pullImage = result.repullImageAndRedeploy;
} else {
const cannotPullImage =
!containerImage || containerImage.toLowerCase().startsWith('sha256');
const result = await confirmContainerRecreation(cannotPullImage);
if (!result) {
return;
}
pullImage = result.pullLatest;
}
updateMutation.mutate(
{ context, stacks, pullImage },
{
onSuccess: () => {
notifySuccess('Success', 'Container image update applied');
router.stateService.go('docker.containers', {}, { reload: true });
},
}
);
}
}
@@ -1,8 +1,17 @@
import { useRouter } from '@uirouter/react';
import { Pause, Play, RefreshCw, Slash, Square, Trash2 } from 'lucide-react';
import {
Download,
Pause,
Play,
RefreshCw,
Slash,
Square,
Trash2,
} from 'lucide-react';
import * as notifications from '@/portainer/services/notifications';
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
import { trimContainerName } from '@/docker/filters/utils';
import { confirmContainerDeletion } from '@/react/docker/containers/common/confirm-container-delete-modal';
import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
import {
@@ -20,8 +29,13 @@ import {
stopContainer,
} from '@/react/docker/containers/containers.service';
import type { EnvironmentId } from '@/react/portainer/environments/types';
import { useStacks } from '@/react/common/stacks/queries/useStacks';
import {
ContainerUpdateContext,
useBulkUpdateContainerImages,
} from '@/react/docker/containers/update';
import { ButtonGroup, Button, AddButton } from '@@/buttons';
import { ButtonGroup, Button, LoadingButton, AddButton } from '@@/buttons';
type ContainerServiceAction = (
endpointId: EnvironmentId,
@@ -70,7 +84,15 @@ export function ContainersDatatableActions({
'DockerContainerCreate',
]);
// Stack redeploys triggered by "Update" need stack-update rights, gated
// separately so we never fire a redeploy the user would get a 403 on.
const { authorized: canUpdateStack } = useAuthorizations(
'PortainerStackUpdate'
);
const router = useRouter();
const stacksQuery = useStacks();
const bulkUpdateMutation = useBulkUpdateContainerImages();
if (!authorized) {
return null;
@@ -162,6 +184,20 @@ export function ContainersDatatableActions({
Remove
</Button>
</Authorized>
<Authorized authorizations="DockerContainerCreate">
<LoadingButton
color="light"
data-cy="update-selected-docker-container-button"
onClick={() => onUpdateClick(selectedItems)}
disabled={selectedItemCount === 0 || stacksQuery.isLoading}
isLoading={bulkUpdateMutation.isLoading}
loadingText="Updating..."
icon={Download}
>
Update
</LoadingButton>
</Authorized>
</ButtonGroup>
{isAddActionVisible && (
<div className="space-left">
@@ -241,6 +277,31 @@ export function ContainersDatatableActions({
);
}
function onUpdateClick(selectedItems: ContainerListViewModel[]) {
// Apply the shared image-update primitive to every outdated container,
// skipping up-to-date/unknown ones and redeploying each owning stack once.
const contexts: ContainerUpdateContext[] = selectedItems.map(
(container) => ({
id: container.Id,
// Strip Docker's leading "/" so toast names match the single-container path.
name: trimContainerName(container.Names[0]),
image: container.Image,
labels: container.Labels,
environmentId: endpointId,
nodeName: container.NodeName,
})
);
bulkUpdateMutation.mutate(
{ contexts, stacks: stacksQuery.data ?? [], canUpdateStack },
{
onSettled: () => {
router.stateService.reload();
},
}
);
}
async function onRemoveClick(selectedItems: ContainerListViewModel[]) {
const isOneContainerRunning = selectedItems.some(
(container) => container.State === 'running'
@@ -14,6 +14,7 @@ import { quickActions } from './quick-actions';
import { stack } from './stack';
import { state } from './state';
import { gpus } from './gpus';
import { updateAvailable } from './updateAvailable';
export function useColumns(
isHostColumnVisible: boolean,
@@ -27,6 +28,7 @@ export function useColumns(
quickActions,
stack,
image,
updateAvailable,
created,
ip,
isHostColumnVisible && host,
@@ -0,0 +1,37 @@
import { CellContext } from '@tanstack/react-table';
import type { ContainerListViewModel } from '@/react/docker/containers/types';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useContainerImageStatus } from '@/react/docker/containers/queries/useContainerImageStatus';
import { UpdateStatusBadge } from '../../../components/UpdateStatusBadge';
import { columnHelper } from './helper';
export const updateAvailable = columnHelper.display({
header: 'Update available',
id: 'updateAvailable',
cell: UpdateAvailableCell,
});
function UpdateAvailableCell({
row: { original: container },
}: CellContext<ContainerListViewModel, unknown>) {
const environmentId = useEnvironmentId();
// One detection request per visible row is acceptable: the backend caches results
// for 24h and the hook keeps a generous client-side staleTime, so re-renders and
// pagination don't re-hit the registry. The query is non-blocking, so the table
// renders immediately and badges fill in as statuses resolve.
const statusQuery = useContainerImageStatus(
environmentId,
container.Id,
container.NodeName
);
return (
<UpdateStatusBadge
status={statusQuery.data?.Status}
isLoading={statusQuery.isLoading}
/>
);
}
@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react';
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
import { UpdateStatusBadge } from './UpdateStatusBadge';
describe('UpdateStatusBadge', () => {
it('renders "Update available" when the image is outdated', () => {
render(<UpdateStatusBadge status="outdated" />);
expect(screen.getByText('Update available')).toBeInTheDocument();
});
it('renders an up-to-date indicator when the image is updated', () => {
render(<UpdateStatusBadge status="updated" />);
expect(screen.getByText('Up to date')).toBeInTheDocument();
});
it('shows a spinner while loading', () => {
render(<UpdateStatusBadge isLoading />);
expect(
screen.getByLabelText('Checking for image updates')
).toBeInTheDocument();
});
it.each<ContainerImageStatusValue>([
'skipped',
'error',
'processing',
'preparing',
])('renders nothing for the neutral status "%s"', (status) => {
const { container } = render(<UpdateStatusBadge status={status} />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when status is undefined and not loading', () => {
const { container } = render(<UpdateStatusBadge />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,52 @@
import { Loader } from 'lucide-react';
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
import { Icon } from '@@/Icon';
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
export interface Props {
status?: ContainerImageStatusValue;
isLoading?: boolean;
}
/**
* CE container image update badge.
*
* Renders "Update available" when the image is outdated, an up-to-date indicator
* when it matches the registry, a spinner while the status is being fetched, and
* nothing for neutral states (skipped/error/processing/preparing) so that
* undetectable images don't clutter the UI.
*/
export function UpdateStatusBadge({ status, isLoading }: Props) {
if (isLoading) {
return (
<span role="status" aria-label="Checking for image updates">
<Icon icon={Loader} size="sm" spin className="!mr-1 align-middle" />
</span>
);
}
if (status === 'outdated') {
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-warning">
<Icon icon={UpdatesAvailable} size="sm" className="align-middle" />
Update available
</span>
);
}
if (status === 'updated') {
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-muted">
<Icon icon={UpToDate} size="sm" className="align-middle" />
Up to date
</span>
);
}
// skipped / error / processing / preparing / undefined -> neutral, nothing to show.
return null;
}
@@ -0,0 +1 @@
export { UpdateStatusBadge } from './UpdateStatusBadge';
@@ -19,6 +19,13 @@ export const queryKeys = {
gpus: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'gpus'] as const,
imageStatus: (environmentId: EnvironmentId, id: string, nodeName?: string) =>
[
...queryKeys.container(environmentId, id),
'imageStatus',
nodeName,
] as const,
top: (environmentId: EnvironmentId, id: string) =>
[...queryKeys.container(environmentId, id), 'top'] as const,
};
@@ -0,0 +1,57 @@
import { renderHook } from '@testing-library/react-hooks';
import { http, HttpResponse } from 'msw';
import { server } from '@/setup-tests/server';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import {
ContainerImageStatus,
useContainerImageStatus,
} from './useContainerImageStatus';
const environmentId = 3;
const containerId = 'abc123';
function renderImageStatusHook(nodeName?: string) {
return renderHook(
() => useContainerImageStatus(environmentId, containerId, nodeName),
{ wrapper: withTestQueryProvider(({ children }) => <>{children}</>) }
);
}
describe('useContainerImageStatus', () => {
it('calls the non-proxied image_status endpoint and returns the status', async () => {
const payload: ContainerImageStatus = { Status: 'outdated' };
server.use(
http.get(
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
() => HttpResponse.json(payload)
)
);
const { result, waitFor } = renderImageStatusHook();
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toEqual(payload);
});
it('forwards nodeName as a query parameter', async () => {
let receivedNodeName: string | null = null;
server.use(
http.get(
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
({ request }) => {
receivedNodeName = new URL(request.url).searchParams.get('nodeName');
return HttpResponse.json<ContainerImageStatus>({ Status: 'updated' });
}
)
);
const { result, waitFor } = renderImageStatusHook('node-2');
await waitFor(() => result.current.isSuccess);
expect(receivedNodeName).toBe('node-2');
});
});
@@ -0,0 +1,68 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildDockerUrl } from '../../queries/utils/buildDockerUrl';
import { ContainerId } from '../types';
import { queryKeys } from './query-keys';
/**
* Status of a running container image as reported by the CE detection engine.
*
* - `outdated`: a newer image is available in the registry.
* - `updated`: the local image matches the remote registry digest.
* - `skipped`: detection is not applicable (digest-pinned or local-only image).
* - `processing` / `preparing`: detection is in progress (mostly relevant for services).
* - `error`: detection failed (registry unreachable, auth failure, ...).
*/
export type ContainerImageStatusValue =
| 'outdated'
| 'updated'
| 'skipped'
| 'processing'
| 'preparing'
| 'error';
export interface ContainerImageStatus {
Status: ContainerImageStatusValue;
Message?: string;
}
// The backend caches detection results for 24h, so a generous client-side staleTime
// is enough and avoids hammering the endpoint when many rows are visible at once.
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
export async function getContainerImageStatus(
environmentId: EnvironmentId,
containerId: ContainerId,
nodeName?: string
) {
try {
const { data } = await axios.get<ContainerImageStatus>(
buildDockerUrl(environmentId, 'containers', containerId, 'image_status'),
{ params: nodeName ? { nodeName } : undefined }
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Unable to retrieve image status');
}
}
export function useContainerImageStatus(
environmentId: EnvironmentId,
containerId: ContainerId,
nodeName?: string,
enabled = true
) {
return useQuery(
queryKeys.imageStatus(environmentId, containerId, nodeName),
() => getContainerImageStatus(environmentId, containerId, nodeName),
{
enabled,
staleTime: STALE_TIME,
refetchOnWindowFocus: false,
}
);
}
@@ -0,0 +1,83 @@
import { Stack } from '@/react/common/stacks/types';
import { getStackFile } from '@/react/common/stacks/queries/useStackFile';
import { updateStack } from '@/react/docker/stacks/useUpdateStack';
import { updateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { recreateContainer } from '../containers.service';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { ContainerUpdateContext, ContainerUpdateKind } from './types';
/** Thrown when an update is attempted on an externally-managed compose container. */
export const EXTERNAL_STACK_UPDATE_ERROR =
'This container is part of a compose project that is not managed by Portainer, so it cannot be updated from here.';
/**
* Redeploy a Portainer stack, forcing a re-pull, while preserving its current
* file content, environment variables and options. Reuses the exact mutations
* the stack page uses for "pull and redeploy" so we never invent a new path.
*/
async function redeployStackWithPull(stack: Stack, pullImage: boolean) {
if (stack.GitConfig) {
await updateGitStack(stack.Id, stack.EndpointId, {
RepullImageAndRedeploy: pullImage,
Env: stack.Env ?? [],
Prune: stack.Option?.Prune,
});
return;
}
// File/standalone compose stack: redeploy with its current file unchanged.
const file = await getStackFile({ stackId: stack.Id });
await updateStack({
stackId: stack.Id,
environmentId: stack.EndpointId,
payload: {
stackFileContent: file.StackFileContent,
env: stack.Env ?? [],
prune: stack.Option?.Prune,
repullImageAndRedeploy: pullImage,
},
});
}
/**
* Shared "apply an image update" primitive. Decides standalone-vs-stack-vs-external
* and runs the matching mutation. This is the single code path behind the
* "Update now" button, the bulk "Update selected" action and (future M4) the
* auto-update job, guaranteeing manual and automatic updates behave identically.
*
* A stack-managed container is ALWAYS routed through stack redeploy so it stays
* part of its stack; an externally-managed compose container is refused rather
* than recreated out-of-band (which would detach it).
*/
export async function applyContainerUpdate(
context: ContainerUpdateContext,
stacks: Stack[],
{ pullImage = true }: { pullImage?: boolean } = {}
): Promise<ContainerUpdateKind> {
const path = resolveContainerUpdatePath(context, stacks);
switch (path.kind) {
case 'standalone':
await recreateContainer(context.environmentId, context.id, pullImage, {
nodeName: context.nodeName,
});
return 'standalone';
case 'stack': {
const stack = stacks.find((s) => s.Id === path.stackId);
if (!stack) {
// Should not happen (resolve found it), but never fall back to a bare
// recreate: that would orphan the container from its stack.
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
}
await redeployStackWithPull(stack, pullImage);
return 'stack';
}
case 'external':
default:
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
}
}
@@ -0,0 +1,66 @@
import { Stack, StackType } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { groupContainersForUpdate } from './groupContainersForUpdate';
import { ContainerUpdateContext } from './types';
function buildContext(
overrides: Partial<ContainerUpdateContext>
): ContainerUpdateContext {
return {
id: 'c1',
name: 'container',
image: 'nginx:latest',
environmentId: 3,
...overrides,
};
}
const stack = {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
} as Stack;
describe('groupContainersForUpdate', () => {
it('redeploys a stack only once when several of its containers are selected', () => {
const contexts = [
buildContext({
id: 'a',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'b',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.stacks).toHaveLength(1);
expect(result.stacks[0].stackId).toBe(7);
expect(result.standalone).toHaveLength(0);
expect(result.external).toHaveLength(0);
});
it('partitions standalone, stack and external containers', () => {
const contexts = [
buildContext({ id: 'standalone', labels: {} }),
buildContext({
id: 'stack',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'external',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.standalone.map((c) => c.id)).toEqual(['standalone']);
expect(result.stacks.map((s) => s.stackId)).toEqual([7]);
expect(result.external.map((c) => c.id)).toEqual(['external']);
});
});
@@ -0,0 +1,50 @@
import { Stack, StackId } from '@/react/common/stacks/types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { ContainerUpdateContext } from './types';
export interface GroupedContainerUpdates {
/** Standalone containers, each recreated individually. */
standalone: ContainerUpdateContext[];
/**
* One entry per owning stack, even if several selected containers belong to
* the same stack: the stack is redeployed ONCE (mirrors M4's "one redeploy
* per stack per tick"). `context` is a representative container of the stack.
*/
stacks: Array<{ stackId: StackId; context: ContainerUpdateContext }>;
/** Externally-managed compose containers, skipped (never recreated). */
external: ContainerUpdateContext[];
}
/**
* Partition a set of containers into the apply paths, de-duplicating stack
* containers so each stack is redeployed only once regardless of how many of
* its containers were selected. Pure and unit-testable.
*/
export function groupContainersForUpdate(
contexts: ContainerUpdateContext[],
stacks: Stack[]
): GroupedContainerUpdates {
const standalone: ContainerUpdateContext[] = [];
const external: ContainerUpdateContext[] = [];
const stackMap = new Map<
StackId,
{ stackId: StackId; context: ContainerUpdateContext }
>();
contexts.forEach((context) => {
const path = resolveContainerUpdatePath(context, stacks);
if (path.kind === 'standalone') {
standalone.push(context);
} else if (path.kind === 'external') {
external.push(context);
} else if (path.kind === 'stack' && path.stackId != null) {
if (!stackMap.has(path.stackId)) {
stackMap.set(path.stackId, { stackId: path.stackId, context });
}
}
});
return { standalone, external, stacks: Array.from(stackMap.values()) };
}
@@ -0,0 +1,16 @@
export { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
export { groupContainersForUpdate } from './groupContainersForUpdate';
export {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
export {
useUpdateContainerImage,
invalidateContainerUpdateQueries,
} from './useUpdateContainerImage';
export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages';
export type {
ContainerUpdateContext,
ContainerUpdateKind,
ContainerUpdatePath,
} from './types';
@@ -0,0 +1,99 @@
import { Stack, StackType } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
function buildStack(overrides: Partial<Stack>): Stack {
return {
Id: 1,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
...overrides,
} as Stack;
}
describe('resolveContainerUpdatePath', () => {
it('returns standalone when there is no compose project label', () => {
expect(
resolveContainerUpdatePath({ labels: {}, environmentId: 3 }, [])
).toEqual({ kind: 'standalone' });
});
it('returns stack when the compose project matches a Portainer stack', () => {
const stack = buildStack({ Id: 7, Name: 'my-stack', EndpointId: 3 });
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'stack', stackId: 7, isGitStack: false });
});
it('flags git stacks so they redeploy via the git path', () => {
const stack = buildStack({
Id: 9,
Name: 'git-stack',
EndpointId: 3,
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
});
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'git-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'stack', stackId: 9, isGitStack: true });
});
it('returns external when the compose project has no matching stack', () => {
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'unknown-project' },
environmentId: 3,
},
[buildStack({ Name: 'other' })]
)
).toEqual({ kind: 'external' });
});
it('returns external when a same-named stack is not a compose stack', () => {
const stack = buildStack({
Name: 'my-stack',
EndpointId: 3,
Type: StackType.Kubernetes,
});
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'external' });
});
it('does not match a stack from a different endpoint', () => {
const stack = buildStack({ Name: 'my-stack', EndpointId: 99 });
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'external' });
});
});
@@ -0,0 +1,48 @@
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { Stack, StackType } from '@/react/common/stacks/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ContainerUpdatePath } from './types';
/**
* Decide how a container's image update must be applied, given the list of
* Portainer-managed stacks. Pure and side-effect free so it can be unit-tested
* and reused by the details button, the bulk action and the M4 auto-update job.
*
* - No compose project label -> `standalone` (recreate-with-pull).
* - Compose project that matches a Portainer Docker Compose `Stack` (same name +
* endpoint + compose type) -> `stack` (redeploy-with-pull, keeps the container
* in its stack).
* - Compose project with no matching Portainer compose stack -> `external`:
* managed outside Portainer (or a same-named stack of another type), so we
* must not recreate it (would detach it / drift).
*/
export function resolveContainerUpdatePath(
context: { labels?: Record<string, string>; environmentId: EnvironmentId },
stacks: Stack[]
): ContainerUpdatePath {
const projectName = context.labels?.[COMPOSE_STACK_NAME_LABEL];
if (!projectName) {
return { kind: 'standalone' };
}
// Match by name + endpoint, but only a Docker Compose stack: a same-named
// swarm/kubernetes stack must not be redeployed via the compose update path.
const stack = stacks.find(
(s) =>
s.Name === projectName &&
s.EndpointId === context.environmentId &&
s.Type === StackType.DockerCompose
);
if (!stack) {
return { kind: 'external' };
}
return {
kind: 'stack',
stackId: stack.Id,
isGitStack: !!stack.GitConfig,
};
}
@@ -0,0 +1,40 @@
import { StackId } from '@/react/common/stacks/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ContainerId } from '../types';
/**
* Minimal context needed to apply an image update to a single container,
* independent of whether it comes from the details view, the bulk action or
* (future M4) the auto-update job.
*/
export interface ContainerUpdateContext {
id: ContainerId;
/** Display name, used for success/error toasts. */
name: string;
/** Current image reference, used to detect un-pullable (sha256) images. */
image: string;
/** Docker labels, used to detect compose/stack membership. */
labels?: Record<string, string>;
environmentId: EnvironmentId;
/** Swarm/agent node hosting the container, threaded through to the API. */
nodeName?: string;
}
/**
* How a container's image update must be applied:
* - `standalone`: recreate-with-pull (no compose project).
* - `stack`: redeploy the owning Portainer stack with re-pull, so the
* container stays part of its stack.
* - `external`: compose-managed but with no matching Portainer stack record;
* Portainer must not touch it (would either detach it or drift).
*/
export type ContainerUpdateKind = 'standalone' | 'stack' | 'external';
export interface ContainerUpdatePath {
kind: ContainerUpdateKind;
/** Set when `kind === 'stack'`. */
stackId?: StackId;
/** Set when `kind === 'stack'`; routes file vs git redeploy. */
isGitStack?: boolean;
}
@@ -0,0 +1,185 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Stack } from '@/react/common/stacks/types';
import {
notifyError,
notifySuccess,
notifyWarning,
} from '@/portainer/services/notifications';
import { getContainerImageStatus } from '../queries/useContainerImageStatus';
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
import { applyContainerUpdate } from './applyContainerUpdate';
import { groupContainersForUpdate } from './groupContainersForUpdate';
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
// Mirror useContainerImageStatus's client-side staleTime so the bulk action
// reuses cached badge statuses instead of re-hitting the registry per row.
const STATUS_STALE_TIME = 5 * 60 * 1000;
interface BulkUpdateParams {
contexts: ContainerUpdateContext[];
stacks: Stack[];
/**
* Whether the user holds `PortainerStackUpdate`. Stack redeploys are gated on
* it everywhere else, so without it we skip (never 403) stack-managed ones.
*/
canUpdateStack: boolean;
}
/**
* Bulk "Update selected": applies the shared update primitive to each selected
* container that is `outdated`, skipping up-to-date/unknown ones with a summary,
* and grouping stack containers so each owning stack redeploys ONCE even if
* several of its containers were selected. Reports per-item success/failure.
*/
export function useBulkUpdateContainerImages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts, stacks, canUpdateStack),
});
}
async function bulkUpdate(
queryClient: ReturnType<typeof useQueryClient>,
contexts: ContainerUpdateContext[],
stacks: Stack[],
canUpdateStack: boolean
) {
// Resolve each container's status (cached where the badge already loaded it).
const statuses = await Promise.all(
contexts.map((context) =>
queryClient
.fetchQuery(
containerQueryKeys.imageStatus(
context.environmentId,
context.id,
context.nodeName
),
() =>
getContainerImageStatus(
context.environmentId,
context.id,
context.nodeName
),
{ staleTime: STATUS_STALE_TIME }
)
.then((status) => status.Status)
.catch(() => 'error' as const)
)
);
const outdated = contexts.filter((_c, i) => statuses[i] === 'outdated');
const skippedNotOutdated = contexts.length - outdated.length;
// Nothing actionable: make the no-op explicit instead of a vague "skipped".
if (outdated.length === 0) {
notifyWarning(
'Nothing to update',
'None of the selected containers have updates available.'
);
return { containersUpdated: 0, stacksUpdated: 0, failures: [], skipped: 0 };
}
const { standalone, stacks: stackGroups, external } =
groupContainersForUpdate(outdated, stacks);
// Without stack-update rights we must not attempt a stack redeploy (403).
const allowedStackGroups = canUpdateStack ? stackGroups : [];
const skippedUnauthorizedStacks = canUpdateStack ? 0 : stackGroups.length;
let containersUpdated = 0;
let stacksUpdated = 0;
const failures: string[] = [];
// Standalone containers: recreate-with-pull, one per container.
await runSequential(standalone, async (context) => {
try {
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
containersUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError('Failure', err as Error, `Unable to update ${context.name}`);
}
});
// Stack-managed containers: redeploy each owning stack exactly once.
await runSequential(allowedStackGroups, async ({ context }) => {
try {
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
stacksUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError(
'Failure',
err as Error,
`Unable to redeploy stack for ${context.name}`
);
}
});
// A stack redeploy updates every container in the stack, so report containers
// and stacks separately rather than conflating both into one "update" count.
if (containersUpdated > 0 || stacksUpdated > 0) {
const parts: string[] = [];
if (containersUpdated > 0) {
parts.push(`${containersUpdated} ${pluralize(containersUpdated, 'container')}`);
}
if (stacksUpdated > 0) {
parts.push(`${stacksUpdated} ${pluralize(stacksUpdated, 'stack')}`);
}
notifySuccess('Success', `${parts.join(' and ')} updated`);
}
const skippedExternal = external.length;
if (
skippedNotOutdated > 0 ||
skippedExternal > 0 ||
skippedUnauthorizedStacks > 0
) {
const parts: string[] = [];
if (skippedNotOutdated > 0) {
parts.push(`${skippedNotOutdated} not outdated`);
}
if (skippedExternal > 0) {
parts.push(`${skippedExternal} managed outside Portainer`);
}
if (skippedUnauthorizedStacks > 0) {
parts.push(
`${skippedUnauthorizedStacks} ${pluralize(
skippedUnauthorizedStacks,
'stack'
)} you can't update`
);
}
notifyWarning('Some containers were skipped', parts.join(', '));
}
return {
containersUpdated,
stacksUpdated,
failures,
skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks,
};
}
async function runSequential<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
// Sequential to avoid hammering registries / overlapping stack redeploys.
await items.reduce(
(chain, item) => chain.then(() => fn(item)),
Promise.resolve()
);
}
function pluralize(count: number, word: string) {
return count === 1 ? word : `${word}s`;
}
@@ -0,0 +1,56 @@
import { QueryClient, useMutation, useQueryClient } from '@tanstack/react-query';
import { withError } from '@/react-tools/react-query';
import { Stack } from '@/react/common/stacks/types';
import { queryKeys as stacksQueryKeys } from '@/react/common/stacks/queries/query-keys';
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
import { applyContainerUpdate } from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
/**
* Refresh the data affected by a container image update: the container itself,
* its image-status badge (so it flips away from "outdated") and the stacks
* list (a stack redeploy bumps its deployment info).
*/
export function invalidateContainerUpdateQueries(
queryClient: QueryClient,
context: ContainerUpdateContext
) {
queryClient.invalidateQueries(
containerQueryKeys.container(context.environmentId, context.id)
);
queryClient.invalidateQueries(
containerQueryKeys.imageStatus(
context.environmentId,
context.id,
context.nodeName
)
);
queryClient.invalidateQueries(stacksQueryKeys.base());
}
interface UpdateContainerImageParams {
context: ContainerUpdateContext;
stacks: Stack[];
pullImage?: boolean;
}
/**
* Single-container image-update mutation built on the shared apply primitive.
* Used by the details-view "Update now" button; the bulk action drives the same
* primitive directly (see useBulkUpdateContainerImages).
*/
export function useUpdateContainerImage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ context, stacks, pullImage }: UpdateContainerImageParams) =>
applyContainerUpdate(context, stacks, { pullImage }),
onSuccess: (_kind, { context }) => {
invalidateContainerUpdateQueries(queryClient, context);
},
...withError('Unable to update container image'),
});
}