feat(containers): interactive image-status badge (click to update / re-check)

Make the container image-status badge actionable, matching native Portainer:
- Clicking "Update available" opens the update confirm dialog and runs the
  existing update flow (standalone recreate-with-pull / stack redeploy), gated
  and disabled while in flight to avoid a double submit. The confirm+apply logic
  is extracted from UpdateNowButton into a shared useApplyContainerImageUpdate
  hook so the details button and the list badge share one implementation.
- Clicking "Up to date" re-queries the registry. Because the server caches image
  status (statusCache 5m + remoteDigestCache 5s), a plain refetch was a no-op, so
  the endpoint gains an optional ?force=true that bypasses BOTH caches for a
  manual re-check while still repopulating them; the default (auto badges + the
  auto-update daemon) keeps using the caches unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-01 19:04:49 +03:00
parent f658d67ccb
commit 6aecdfbe46
12 changed files with 646 additions and 109 deletions
+33 -6
View File
@@ -123,7 +123,23 @@ func AggregateImageStatus(statuses []Status) Status {
return Updated
}
// ContainerImageStatus returns the image status for a container, serving a recent
// value from the statusCache when available (the default used by per-row badges,
// ContainersImageStatus and the auto-update daemon).
func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) {
return c.containerImageStatus(ctx, containerID, endpoint, nodeName, false)
}
// ContainerImageStatusForced recomputes the image status against the remote
// registry, bypassing the cached read while still refreshing the cache with the
// fresh result. It backs the UI's manual "re-check" action, where the user
// explicitly asks for an up-to-date registry comparison rather than the value
// cached for statusCacheTTL.
func (c *DigestClient) ContainerImageStatusForced(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) {
return c.containerImageStatus(ctx, containerID, endpoint, nodeName, true)
}
func (c *DigestClient) containerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string, force bool) (Status, error) {
cli, err := c.clientFactory.CreateClient(endpoint, nodeName, nil)
if err != nil {
log.Warn().Str("swarmNodeId", nodeName).Msg("Cannot create new docker client.")
@@ -154,8 +170,13 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
// status (the full computation would now return "outdated") until it expired. A
// short TTL re-checks the remote digest within the poll window. Both Outdated
// and Skipped are cached too (only the error paths return early without caching).
if s, err := CachedResourceImageStatus(imageID); err == nil {
return s, nil
//
// A forced re-check (force=true) skips this cached read and recomputes against
// the registry, then writes the fresh result back into the cache below.
if !force {
if s, err := CachedResourceImageStatus(imageID); err == nil {
return s, nil
}
}
digs := make([]digest.Digest, 0)
@@ -178,7 +199,7 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
images = append(images, ParseRepoTags(imageInspect.RepoTags)...)
}
s, err := c.checkStatus(ctx, images, digs)
s, err := c.checkStatus(ctx, images, digs, force)
if err != nil {
log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status")
return Error, err
@@ -228,7 +249,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string,
return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil
}
func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest) (Status, error) {
func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest, force bool) (Status, error) {
if digests == nil {
digests = make([]digest.Digest, 0)
}
@@ -249,8 +270,14 @@ func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests
for _, img := range images {
var remoteDigest digest.Digest
var err error
if rd, ok := remoteDigestCache.Get(img.FullName()); ok {
remoteDigest, _ = rd.(digest.Digest)
// A forced re-check skips the short-lived remoteDigestCache read so it
// actually HEADs the registry; the fresh digest is still written back
// below. The default path keeps reusing the cache (auto-badges and the
// auto-update daemon must not add registry load).
if !force {
if rd, ok := remoteDigestCache.Get(img.FullName()); ok {
remoteDigest, _ = rd.(digest.Digest)
}
}
if remoteDigest == "" {
remoteDigest, err = c.RemoteDigest(ctx, *img)
+104
View File
@@ -1,12 +1,31 @@
package images
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/opencontainers/go-digest"
"github.com/stretchr/testify/require"
)
// fakeClientFactory hands the DigestClient a Docker client wired to a test server.
type fakeClientFactory struct {
cli *dockerclient.Client
}
func (f fakeClientFactory) CreateClient(*portainer.Endpoint, string, *time.Duration) (*dockerclient.Client, error) {
return f.cli, nil
}
// TestStatusCacheTTLIsShort is a regression guard for the stale-detection bug: the
// statusCache is keyed by the LOCAL imageID, which does not change when upstream
// pushes a new image under the same tag. A long TTL (the previous 24h) would serve
@@ -27,6 +46,91 @@ func TestStatusCacheTTLIsShort(t *testing.T) {
require.LessOrEqual(t, time.Until(exp), statusCacheTTL)
}
// TestContainerImageStatusForcedBypassesCache verifies the manual "re-check"
// contract: the default read serves the value cached under the local imageID,
// while the forced read skips that cached read and recomputes (here the image
// inspect is stubbed to fail, so the forced call surfaces a non-cached result
// instead of the seeded sentinel).
func TestContainerImageStatusForcedBypassesCache(t *testing.T) {
const containerID = "force-test-container"
const imageID = "sha256:0000000000000000000000000000000000000000000000000000000000000001"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/containers/"+containerID+"/json") {
resp := container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: containerID,
Image: imageID,
},
Config: &container.Config{Image: "nginx:latest"},
}
_ = json.NewEncoder(w).Encode(resp)
return
}
// Any other call (notably the image inspect on the forced recompute path)
// fails, so the forced status is deterministically not the cached sentinel
// and no real registry is contacted.
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
cli, err := dockerclient.NewClientWithOpts(
dockerclient.WithHost(srv.URL),
dockerclient.WithHTTPClient(http.DefaultClient),
)
require.NoError(t, err)
c := &DigestClient{clientFactory: fakeClientFactory{cli: cli}}
// Seed the cache for the local imageID with a sentinel the recompute here
// could never legitimately reproduce.
CacheResourceImageStatus(imageID, Updated)
defer EvictImageStatus(imageID)
// Default (cached) read serves the seeded sentinel without recomputing.
cached, err := c.ContainerImageStatus(context.Background(), containerID, &portainer.Endpoint{}, "")
require.NoError(t, err)
require.Equal(t, Updated, cached)
// Forced read must bypass the cache: it recomputes and, with the image inspect
// stubbed to fail, does not return the cached sentinel.
forced, _ := c.ContainerImageStatusForced(context.Background(), containerID, &portainer.Endpoint{}, "")
require.NotEqual(t, Updated, forced)
}
// TestCheckStatusForcedBypassesRemoteDigestCache proves the forced re-check also
// bypasses the short-lived remoteDigestCache (not just the statusCache): with a
// warm cache entry that matches the local digest, the default path returns
// "updated" without any registry HEAD, while the forced path ignores the cache
// and performs a fresh remote lookup (here against an unroutable registry, so it
// errors instead of serving the cached match).
func TestCheckStatusForcedBypassesRemoteDigestCache(t *testing.T) {
// A tag on an unroutable registry: RemoteDigest fails fast (connection
// refused) rather than reaching a real network.
img, err := ParseImage(ParseImageOptions{Name: "127.0.0.1:1/portainer/forcecheck:latest"})
require.NoError(t, err)
localDigest := digest.Digest("sha256:" + strings.Repeat("a", 64))
// Warm the remote digest cache with a value that matches the local digest.
remoteDigestCache.Set(img.FullName(), localDigest, 0)
defer remoteDigestCache.Delete(img.FullName())
c := &DigestClient{}
// Default path: reads the warm cache, remote == local -> Updated, no HEAD.
cached, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, false)
require.NoError(t, err)
require.Equal(t, Updated, cached)
// Forced path: skips the cache and attempts a fresh remote lookup, which
// fails against the unroutable registry instead of serving the cached match.
forced, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, true)
require.Error(t, err)
require.Equal(t, Error, forced)
}
func TestAggregateImageStatus(t *testing.T) {
t.Parallel()
@@ -26,6 +26,10 @@ type imageStatusResponse struct {
// @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 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.
// @description **Access policy**: authenticated
// @tags docker
// @security ApiKeyAuth
@@ -34,10 +38,7 @@ type imageStatusResponse struct {
// @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.
// @param force query bool false "Bypass the server-side status cache and recompute against the registry (manual re-check)"
// @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"
@@ -52,6 +53,11 @@ func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *htt
// nodeName is optional and only relevant for Swarm/agent endpoints.
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
// force is set by the UI's manual "re-check" action to bypass the ~5m server
// cache and recompute against the registry. Absent (the default, used by the
// per-row auto-badges) the cached value is served as before.
force, _ := request.RetrieveBooleanQueryParameter(r, "force", true)
endpoint, err := middlewares.FetchEndpoint(r)
if err != nil {
return httperror.NotFound("Unable to find an environment on request context", err)
@@ -68,7 +74,12 @@ func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *htt
// filter; this mirrors upstream ContainersImageStatus behaviour.
digestClient := images.NewClientWithRegistry(images.NewRegistryClient(handler.dataStore), handler.dockerClientFactory)
status, err := digestClient.ContainerImageStatus(r.Context(), containerID, endpoint, nodeName)
statusFn := digestClient.ContainerImageStatus
if force {
statusFn = digestClient.ContainerImageStatusForced
}
status, err := statusFn(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
@@ -35,19 +35,23 @@ const composeStack = {
Type: StackType.DockerCompose,
};
vi.mock(
'@/react/docker/containers/update',
async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
})
);
// Mock the concrete mutation module so both the `update` index re-export and the
// shared `useApplyContainerImageUpdate` hook (which imports it directly) resolve
// to the stub.
vi.mock('@/react/docker/containers/update/useUpdateContainerImage', () => ({
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
invalidateContainerUpdateQueries: vi.fn(),
}));
function setStatus(status?: ContainerImageStatusValue) {
mockUseImageStatus.mockReturnValue({ data: status ? { Status: status } : undefined });
mockUseImageStatus.mockReturnValue({
data: status ? { Status: status } : undefined,
});
}
function renderButton(props: Partial<React.ComponentProps<typeof UpdateNowButton>> = {}) {
function renderButton(
props: Partial<React.ComponentProps<typeof UpdateNowButton>> = {}
) {
return render(
<UpdateNowButton
environmentId={3}
@@ -2,17 +2,8 @@ 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 { useApplyContainerImageUpdate } from '@/react/docker/containers/update';
import { ButtonGroup, LoadingButton } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
@@ -55,38 +46,32 @@ export function UpdateNowButton({
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
);
const { apply, isLoading, isExternal, stackUpdateForbidden, canApply } =
useApplyContainerImageUpdate({
environmentId,
containerId,
nodeName,
containerImage,
containerName,
labels,
isPortainer,
// The details view reloads to reflect the recreated/redeployed container.
onSuccess: () =>
router.stateService.go('docker.containers', {}, { reload: true }),
});
// 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}
onClick={apply}
disabled={!canApply}
isLoading={isLoading}
loadingText="Updating..."
data-cy="update-now-button"
icon={Download}
@@ -116,46 +101,4 @@ export function UpdateNowButton({
}
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 });
},
}
);
}
}
@@ -2,7 +2,13 @@ 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 {
useContainerImageStatus,
useRecheckContainerImageStatus,
} from '@/react/docker/containers/queries/useContainerImageStatus';
import { useApplyContainerImageUpdate } from '@/react/docker/containers/update';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { UpdateStatusBadge } from '../../../components/UpdateStatusBadge';
@@ -28,10 +34,72 @@ function UpdateAvailableCell({
container.NodeName
);
return (
// Manual re-check forces a cache-bypassing registry comparison and writes the
// fresh result back into the status query cache, so the badge flips if it
// changed. (A plain refetch would only bypass the client staleTime and still
// hit the ~5m server cache.)
const recheckMutation = useRecheckContainerImageStatus(
environmentId,
container.Id,
container.NodeName
);
// Same shared apply flow as the details-view "Update now" button (confirm +
// standalone/stack/external routing + permission gating). No `onSuccess`: the
// mutation's query invalidation refreshes this row's badge in place.
const {
apply,
isLoading: isUpdating,
isExternal,
stackUpdateForbidden,
canApply,
} = useApplyContainerImageUpdate({
environmentId,
containerId: container.Id,
nodeName: container.NodeName,
containerImage: container.Image,
containerName: container.Names?.[0] ?? container.Id,
labels: container.Labels,
isPortainer: container.IsPortainer ?? false,
});
const status = statusQuery.data?.Status;
const badge = (
<UpdateStatusBadge
status={statusQuery.data?.Status}
status={status}
isLoading={statusQuery.isLoading}
// Externally-managed / permission-gated updates stay non-actionable: no
// click handler, so the badge renders as a plain span (wrapped below in a
// tooltip that explains why).
onUpdateClick={
status === 'outdated' && canApply ? () => apply() : undefined
}
isUpdating={isUpdating}
onRecheckClick={
status === 'updated' ? () => recheckMutation.mutate() : undefined
}
isRechecking={recheckMutation.isLoading}
/>
);
// Mirror the details-view button's explanations for the two gated cases so a
// user understands why the "Update available" badge isn't clickable here.
if (status === 'outdated' && isExternal) {
return (
<TooltipWithChildren message="This container belongs to a compose project that is managed outside Portainer, so it can't be updated from here.">
<span>{badge}</span>
</TooltipWithChildren>
);
}
if (status === 'outdated' && stackUpdateForbidden) {
return (
<TooltipWithChildren message="Updating this container redeploys its stack, which requires stack update permission you don't have.">
<span>{badge}</span>
</TooltipWithChildren>
);
}
return badge;
}
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { vi } from 'vitest';
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
@@ -41,4 +42,85 @@ describe('UpdateStatusBadge', () => {
expect(container).toBeEmptyDOMElement();
});
describe('interactive behavior', () => {
it('renders "Update available" as a plain span when no handler is passed', () => {
render(<UpdateStatusBadge status="outdated" />);
expect(
screen.queryByRole('button', { name: /update available/i })
).not.toBeInTheDocument();
});
it('renders "Update available" as a button and fires the handler on click', () => {
const onUpdateClick = vi.fn();
render(
<UpdateStatusBadge status="outdated" onUpdateClick={onUpdateClick} />
);
const button = screen.getByRole('button', {
name: /update available/i,
});
fireEvent.click(button);
expect(onUpdateClick).toHaveBeenCalledTimes(1);
});
it('renders "Up to date" as a plain span when no handler is passed', () => {
render(<UpdateStatusBadge status="updated" />);
expect(
screen.queryByRole('button', { name: /up to date/i })
).not.toBeInTheDocument();
});
it('renders "Up to date" as a button and fires the recheck handler on click', () => {
const onRecheckClick = vi.fn();
render(
<UpdateStatusBadge status="updated" onRecheckClick={onRecheckClick} />
);
const button = screen.getByRole('button', { name: /up to date/i });
fireEvent.click(button);
expect(onRecheckClick).toHaveBeenCalledTimes(1);
});
it('shows a pending affordance and disables the update button while updating', () => {
const onUpdateClick = vi.fn();
render(
<UpdateStatusBadge
status="outdated"
onUpdateClick={onUpdateClick}
isUpdating
/>
);
const button = screen.getByRole('button', {
name: /updating the image/i,
});
expect(button).toBeDisabled();
expect(screen.getByText('Updating...')).toBeInTheDocument();
fireEvent.click(button);
expect(onUpdateClick).not.toHaveBeenCalled();
});
it('shows a spinner and disables the recheck button while rechecking', () => {
const onRecheckClick = vi.fn();
render(
<UpdateStatusBadge
status="updated"
onRecheckClick={onRecheckClick}
isRechecking
/>
);
const button = screen.getByRole('button', { name: /up to date/i });
expect(button).toBeDisabled();
fireEvent.click(button);
expect(onRecheckClick).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,5 @@
import { Loader } from 'lucide-react';
import clsx from 'clsx';
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
@@ -10,8 +11,32 @@ import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus
export interface Props {
status?: ContainerImageStatusValue;
isLoading?: boolean;
/**
* When provided, the "Update available" badge becomes a button that triggers
* an image update. Omit it to keep the plain, non-interactive span.
*/
onUpdateClick?: () => void;
/**
* When provided, the "Up to date" badge becomes a button that re-checks the
* registry for a newer image. Omit it to keep the plain span.
*/
onRecheckClick?: () => void;
/** Drives the spinner shown on the "Up to date" badge while a recheck runs. */
isRechecking?: boolean;
/**
* While an update is in flight the "Update available" button shows a pending
* "Updating..." affordance and is disabled, preventing a double-submit (parity
* with the details-view "Update now" button).
*/
isUpdating?: boolean;
}
const badgeClass = 'inline-flex items-center gap-1 whitespace-nowrap';
// Reset the native button chrome so the interactive badge looks identical to the
// plain span, while staying keyboard-focusable with a visible focus ring.
const interactiveClass =
'cursor-pointer border-0 bg-transparent p-0 hover:underline focus:outline-none focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-blue-5';
/**
* CE container image update badge.
*
@@ -19,8 +44,20 @@ export interface Props {
* 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.
*
* The two visible states become interactive when the matching handler is passed:
* clicking "Update available" applies the image update, clicking "Up to date"
* re-checks the registry (showing a spinner meanwhile). Without a handler each
* stays a plain span so non-interactive callers are unaffected.
*/
export function UpdateStatusBadge({ status, isLoading }: Props) {
export function UpdateStatusBadge({
status,
isLoading,
onUpdateClick,
onRecheckClick,
isRechecking,
isUpdating,
}: Props) {
if (isLoading) {
return (
<span role="status" aria-label="Checking for image updates">
@@ -30,21 +67,71 @@ export function UpdateStatusBadge({ status, isLoading }: Props) {
}
if (status === 'outdated') {
return (
<span className="inline-flex items-center gap-1 whitespace-nowrap text-warning">
const content = isUpdating ? (
<>
<Icon icon={Loader} size="sm" spin className="align-middle" />
Updating...
</>
) : (
<>
<Icon icon={UpdatesAvailable} size="sm" className="align-middle" />
Update available
</span>
</>
);
if (onUpdateClick) {
return (
<button
type="button"
onClick={onUpdateClick}
// Disabled while updating: blocks the click so a second submit can't
// fire another recreate/redeploy.
disabled={isUpdating}
className={clsx(badgeClass, interactiveClass, 'text-warning')}
data-cy="update-status-badge-update"
aria-label={
isUpdating
? 'Updating the image'
: 'Update available, click to update the image'
}
>
{content}
</button>
);
}
return <span className={clsx(badgeClass, 'text-warning')}>{content}</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" />
const content = (
<>
<Icon
icon={isRechecking ? Loader : UpToDate}
size="sm"
spin={isRechecking}
className="align-middle"
/>
Up to date
</span>
</>
);
if (onRecheckClick) {
return (
<button
type="button"
onClick={onRecheckClick}
disabled={isRechecking}
className={clsx(badgeClass, interactiveClass, 'text-muted')}
data-cy="update-status-badge-recheck"
aria-label="Up to date, click to re-check for image updates"
>
{content}
</button>
);
}
return <span className={clsx(badgeClass, 'text-muted')}>{content}</span>;
}
// skipped / error / processing / preparing / undefined -> neutral, nothing to show.
@@ -7,6 +7,7 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import {
ContainerImageStatus,
useContainerImageStatus,
useRecheckContainerImageStatus,
} from './useContainerImageStatus';
const environmentId = 3;
@@ -54,4 +55,53 @@ describe('useContainerImageStatus', () => {
await waitFor(() => result.current.isSuccess);
expect(receivedNodeName).toBe('node-2');
});
it('does not send the force parameter on the background query', async () => {
let receivedForce: string | null = 'unset';
server.use(
http.get(
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
({ request }) => {
receivedForce = new URL(request.url).searchParams.get('force');
return HttpResponse.json<ContainerImageStatus>({ Status: 'updated' });
}
)
);
const { result, waitFor } = renderImageStatusHook();
await waitFor(() => result.current.isSuccess);
expect(receivedForce).toBeNull();
});
});
describe('useRecheckContainerImageStatus', () => {
it('sends force=true and updates the shared query cache with the fresh status', async () => {
let receivedForce: string | null = null;
server.use(
http.get(
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
({ request }) => {
receivedForce = new URL(request.url).searchParams.get('force');
return HttpResponse.json<ContainerImageStatus>({
Status: 'outdated',
});
}
)
);
const wrapper = withTestQueryProvider(({ children }) => <>{children}</>);
const { result, waitFor } = renderHook(
() => useRecheckContainerImageStatus(environmentId, containerId),
{ wrapper }
);
result.current.mutate();
await waitFor(() => result.current.isSuccess);
expect(receivedForce).toBe('true');
expect(result.current.data).toEqual({ Status: 'outdated' });
});
});
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { withError } from '@/react-tools/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { buildDockerUrl } from '../../queries/utils/buildDockerUrl';
@@ -39,12 +40,23 @@ export const STALE_TIME = 5 * 60 * 1000; // 5 minutes
export async function getContainerImageStatus(
environmentId: EnvironmentId,
containerId: ContainerId,
nodeName?: string
nodeName?: string,
// When true, ask the backend to bypass its short-lived status cache and
// recompute against the registry (manual re-check). Off by default so the
// per-row auto-badges keep using the cache.
force = false
) {
try {
const params: { nodeName?: string; force?: boolean } = {};
if (nodeName) {
params.nodeName = nodeName;
}
if (force) {
params.force = true;
}
const { data } = await axios.get<ContainerImageStatus>(
buildDockerUrl(environmentId, 'containers', containerId, 'image_status'),
{ params: nodeName ? { nodeName } : undefined }
{ params: Object.keys(params).length > 0 ? params : undefined }
);
return data;
} catch (err) {
@@ -68,3 +80,30 @@ export function useContainerImageStatus(
}
);
}
/**
* Manual "re-check" for a single container: forces a cache-bypassing registry
* comparison and writes the fresh result into the shared image-status query cache,
* so the badge flips in place if the status changed. Used by the interactive
* "Up to date" list badge; the background per-row query keeps using the cache.
*/
export function useRecheckContainerImageStatus(
environmentId: EnvironmentId,
containerId: ContainerId,
nodeName?: string
) {
const queryClient = useQueryClient();
return useMutation(
() => getContainerImageStatus(environmentId, containerId, nodeName, true),
{
onSuccess: (data) => {
queryClient.setQueryData(
queryKeys.imageStatus(environmentId, containerId, nodeName),
data
);
},
...withError('Unable to refresh image status'),
}
);
}
@@ -8,6 +8,7 @@ export {
useUpdateContainerImage,
invalidateContainerUpdateQueries,
} from './useUpdateContainerImage';
export { useApplyContainerImageUpdate } from './useApplyContainerImageUpdate';
export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages';
export type {
ContainerUpdateContext,
@@ -0,0 +1,121 @@
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 { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerId } from '../types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { useUpdateContainerImage } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
interface Params {
environmentId: EnvironmentId;
containerId: ContainerId;
nodeName?: string;
containerImage: string;
containerName: string;
labels?: Record<string, string>;
/** Portainer's own container can't update itself, so the action is disabled. */
isPortainer: boolean;
/**
* Post-success side effect. The details-view button navigates/reloads here;
* the list badge leaves it empty and lets react-query invalidation refresh the
* row.
*/
onSuccess?: () => void;
}
/**
* Shared "apply an image update to a single container" action, used by both the
* details-view "Update now" button and the interactive list badge so the confirm
* dialogs, standalone/stack/external routing and permission gating live in ONE
* place.
*
* Routing mirrors the backend: standalone -> recreate-with-pull, stack-managed ->
* stack redeploy-with-pull (container stays in its stack), externally-managed
* compose -> not touched. A stack redeploy is gated by `PortainerStackUpdate`, so
* a user with container-create but without stack-update rights can't apply it
* (surfaced via `stackUpdateForbidden` rather than a 403 on click).
*/
export function useApplyContainerImageUpdate({
environmentId,
containerId,
nodeName,
containerImage,
containerName,
labels,
isPortainer,
onSuccess,
}: Params) {
const stacksQuery = useStacks();
const updateMutation = useUpdateContainerImage();
// A stack redeploy needs stack-update rights, not just container-create.
const { authorized: canUpdateStack } = useAuthorizations(
'PortainerStackUpdate',
environmentId
);
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.
const stackUpdateForbidden = path.kind === 'stack' && !canUpdateStack;
const canApply =
!isPortainer &&
!isExternal &&
!stackUpdateForbidden &&
!stacksQuery.isLoading;
return {
apply,
isLoading: updateMutation.isLoading,
isExternal,
stackUpdateForbidden,
canApply,
};
async function apply() {
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');
onSuccess?.();
},
}
);
}
}