Add native CE detection of "a newer image is available" for running
containers, surfaced as a read-only HTTP endpoint and a containers-list
badge/column. No applying of updates (M3/M4), no auto-heal (M1).
Backend:
- New CE handler GET /docker/{id}/containers/{containerId}/image_status
backed by the existing zlib/CE digest engine
(images.NewClientWithRegistry + ContainerImageStatus). Honors nodeName,
authz, and routes registry calls through the credential store / SSRF
AllowList. Engine failures degrade to a 200 {Status:"error"} so the UI
stays graceful. Response shape: {Status, Message?}.
Frontend (CE-only, no isBE gating; the EE ImageStatus component is left
untouched):
- useContainerImageStatus TanStack Query hook (5min staleTime, no
refetch-on-focus; backend caches 24h) calling the non-proxied endpoint.
- UpdateStatusBadge component (own assets, neutral on skipped/error).
- "Update available" column in the containers datatable; one cached,
non-blocking query per visible row.
Tests: Go response-shape unit test; vitest for the badge (all statuses)
and the hook (url + nodeName query param via msw).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54 lines
1.4 KiB
Go
54 lines
1.4 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|