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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-9
@@ -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}
|
||||
|
||||
+17
-74
@@ -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 });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+71
-3
@@ -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;
|
||||
}
|
||||
|
||||
+83
-1
@@ -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?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user