+ {/* Keep mounts in the parent scroll flow: overlay scrollbars can hide a nested list. */}
+
{(disk) => {
const total = disk.total ?? 0;
diff --git a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx
index acd11805b..af0170e5e 100644
--- a/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx
+++ b/frontend-modern/src/components/shared/cards/__tests__/DisksCard.test.tsx
@@ -55,6 +55,21 @@ describe('DisksCard', () => {
expect(screen.getByText('/data')).toBeInTheDocument();
});
+ it.each([2, 24])('keeps all %i mounts in the parent scroll flow', (count) => {
+ const disks = Array.from({ length: count }, (_, i) => ({
+ mountpoint: `/mnt/disk-${i}`,
+ total: 100,
+ used: 50,
+ free: 50,
+ usage: 0.5,
+ }));
+ render(() => );
+ const mounts = screen.getByTestId('disks-card-mounts');
+ expect(mounts.children).toHaveLength(count);
+ expect(mounts.className).not.toMatch(/max-h-|overflow-|custom-scrollbar/);
+ expect(screen.getByTitle(`/mnt/disk-${count - 1}`)).toBeInTheDocument();
+ });
+
it('renders nothing when no disks are available', () => {
const { container } = render(() => );
diff --git a/internal/api/resourceapi/resources_registry_propagation_test.go b/internal/api/resourceapi/resources_registry_propagation_test.go
new file mode 100644
index 000000000..d5bb70d66
--- /dev/null
+++ b/internal/api/resourceapi/resources_registry_propagation_test.go
@@ -0,0 +1,66 @@
+package resourceapi
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+ "github.com/rcourtman/pulse-go-rewrite/internal/models"
+ unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
+)
+
+// This is the final leg of collector -> ApplyDockerReport -> /api/resources.
+func TestResourcesRegistryResponseQualification(t *testing.T) {
+ path := os.Getenv("PULSE_REGISTRY_SNAPSHOT_PROOF")
+ if path == "" {
+ t.Skip("set PULSE_REGISTRY_SNAPSHOT_PROOF to ingestion qualification output")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var snapshots []models.StateSnapshot
+ if err := json.Unmarshal(data, &snapshots); err != nil {
+ t.Fatal(err)
+ }
+ if len(snapshots) != 2 {
+ t.Fatal("expected failure and recovery snapshots")
+ }
+ for phase, snapshot := range snapshots {
+ registry := unified.NewRegistry(nil)
+ registry.IngestSnapshot(snapshot)
+ h := NewQueryService(&config.Config{DataPath: t.TempDir()})
+ h.SetStateProvider(resourceUnifiedSeedProvider{snapshot: snapshot, resources: registry.ListByType(unified.ResourceTypeAppContainer)})
+ rec := httptest.NewRecorder()
+ h.HandleListResources(rec, httptest.NewRequest(http.MethodGet, "/api/resources?type=app-container&limit=100", nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
+ }
+ var response ResourcesResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
+ t.Fatal(err)
+ }
+ if len(response.Data) != 4 {
+ t.Fatalf("expected four resources: %s", rec.Body.String())
+ }
+ for _, r := range response.Data {
+ var want *models.DockerContainerUpdateStatus
+ for _, c := range snapshot.DockerHosts[0].Containers {
+ if c.Name == r.Name {
+ want = c.UpdateStatus
+ }
+ }
+ if want == nil || r.Docker == nil || r.Docker.UpdateStatus == nil {
+ t.Fatalf("lost identity/status: %+v", r)
+ }
+ got := r.Docker.UpdateStatus
+ if got.CurrentDigest != want.CurrentDigest || got.LatestDigest != want.LatestDigest || got.Error != want.Error || got.UpdateAvailable != want.UpdateAvailable {
+ t.Fatalf("projection changed status: %+v vs %+v", got, want)
+ }
+ }
+ t.Logf("phase %d endpoint=%s", phase, rec.Body.String())
+ }
+}
diff --git a/internal/dockeragent/collect_registry_response_test.go b/internal/dockeragent/collect_registry_response_test.go
new file mode 100644
index 000000000..7c8da25ad
--- /dev/null
+++ b/internal/dockeragent/collect_registry_response_test.go
@@ -0,0 +1,114 @@
+package dockeragent
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ containertypes "github.com/moby/moby/api/types/container"
+ "github.com/moby/moby/api/types/image"
+ agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
+ "github.com/rs/zerolog"
+)
+
+// Optional output feeds the ingest and API qualification probes without
+// substituting hand-written update statuses for collector output.
+func TestCollectRegistryResponseIsolation(t *testing.T) {
+ refs := []string{"redis:8-alpine", "postgres:16-alpine", "ghcr.io/searxng/searxng:latest", "ghcr.io/paperless-ngx/paperless-ngx:latest"}
+ var reports []agentsdocker.Report
+ for _, phase := range []string{"invalid", "valid"} {
+ checker := NewRegistryChecker(zerolog.Nop())
+ calls := map[string]int{}
+ checker.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == "auth.docker.io" || strings.HasSuffix(req.URL.Path, "/token") {
+ return newStringResponse(200, nil, `{"token":"synthetic"}`), nil
+ }
+ key := req.URL.Host + req.URL.Path
+ calls[key]++
+ var i int
+ for i = 0; i < len(refs); i++ {
+ reg, repo, tag := parseImageReference(refs[i])
+ if key == reg+"/v2/"+repo+"/manifests/"+tag {
+ break
+ }
+ }
+ if i == len(refs) {
+ return nil, fmt.Errorf("unexpected reference %s", key)
+ }
+ if phase == "invalid" && i < 3 {
+ if req.Method == http.MethodHead {
+ return newStringResponse(200, nil, ""), nil
+ }
+ return newStringResponse(200, nil, `registry unavailable`), nil
+ }
+ if req.Method == http.MethodHead {
+ return newStringResponse(200, map[string]string{"Content-Type": "application/vnd.oci.image.index.v1+json", "Docker-Content-Digest": fmt.Sprintf("sha256:index-%d", i)}, ""), nil
+ }
+ return newStringResponse(200, nil, fmt.Sprintf(`{"schemaVersion":2,"manifests":[{"digest":"sha256:platform-%d","platform":{"architecture":"amd64","os":"linux"}},{"digest":"sha256:arm-%d","platform":{"architecture":"arm64","os":"linux"}}]}`, i, i)), nil
+ })}
+ report := agentsdocker.Report{Timestamp: time.Now().UTC(), Agent: agentsdocker.AgentInfo{ID: "registry-proof", IntervalSeconds: 30}, Host: agentsdocker.HostInfo{Hostname: "registry-proof"}}
+ for i, ref := range refs {
+ inspect := baseInspect()
+ inspect.Config.Image = ref
+ reg, repo, _ := parseImageReference(ref)
+ current := fmt.Sprintf("sha256:index-%d", i)
+ if i == 2 {
+ current = fmt.Sprintf("sha256:platform-%d", i)
+ }
+ a := &Agent{logger: zerolog.Nop(), runtime: RuntimeDocker, prevContainerCPU: make(map[string]cpuSample), registryChecker: checker, docker: &fakeDockerClient{
+ containerInspectWithRawFn: func(context.Context, string, bool) (containertypes.InspectResponse, []byte, error) {
+ return inspect, nil, nil
+ },
+ containerStatsOneShotFn: func(context.Context, string) (dockerStatsResponseReader, error) {
+ return statsReader(t, containertypes.StatsResponse{}), nil
+ },
+ imageInspectWithRawFn: func(context.Context, string) (image.InspectResponse, []byte, error) {
+ return image.InspectResponse{RepoDigests: []string{reg + "/" + repo + "@" + current}, Architecture: "amd64", Os: "linux"}, nil, nil
+ },
+ }}
+ for attempt := 0; attempt < 2; attempt++ {
+ got, err := a.collectContainer(context.Background(), containertypes.Summary{ID: fmt.Sprintf("%064x", i+1), Names: []string{fmt.Sprintf("/container-%d", i)}, Image: ref, ImageID: fmt.Sprintf("image-%d", i), State: "running"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := got.UpdateStatus
+ if s == nil {
+ t.Fatal("missing status")
+ }
+ if s.CurrentDigest != current || s.UpdateAvailable {
+ t.Errorf("%s %s attempt %d: %+v", phase, ref, attempt, s)
+ }
+ if phase == "invalid" && i < 3 {
+ if s.Error == "" || s.LatestDigest != "" {
+ t.Errorf("non-manifest became update: %+v", s)
+ }
+ } else if s.Error != "" || s.LatestDigest != fmt.Sprintf("sha256:index-%d", i) {
+ t.Errorf("reference/platform mismatch: %+v", s)
+ }
+ if attempt == 1 {
+ report.Containers = append(report.Containers, got)
+ }
+ }
+ }
+ for key, n := range calls {
+ if n != 2 {
+ t.Errorf("%s requests=%d, expected one HEAD/GET with cache reuse", key, n)
+ }
+ }
+ reports = append(reports, report)
+ }
+ data, err := json.MarshalIndent(reports, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if path := os.Getenv("PULSE_REGISTRY_REPORT_PROOF"); path != "" {
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/dockeragent/registry.go b/internal/dockeragent/registry.go
index d997ff56f..6b800a53d 100644
--- a/internal/dockeragent/registry.go
+++ b/internal/dockeragent/registry.go
@@ -433,6 +433,24 @@ func (r *RegistryChecker) fetchManifest(ctx context.Context, manifestURL, author
if err != nil {
return "", "", nil, fmt.Errorf("read manifest body: %w", err)
}
+ if len(body) == 0 {
+ return "", "", nil, fmt.Errorf("no digest in response")
+ }
+
+ // Only hash or trust digest metadata for an actual image manifest. A
+ // proxy/login/error page with HTTP 200 otherwise becomes a plausible but
+ // unrelated sha256 value, shared by every reference receiving that page.
+ var manifest struct {
+ SchemaVersion int `json:"schemaVersion"`
+ Config struct {
+ Digest string `json:"digest"`
+ } `json:"config"`
+ Manifests []json.RawMessage `json:"manifests"`
+ }
+ if err := json.Unmarshal(body, &manifest); err != nil || manifest.SchemaVersion != 2 ||
+ (manifest.Config.Digest == "" && manifest.Manifests == nil) {
+ return "", "", nil, fmt.Errorf("registry returned a non-manifest response")
+ }
digest := strings.Trim(resp.Header.Get("Docker-Content-Digest"), `"`)
if digest == "" {
digest = strings.Trim(resp.Header.Get("Etag"), `"`)
diff --git a/internal/dockeragent/registry_coverage_test.go b/internal/dockeragent/registry_coverage_test.go
index 4fcd7b946..362810ced 100644
--- a/internal/dockeragent/registry_coverage_test.go
+++ b/internal/dockeragent/registry_coverage_test.go
@@ -382,7 +382,7 @@ func TestRegistryChecker_FetchDigest_DigestHeaders(t *testing.T) {
})
t.Run("GET fallback resolves manifest list", func(t *testing.T) {
- manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
+ manifestBody := `{"schemaVersion":2,"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
checker := &RegistryChecker{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -407,7 +407,7 @@ func TestRegistryChecker_FetchDigest_DigestHeaders(t *testing.T) {
})
t.Run("GET fallback preserves index digest when HEAD identifies a manifest list", func(t *testing.T) {
- manifestBody := `{"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
+ manifestBody := `{"schemaVersion":2,"manifests":[{"digest":"sha256:amd64","platform":{"architecture":"amd64","os":"linux"}}]}`
var methods []string
checker := &RegistryChecker{
httpClient: &http.Client{
diff --git a/internal/dockeragent/registry_manifest_test.go b/internal/dockeragent/registry_manifest_test.go
index 49224a3cb..79379826e 100644
--- a/internal/dockeragent/registry_manifest_test.go
+++ b/internal/dockeragent/registry_manifest_test.go
@@ -15,6 +15,7 @@ func TestRegistryChecker_ResolveManifestList(t *testing.T) {
logger := zerolog.Nop()
t.Run("resolve manifest list", func(t *testing.T) {
manifestListBody := `{
+ "schemaVersion": 2,
"manifests": [
{
"digest": "sha256:armv7",
diff --git a/internal/dockeragent/registry_response_validation_test.go b/internal/dockeragent/registry_response_validation_test.go
new file mode 100644
index 000000000..86488e6e9
--- /dev/null
+++ b/internal/dockeragent/registry_response_validation_test.go
@@ -0,0 +1,37 @@
+package dockeragent
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/rs/zerolog"
+)
+
+// A successful HTTP status is not proof that a fallback body is a manifest:
+// proxies and registry frontends can return the same HTML/JSON error for unrelated tags.
+func TestRegistryCheckerRejectsNonManifestFallback(t *testing.T) {
+ for _, body := range []string{`registry unavailable`, `{"errors":[{"code":"UNAVAILABLE"}]}`, `{}`, `null`, `{"schemaVersion":2}`} {
+ t.Run(body, func(t *testing.T) {
+ checker := NewRegistryChecker(zerolog.Nop())
+ checker.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == "auth.docker.io" || strings.HasSuffix(req.URL.Path, "/token") {
+ return newStringResponse(200, nil, `{"token":"synthetic"}`), nil
+ }
+ if req.Method == http.MethodHead {
+ return newStringResponse(200, nil, ""), nil
+ }
+ return newStringResponse(200, nil, body), nil
+ })}
+ for _, ref := range []string{"redis:8-alpine", "postgres:16-alpine", "ghcr.io/searxng/searxng:latest"} {
+ for attempt := 0; attempt < 2; attempt++ {
+ got := checker.CheckImageUpdate(context.Background(), ref, "sha256:current", "amd64", "linux", "")
+ if got.Error == "" || got.LatestDigest != "" || got.UpdateAvailable {
+ t.Errorf("%s attempt %d accepted non-manifest response: %+v", ref, attempt, got)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/internal/monitoring/docker_registry_propagation_test.go b/internal/monitoring/docker_registry_propagation_test.go
new file mode 100644
index 000000000..002895326
--- /dev/null
+++ b/internal/monitoring/docker_registry_propagation_test.go
@@ -0,0 +1,61 @@
+package monitoring
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/mock"
+ "github.com/rcourtman/pulse-go-rewrite/internal/models"
+ agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
+)
+
+// Consumes the real collector's synthetic registry response qualification output.
+func TestDockerRegistryReportQualification(t *testing.T) {
+ path := os.Getenv("PULSE_REGISTRY_REPORT_PROOF")
+ if path == "" {
+ t.Skip("set PULSE_REGISTRY_REPORT_PROOF to collector qualification output")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var reports []agentsdocker.Report
+ if err := json.Unmarshal(data, &reports); err != nil {
+ t.Fatal(err)
+ }
+ if len(reports) != 2 {
+ t.Fatal("expected failure and recovery reports")
+ }
+ previous := mock.IsMockEnabled()
+ mustSetMockEnabled(t, false)
+ t.Cleanup(func() { mustSetMockEnabled(t, previous) })
+ m := newTestMonitor(t)
+ var snapshots []models.StateSnapshot
+ for _, report := range reports {
+ host, err := m.ApplyDockerReport(report, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(host.Containers) != len(report.Containers) {
+ t.Fatal("container inventory changed")
+ }
+ for i, c := range host.Containers {
+ want := report.Containers[i].UpdateStatus
+ got := c.UpdateStatus
+ if got == nil || got.CurrentDigest != want.CurrentDigest || got.LatestDigest != want.LatestDigest || got.Error != want.Error || got.UpdateAvailable != want.UpdateAvailable {
+ t.Fatalf("ingest altered status: %+v vs %+v", got, want)
+ }
+ }
+ snapshots = append(snapshots, models.StateSnapshot{DockerHosts: []models.DockerHost{host}, LastUpdate: report.Timestamp})
+ }
+ if path := os.Getenv("PULSE_REGISTRY_SNAPSHOT_PROOF"); path != "" {
+ data, err := json.MarshalIndent(snapshots, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go
index f05c9c053..3f304eab9 100644
--- a/internal/monitoring/monitor.go
+++ b/internal/monitoring/monitor.go
@@ -735,8 +735,14 @@ func physicalDiskFromReadStateView(view *unifiedresources.PhysicalDiskView) mode
return models.PhysicalDisk{Wearout: unifiedresources.WearoutUnreported}
}
- return models.PhysicalDisk{
- ID: view.ID(),
+ // Preserve the provider key across the canonical read-state round trip.
+ // Older/synthetic views without source metadata retain their existing fallback.
+ sourceID := view.SourceID()
+ if sourceID == "" {
+ sourceID = view.ID()
+ }
+ disk := models.PhysicalDisk{
+ ID: sourceID,
Node: view.Node(),
Instance: view.Instance(),
DevPath: view.DevPath(),
@@ -758,6 +764,8 @@ func physicalDiskFromReadStateView(view *unifiedresources.PhysicalDiskView) mode
Collection: diskinventory.CloneStatus(view.Collection()),
LastChecked: view.LastSeen(),
}
+ return disk
+
}
func physicalDiskIOFromUnifiedMeta(in *unifiedresources.PhysicalDiskIOMeta) *models.DiskIO {
diff --git a/internal/monitoring/physical_disk_roundtrip_test.go b/internal/monitoring/physical_disk_roundtrip_test.go
new file mode 100644
index 000000000..39ef3993a
--- /dev/null
+++ b/internal/monitoring/physical_disk_roundtrip_test.go
@@ -0,0 +1,121 @@
+package monitoring
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/rcourtman/pulse-go-rewrite/internal/config"
+ "github.com/rcourtman/pulse-go-rewrite/internal/models"
+ "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
+)
+
+// Exercise the real skipped-poll path, not just the ID helper: canonical views
+// are converted back into source state and then re-ingested by the adapter.
+func TestPhysicalDiskSkippedPollPreservesSourceIdentity(t *testing.T) {
+ for _, device := range []string{"/dev/sdx", "sdx"} {
+ t.Run(device, func(t *testing.T) {
+ state := models.NewState()
+ nodes := []models.Node{{ID: "pve-node1", Name: "node1", Instance: "pve"}, {ID: "pve-node2", Name: "node2", Instance: "pve"}}
+ state.UpdateNodesForInstance("pve", nodes)
+ var disks []models.PhysicalDisk
+ for _, fixture := range []struct{ node, target string }{{"node1", ""}, {"node2", ""}, {"node1", "megaraid,0"}, {"node1", "megaraid,1"}} {
+ disks = append(disks, models.PhysicalDisk{
+ ID: unifiedresources.ProxmoxPhysicalDiskSourceID("pve", fixture.node, device, "", fixture.target),
+ Instance: "pve", Node: fixture.node, DevPath: device, Target: fixture.target,
+ Model: "USB fixture", Size: 1024, Temperature: 37, Wearout: -1, LastChecked: time.Now(),
+ })
+ }
+ state.UpdatePhysicalDisks("pve", disks)
+ adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
+ adapter.PopulateFromSnapshot(state.GetSnapshot())
+ m := &Monitor{state: state, resourceStore: adapter, lastPhysicalDiskPoll: map[string]time.Time{"pve": time.Now()}}
+ canonical := map[string]bool{}
+ for _, view := range adapter.PhysicalDisks() {
+ canonical[view.ID()] = true
+ }
+ if len(canonical) != len(disks) {
+ t.Fatalf("initial disks = %d, want %d", len(canonical), len(disks))
+ }
+ for cycle := 0; cycle < 8; cycle++ {
+ m.maybePollPhysicalDisksAsync(context.Background(), "pve", &config.PVEInstance{}, nil, nil, nil, nil)
+ got := state.GetSnapshot().PhysicalDisks
+ if len(got) != len(disks) {
+ t.Fatalf("cycle %d: source count %d", cycle, len(got))
+ }
+ wantIDs := map[string]bool{}
+ for _, disk := range disks {
+ wantIDs[disk.ID] = true
+ }
+ for _, disk := range got {
+ if !wantIDs[disk.ID] {
+ t.Fatalf("cycle %d: canonical ID leaked into provider state: %q", cycle, disk.ID)
+ }
+ if disk.DevPath != device || disk.Temperature != 37 || disk.Size != 1024 {
+ t.Fatalf("cycle %d: metadata lost: %+v", cycle, disk)
+ }
+ }
+ adapter.PopulateFromSnapshot(state.GetSnapshot())
+ views := adapter.PhysicalDisks()
+ if len(views) != len(disks) {
+ t.Fatalf("cycle %d: view count = %d", cycle, len(views))
+ }
+ for _, view := range views {
+ if !canonical[view.ID()] {
+ t.Fatalf("cycle %d: canonical identity churn: %s", cycle, view.ID())
+ }
+ }
+ // Retain the JSON-visible projection as well as the typed read-state checks.
+ payload, err := json.Marshal(adapter.GetAll())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var resources []unifiedresources.Resource
+ if err := json.Unmarshal(payload, &resources); err != nil {
+ t.Fatal(err)
+ }
+ count := 0
+ for _, resource := range resources {
+ if resource.Type != unifiedresources.ResourceTypePhysicalDisk {
+ continue
+ }
+ count++
+ if !canonical[resource.ID] || resource.Proxmox == nil || !wantIDs[resource.Proxmox.SourceID] || resource.PhysicalDisk == nil || resource.PhysicalDisk.Temperature != 37 {
+ t.Fatalf("cycle %d: JSON disk identity/metadata lost: %+v", cycle, resource)
+ }
+ }
+ if count != len(disks) {
+ t.Fatalf("cycle %d: JSON disk count %d", cycle, count)
+ }
+ }
+ // A confirmed full inventory removal must still remove source-owned disks.
+ state.UpdatePhysicalDisks("pve", nil)
+ adapter.PopulateFromSnapshot(state.GetSnapshot())
+ if got := len(adapter.PhysicalDisks()); got != 0 {
+ t.Fatalf("removed inventory retained %d disks", got)
+ }
+ })
+ }
+}
+
+func TestPhysicalDiskReadbackSourceIDFallback(t *testing.T) {
+ for _, resource := range []unifiedresources.Resource{
+ {ID: "canonical"},
+ {ID: "canonical", Proxmox: &unifiedresources.ProxmoxData{}},
+ {ID: "canonical", Proxmox: &unifiedresources.ProxmoxData{SourceID: " native "}},
+ } {
+ view := unifiedresources.NewPhysicalDiskView(&resource)
+ want := "canonical"
+ if resource.Proxmox != nil && resource.Proxmox.SourceID != "" {
+ want = "native"
+ }
+ if got := physicalDiskFromReadStateView(&view).ID; got != want {
+ t.Fatalf("ID = %q, want %q", got, want)
+ }
+ }
+ var view unifiedresources.PhysicalDiskView
+ if view.SourceID() != "" {
+ t.Fatal("nil resource has a source ID")
+ }
+}
diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go
index 7ae22239b..2ad14c902 100644
--- a/internal/unifiedresources/views.go
+++ b/internal/unifiedresources/views.go
@@ -2101,6 +2101,16 @@ func (v PhysicalDiskView) ID() string {
return v.r.ID
}
+// SourceID returns the Proxmox-native inventory key, not the canonical
+// resource ID. Polling adapters must preserve this key when writing views back
+// into provider state; hashing a canonical ID again creates a new resource.
+func (v PhysicalDiskView) SourceID() string {
+ if v.r == nil || v.r.Proxmox == nil {
+ return ""
+ }
+ return strings.TrimSpace(v.r.Proxmox.SourceID)
+}
+
func (v PhysicalDiskView) Name() string {
if v.r == nil {
return ""
diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py
index 9336367df..349e6cd4a 100644
--- a/scripts/release_control/canonical_completion_guard_test.py
+++ b/scripts/release_control/canonical_completion_guard_test.py
@@ -226,6 +226,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/monitoring/availability_probe_agent_test.go",
"internal/monitoring/monitor_host_agent_removal_lifecycle_test.go",
"internal/monitoring/monitor_host_agents_test.go",
+ "internal/monitoring/physical_disk_roundtrip_test.go",
"scripts/installtests/agent_state_dir_lifecycle_test.go",
"scripts/installtests/install_ps1_test.go",
"scripts/installtests/install_sh_test.go",
@@ -316,6 +317,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/monitoring/monitor_mock_alerts_test.go",
"internal/monitoring/monitor_pve_cluster_refresh_test.go",
"internal/monitoring/monitor_pve_guest_lxc_test.go",
+ "internal/monitoring/physical_disk_roundtrip_test.go",
"internal/monitoring/proxmox_large_cluster_poll_budget_test.go",
"internal/monitoring/pve_protection_observation_test.go",
"internal/monitoring/ratetracker_test.go",
@@ -452,6 +454,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase):
"internal/monitoring/availability_probe_agent_test.go",
"internal/monitoring/monitor_host_agent_removal_lifecycle_test.go",
"internal/monitoring/monitor_host_agents_test.go",
+ "internal/monitoring/physical_disk_roundtrip_test.go",
"scripts/installtests/agent_state_dir_lifecycle_test.go",
"scripts/installtests/install_ps1_test.go",
"scripts/installtests/install_sh_test.go",
diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py
index 8119a4eec..16c062b42 100644
--- a/scripts/release_control/subsystem_lookup_test.py
+++ b/scripts/release_control/subsystem_lookup_test.py
@@ -4315,6 +4315,7 @@ class SubsystemLookupTest(unittest.TestCase):
"internal/monitoring/availability_probe_agent_test.go",
"internal/monitoring/monitor_host_agent_removal_lifecycle_test.go",
"internal/monitoring/monitor_host_agents_test.go",
+ "internal/monitoring/physical_disk_roundtrip_test.go",
"scripts/installtests/agent_state_dir_lifecycle_test.go",
"scripts/installtests/install_ps1_test.go",
"scripts/installtests/install_sh_test.go",
diff --git a/tests/qualification/disk-mounts/README.md b/tests/qualification/disk-mounts/README.md
new file mode 100644
index 000000000..b7a1fa23c
--- /dev/null
+++ b/tests/qualification/disk-mounts/README.md
@@ -0,0 +1,49 @@
+# Disk mount visibility (#2051)
+
+The Disks card previously clipped its mount list to 140px. Its thin nested
+scrollbar was the only indication of additional mounts. The repair keeps all
+mounts in the outer scrolling flow; aggregate usage and individual mount data
+are unchanged. Large lists make the card taller rather than introducing another
+scroll target. This deliberately avoids a global scrollbar-style change.
+
+## Reproduce
+
+Install locked root and frontend-modern npm dependencies and pinned Playwright
+Firefox/Chromium browsers, then from the repository root:
+
+```sh
+pulse-heavy-run -- node tests/qualification/disk-mounts/check.mjs
+```
+
+The isolated Vite fixture imports the production card and stylesheet. It uses
+synthetic mount data, no backend, credentials or customer installation. It checks
+2 and 24 mounts in light/dark themes, list clipping and keyboard End/Tab access
+to the final mount and following control. Vite is shut down after the run.
+The qualification HTML is not an application entry or production build input.
+
+## Evidence — 11 September 2026
+
+Baseline 72809bc1cf71, Firefox 142.0.1: short list 54/54px; long list
+clientHeight=140, scrollHeight=736, overflow=auto, maxHeight=140px. The no-clipping
+assertion fails. After repair all eight Firefox 142.0.1 / Chromium 141.0.7390.37
+cases pass: short 54/54px, long 736/736px, overflow=visible, maxHeight=none.
+Keyboard checks pass in each case. Component coverage additionally preserves
+aggregate usage, all mounts and the empty state.
+
+This demonstrates the nested clipping and its removal with production CSS. It
+does not establish why Firefox 140.15 ESR on the reporter's Debian desktop hides
+its native scrollbar, nor reproduce their full drawer/estate. Reporter retest
+and release availability remain distinct from this source/browser proof.
+
+The reporter's subsequent before/after-scroll crops in comment
+https://github.com/rcourtman/Pulse/issues/2051#issuecomment-5632905322 were also
+inspected: no evident pre-scroll thumb, then a narrow pale mark beside the disk
+bars after scrolling. Removing the nested overflow eliminates that card's native
+track/thumb entirely, rather than claiming to repair Firefox painting. The
+fixture proves there is no hidden card overflow before keyboard scrolling;
+outer-page navigation still reaches every mount. Exact desktop painting remains
+unreproduced.
+
+Completion verification adds 600x500 and 1200x500 viewports: all 16 cases pass.
+Full-page narrow Firefox dark and desktop Chromium light images were inspected;
+all mounts and the focused following control remain visible without nested clipping.
diff --git a/tests/qualification/disk-mounts/check.mjs b/tests/qualification/disk-mounts/check.mjs
new file mode 100644
index 000000000..d557bd5df
--- /dev/null
+++ b/tests/qualification/disk-mounts/check.mjs
@@ -0,0 +1,78 @@
+import { firefox, chromium } from "@playwright/test";
+import { createServer } from "../../../frontend-modern/node_modules/vite/dist/node/index.js";
+import assert from "node:assert/strict";
+process.chdir("frontend-modern");
+const server = await createServer({
+ root: ".",
+ configFile: "vite.config.ts",
+ server: { port: 18791 },
+});
+await server.listen();
+try {
+ for (const engine of [firefox, chromium]) {
+ const browser = await engine.launch();
+ try {
+ for (const width of [600, 1200])
+ for (const theme of ["light", "dark"])
+ for (const count of [2, 24]) {
+ const page = await browser.newPage({
+ viewport: { width, height: 500 },
+ });
+ await page.goto(
+ `http://127.0.0.1:18791/qualification/disks/?theme=${theme}&count=${count}`,
+ );
+ const last = page.getByTitle(`/mnt/disk-${count - 1}`, {
+ exact: true,
+ });
+ await last.waitFor();
+ const geometry = await last.evaluate((el) => {
+ const list = el.parentElement.parentElement.parentElement;
+ const style = getComputedStyle(list);
+ return {
+ height: list.clientHeight,
+ scrollHeight: list.scrollHeight,
+ overflow: style.overflowY,
+ maxHeight: style.maxHeight,
+ };
+ });
+ console.log(
+ JSON.stringify({
+ browser: browser.version(),
+ theme,
+ width,
+ count,
+ ...geometry,
+ }),
+ );
+ assert.equal(
+ geometry.height,
+ geometry.scrollHeight,
+ "mounts must not be clipped inside a nested scroller",
+ );
+ await page.getByRole("button", { name: "Before disks" }).focus();
+ await page.keyboard.press("End");
+ await page.waitForTimeout(300);
+ await page.keyboard.press("Tab");
+ assert.equal(
+ await page
+ .getByRole("button", { name: "After disks" })
+ .evaluate((el) => el === document.activeElement),
+ true,
+ );
+ const box = await last.boundingBox();
+ assert.ok(
+ box && box.y >= 0 && box.y + box.height <= 500,
+ "last mount reachable through outer scrolling",
+ );
+ if (process.env.DISK_SCREENSHOT_DIR && count === 24) {
+ await page.screenshot({path: `${process.env.DISK_SCREENSHOT_DIR}/${engine.name()}-${theme}-${width}.png`, fullPage:true});
+ }
+ await page.close();
+ }
+ } finally {
+ await browser.close();
+ }
+ }
+} finally {
+ await server.close();
+}
diff --git a/tests/qualification/registry-response/README.md b/tests/qualification/registry-response/README.md
new file mode 100644
index 000000000..ed58c6aef
--- /dev/null
+++ b/tests/qualification/registry-response/README.md
@@ -0,0 +1,50 @@
+# Registry fallback response qualification
+
+This synthetic proof covers the Docker agent's registry response fallback,
+container collection, report ingestion and `GET /api/resources`. It does not
+contact a registry, run Docker, update a container or establish an affected
+installation's cause. Registry fixtures use the reported references
+`redis:8-alpine`, `postgres:16-alpine`, `ghcr.io/searxng/searxng:latest` and
+`ghcr.io/paperless-ngx/paperless-ngx:latest` with synthetic digest values.
+
+The HTTP fixture first returns the same headerless HTTP 200 error page for
+three references while the fourth is healthy. Those responses must produce
+errors, no latest digest and no update indication, including cached checks.
+A fresh checker then models agent restart with valid independent indexes;
+matching index **or** platform digests must suppress updates. Exact request
+paths and one HEAD/GET pair per reference establish reference isolation and
+cache reuse. The production collector, not a hand-written status fixture,
+produces the reports consumed by the next two probes.
+
+Run from the repository root, using a fresh private temporary directory:
+
+```sh
+proof=$(mktemp -d)
+export PULSE_REGISTRY_REPORT_PROOF="$proof/reports.json"
+export PULSE_REGISTRY_SNAPSHOT_PROOF="$proof/snapshots.json"
+go test -race ./internal/dockeragent -run 'TestCollectRegistryResponseIsolation|TestRegistryChecker' -count=1
+go test -race ./internal/monitoring -run '^TestDockerRegistryReportQualification$' -count=1
+go test -race ./internal/api/resourceapi -run '^TestResourcesRegistryResponseQualification$' -count=1 -v
+```
+
+The latter two probes skip without explicit input paths; they fail for missing
+or malformed input when configured. Preserve both JSON files and command logs.
+The endpoint probe calls the resource query handler directly, not the HTTP
+authentication middleware. No credential or authorization acceptance is claimed.
+
+`fetchManifest` requires a schema-2 manifest envelope (config digest or index
+manifest array) before using a GET fallback body or its digest metadata. This
+is a minimal error-page discriminator, not full descriptor/cryptographic
+validation. See the [OCI manifest specification](https://github.com/opencontainers/image-spec/blob/147f9c13cedb47a0c4d9a11a222961073d585877/manifest.md)
+and [image index specification](https://github.com/opencontainers/image-spec/blob/147f9c13cedb47a0c4d9a11a222961073d585877/image-index.md),
+both pinned to OCI Image Specification v1.1.1.
+Existing HEAD digest handling, credentials, TLS, cache intervals and HTTP
+error handling remain unchanged. A failed check is not proof of an up-to-date
+image; consumers must retain the error field. No new UI state is introduced.
+
+Before attributing an incident to this mechanism, obtain safe response evidence
+from the affected agent's network path: status, content type, digest headers,
+and locally computed body hash, excluding authorization headers, cookies and
+private response content. A matching synthetic symptom alone does not prove
+that the reporter received the same response. Installed acceptance requires an
+updated agent and verified error/recovery status on the same workload.