fix: preserve disk identity and reject invalid registry responses

Carry verified maintenance repairs onto the published 6.4 line: preserve provider disk keys across read-state round trips, reject non-manifest registry bodies, and keep mount lists in parent scroll flow. Add regression coverage and qualification fixtures. Recover the unpublished range without changing runtime source; bind existing mount browser evidence to the correct published base. Preserve prior source and proof records.

Contract-Neutral: Workflow-only PR concurrency changes preserve all jobs, triggers, permissions and frontend dependency security checks. Scheduling contract updated; no dependency security verification change is warranted.
Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-09-15 01:16:35 +01:00
parent ea4c4a7147
commit 8509cf5d72
30 changed files with 744 additions and 32 deletions

View file

@ -23,7 +23,7 @@ permissions:
# E2E workflow's concurrency policy.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
secret-scan:

View file

@ -31,7 +31,7 @@ on:
# collapse to the newest pending one, so intermediate pushes skip.
concurrency:
group: e2e-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read

View file

@ -10042,7 +10042,21 @@
]
}
],
"work_claims": [],
"work_claims": [
{
"id": "pulse-maintainer-lane-l8",
"agent_id": "pulse-maintainer",
"summary": "Verify and backport mount-list overflow repair for issue2051",
"target_id": "v6-product-lane-expansion",
"claimed_at": "2026-09-14T23:28:45Z",
"heartbeat_at": "2026-09-14T23:28:45Z",
"expires_at": "2026-09-15T01:28:45Z",
"work_item": {
"kind": "lane",
"id": "L8"
}
}
],
"open_decisions": [],
"source_of_truth_file": "docs/release-control/v6/internal/SOURCE_OF_TRUTH.md",
"resolved_decisions": [

View file

@ -15,6 +15,15 @@
## Purpose
### Physical disk skipped-poll identity
Read-state round trips preserve the provider SourceID rather than rehashing a
canonical ID. Older views without source metadata retain their ID fallback.
Preserve disk metadata supported by this release model. Repeated-cycle
regressions cover serial-less disks, node/controller separation, JSON identity,
metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076
without main-only interval fields or unrelated main metrics changes.
### Host-local network evidence exclusion
Automatic PVE association must not treat loopback, unspecified, multicast or

View file

@ -15,6 +15,13 @@
## Purpose
### Superseded pull-request validation
Build and Test and Core E2E cancel prior validation only for pull_request events.
Branch pushes and manual runs retain their verdicts. This backports reviewed
main092e98823903aa90149268d8f851aad46ea72060; triggers, jobs, permissions and
concurrency group identities are unchanged, verified by semantic YAML comparison.
### Immutable release source
The committed release-note visual plan must pass the same validator as the

View file

@ -20,6 +20,8 @@
## Purpose
Mount lists stay in the parent scroll flow so overlay scrollbars cannot hide additional mounts. The maintenance backport preserves the reviewed DisksCard layout repair; two and twenty-four mount cases retain keyboard access to the final mount.
Overview delivery diagnoses use latest-started refresh ownership. Older bulk
responses cannot overwrite newer card notification status, and an empty active
alert set invalidates outstanding reads. Disposal also prevents updates. Failed

View file

@ -17,6 +17,15 @@
## Purpose
### Physical disk skipped-poll identity
Read-state round trips preserve the provider SourceID rather than rehashing a
canonical ID. Older views without source metadata retain their ID fallback.
Preserve disk metadata supported by this release model. Repeated-cycle
regressions cover serial-less disks, node/controller separation, JSON identity,
metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076
without main-only interval fields or unrelated main metrics changes.
**Availability backfill preserves concurrent discovery changes (7 September 2026)**
The backfill List snapshot is a work list, not an authoritative record to save.

View file

@ -1612,6 +1612,7 @@
"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"
@ -6333,6 +6334,7 @@
"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",
@ -8046,6 +8048,7 @@
"exact_files": [
"frontend-modern/src/stores/__tests__/websocket-unified.test.ts",
"internal/monitoring/issue1595_collection_trust_test.go",
"internal/monitoring/physical_disk_roundtrip_test.go",
"internal/unifiedresources/adapter_coverage_test.go",
"internal/unifiedresources/adapters_test.go",
"internal/unifiedresources/ceph_pool_health_contract_test.go",

View file

@ -15,6 +15,15 @@
## Purpose
### Physical disk skipped-poll identity
Read-state round trips preserve the provider SourceID rather than rehashing a
canonical ID. Older views without source metadata retain their ID fallback.
Preserve disk metadata supported by this release model. Repeated-cycle
regressions cover serial-less disks, node/controller separation, JSON identity,
metadata and confirmed removal. This carries main01742e2279/c9e71eac86 for #2076
without main-only interval fields or unrelated main metrics changes.
TrueNAS EMERGENCY evidence retains canonical critical severity and the existing resource and incident identity. Repeated EMERGENCY observations remain actionable through the alerts consumer and interrupt pending recovery; confirmed absence, not an unmapped severity, supplies recovery evidence. Projection, dispatch and recovery-streak regression tests exercise this boundary.
ResourceIncident carries optional nativeSeverity JSON evidence independently of canonical Severity and identity. Missing nativeSeverity remains compatible with older payloads. TrueNAS INFO and NOTICE may share canonical monitor risk without becoming indistinguishable to alert consumers; native severity does not change resource or incident identity.

View file

@ -1,44 +1,33 @@
{
"version": 1,
"base_sha": "062e7c805046f8a40852ac0fd92376fa6096950d",
"verified_at": "2026-09-14T22:42:59.987155Z",
"base_sha": "ea4c4a71475629e45dd053dc024532443c293809",
"verified_at": "2026-09-14T23:30:14.962745Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/api/notifications.ts",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts"
"frontend-modern/src/components/shared/cards/DisksCard.tsx"
],
"content_sha256": {
"frontend-modern/src/api/notifications.ts": "ed4d7dd4946ed2295dff919134f394d0139d270bb446d069f4ddc8a9784b1667",
"frontend-modern/src/components/Alerts/WebhookConfigList.tsx": "0830ab5c9f7e0173dbdf3e92ed0e0c9cbf0a34942edb58001a3220d43b39d6c3",
"frontend-modern/src/components/Alerts/useWebhookConfigState.ts": "49c4aadf57899de6cc47690ecc682787d82183d76bacc8c6e250d5da39e30033"
"frontend-modern/src/components/shared/cards/DisksCard.tsx": "cdda554f93b7aecb925d803b0192260ef1a445065c501468a0412b824dc08028"
},
"routes": [
"/browser-tests/severity.html"
"/qualification/disks/"
],
"viewports": [
{
"width": 1440,
"height": 1000
"width": 600,
"height": 500
},
{
"width": 390,
"height": 844
"width": 1200,
"height": 500
}
],
"states": [
"Existing warning email and webhook",
"All, critical and warning severity saved and reloaded",
"Cancelled webhook edit",
"New warning webhook persisted"
"2 and 24 mounts, light and dark themes, in parent scroll flow"
],
"interactions": [
"Change production email severity selector, save through NotificationsAPI to synthetic backend, reload page and assert each supported value",
"Edit production webhook, save and reload each supported severity",
"Cancel changed webhook severity and verify original warning preserved",
"Create warning webhook and reload synthetic persisted store",
"Inspect desktop edit and narrow saved screenshots"
"Focus Before disks, End then Tab; final mount visible and After disks focused"
],
"command": "pulse-worker-browser severity-browser.cjs",
"notes": "Production-component fixture built with Vite esnext and project CSS. Synthetic email HTTP responses and webhook localStorage persistence; no live backend, notification delivery or whole-application navigation proof. Runtime source hashes match the verified implementation. Desktop edit and narrow saved screenshots inspected."
"command": "pulse-worker-browser mount-proof.cjs",
"notes": "Production DisksCard fixture with project CSS; eight styled Chromium cases verified at the original recorded observation time. Source content unchanged by recovery. Earlier warning-severity evidence belongs to already published PR2090 and is retained there, not claimed as a new observation here. Screenshots 600px dark with 24 mounts and 1200px light with 2 mounts inspected. Firefox and reporter attachments not verified. This is synthetic component proof, not installed reporter acceptance. Initial module-path and unstyled runs not counted."
}

View file

@ -0,0 +1,7 @@
<!doctype html>
<html>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,22 @@
import { render } from 'solid-js/web';
import { DisksCard } from '../../src/components/shared/cards/DisksCard';
import '../../src/index.css';
const params = new URLSearchParams(location.search);
document.documentElement.classList.toggle('dark', params.get('theme') === 'dark');
const disks = Array.from({ length: Number(params.get('count') ?? 24) }, (_, i) => ({
mountpoint: `/mnt/disk-${i}`,
total: 1000000000,
used: 500000000,
free: 500000000,
usage: 0.5,
}));
render(
() => (
<main class="bg-surface text-base-content p-4" style={{ width: '440px' }}>
<button>Before disks</button>
<DisksCard disks={disks} />
<button>After disks</button>
</main>
),
document.getElementById('root')!,
);

View file

@ -344,6 +344,14 @@ const frontendIndexCssSource = readFileSync(join(process.cwd(), 'src/index.css')
const readFrontendSource = (path: string) => readFileSync(join(process.cwd(), path), 'utf8');
describe('shared primitive guardrails', () => {
it('keeps disk mounts in the parent scrolling flow rather than a hidden nested list', () => {
const source = readFrontendSource('src/components/shared/cards/DisksCard.tsx');
expect(source).toContain('data-testid="disks-card-mounts"');
expect(source).not.toMatch(/max-h-|overflow-y-|custom-scrollbar/);
expect(source).toContain('<For each={props.disks}>');
expect(source).toContain('<StackedDiskBar');
});
it('keeps one canonical agent-host metric history group catalog', () => {
expect(HOST_METRICS_HISTORY_GROUPS.map((group) => group.id)).toEqual([
'utilization',

View file

@ -47,7 +47,8 @@ export const DisksCard: Component<DisksCardProps> = (props) => {
</div>
)}
</Show>
<div class="max-h-[140px] overflow-y-auto custom-scrollbar space-y-2">
{/* Keep mounts in the parent scroll flow: overlay scrollbars can hide a nested list. */}
<div class="space-y-2" data-testid="disks-card-mounts">
<For each={props.disks}>
{(disk) => {
const total = disk.total ?? 0;

View file

@ -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(() => <DisksCard disks={disks} />);
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(() => <DisksCard disks={[]} />);

View file

@ -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())
}
}

View file

@ -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, `<html>registry unavailable</html>`), 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)
}
}
}

View file

@ -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"), `"`)

View file

@ -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{

View file

@ -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",

View file

@ -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{`<html>registry unavailable</html>`, `{"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)
}
}
}
})
}
}

View file

@ -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)
}
}
}

View file

@ -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 {

View file

@ -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")
}
}

View file

@ -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 ""

View file

@ -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",

View file

@ -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",

View file

@ -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.

View file

@ -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();
}

View file

@ -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.