From 4c9bb5f198aba676debee55c6efc8b4b00e5fc06 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 15 Oct 2026 21:28:05 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.154.0:=20the=20host=20page=20shows=20e?= =?UTF-8?q?ach=20storage=20place's=20physical=20disk(s)=20and=20whether=20?= =?UTF-8?q?the=20agent=20proved=20them=20(16=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb --- hub/CHANGELOG.md | 10 +++++ hub/internal/api/handler.go | 8 +++- .../api/testdata/host-report.golden.json | 7 ++++ hub/internal/web/hosts.go | 27 ++++++++---- hub/internal/web/hosts_identity_test.go | 41 +++++++++++++++++++ .../web/templates/host_detail_body.html | 4 ++ 6 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 hub/internal/web/hosts_identity_test.go diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index d70a0005..d5910db0 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,13 @@ +## v0.154.0 — the host page shows which physical disk each storage place sits on (2026-10-15) + +Storage identity build A (`documentation/architecture/16-storage-physical-identity.md` §3; agent v0.157.0). + +- The host report's `storage_targets` carry `kind`, `physical_disks`, `identity`, `identity_reason` (agent-computed). The hub's + contract mirror and its copy of `host-report.golden.json` match the agent's byte for byte; the key-set test was seen failing + without the mirror fields. +- The host page's Storage Targets table gains two columns: the physical disk key(s), and „proven" / „unknown (reason)" · kind. + An older agent shows „—". Shown only — no alarm logic (build A). + ## v0.153.0 — no token sends, mints or reveals a household credential or link (R-1055) (2026-10-15) Operator ruling 2026-10-15 13:20 (`09` §3 decision 252, R-1055 = A). `documentation/architecture/05-hub-architecture.md` §17. diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index dc2a08ed..aea215fe 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -847,7 +847,13 @@ type hostStorageTarget struct { BackingDevice string `json:"backing_device"` ClassHint string `json:"class_hint"` Role string `json:"role"` - ThinPool *struct { + // Physical-disk identity (agent v0.157.0, felhom.eu architecture/16-storage-physical-identity.md §3): + // agent-computed; the hub stores it with the report and shows it on the host page. No alarm (build A). + Kind string `json:"kind,omitempty"` + PhysicalDisks []string `json:"physical_disks,omitempty"` + Identity string `json:"identity,omitempty"` + IdentityReason string `json:"identity_reason,omitempty"` + ThinPool *struct { DataUsedFraction float64 `json:"data_used_fraction"` MetadataUsedFraction *float64 `json:"metadata_used_fraction"` } `json:"thin_pool,omitempty"` diff --git a/hub/internal/api/testdata/host-report.golden.json b/hub/internal/api/testdata/host-report.golden.json index 3d6a2d9b..d9c30ed7 100644 --- a/hub/internal/api/testdata/host-report.golden.json +++ b/hub/internal/api/testdata/host-report.golden.json @@ -46,6 +46,9 @@ "backing_device": "", "class_hint": "fast", "role": "", + "kind": "system", + "physical_disks": ["serial:ata:SanDisk_X600:182195804614"], + "identity": "proven", "thin_pool": { "data_used_fraction": 0.42, "metadata_used_fraction": null }, "smart": { "health": "UNKNOWN", @@ -74,6 +77,10 @@ "backing_device": "/dev/sdb1", "class_hint": "slow", "role": "", + "kind": "disk", + "physical_disks": ["serial:usb:External:000000000000"], + "identity": "unknown", + "identity_reason": "usb_bridge", "smart": { "health": "UNKNOWN", "temperature_c": null, diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 921fc186..a1d51482 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -480,6 +480,12 @@ type storageTargetView struct { SmartHealth string TempC *int WearPct *int // NVMe percentage_used + // Physical-disk identity (agent v0.157.0, `16` §3): the disk keys under the place and whether the agent + // can prove them. Empty from an older agent. + Kind string + PhysicalDisks []string + Identity string + IdentityReason string } // parseHostStorageTargets extracts the rich storage-target rows from a report body. A @@ -491,14 +497,18 @@ func parseHostStorageTargets(reportJSON string) []storageTargetView { } var body struct { StorageTargets []struct { - Name string `json:"name"` - Type string `json:"type"` - Role string `json:"role"` - State string `json:"state"` - MountPath string `json:"mount_path"` - Reachable bool `json:"reachable"` - UsedFraction float64 `json:"used_fraction"` - ThinPool *struct { + Name string `json:"name"` + Type string `json:"type"` + Role string `json:"role"` + State string `json:"state"` + MountPath string `json:"mount_path"` + Reachable bool `json:"reachable"` + UsedFraction float64 `json:"used_fraction"` + Kind string `json:"kind"` + PhysicalDisks []string `json:"physical_disks"` + Identity string `json:"identity"` + IdentityReason string `json:"identity_reason"` + ThinPool *struct { DataUsedFraction float64 `json:"data_used_fraction"` } `json:"thin_pool"` Smart struct { @@ -517,6 +527,7 @@ func parseHostStorageTargets(reportJSON string) []storageTargetView { MountPath: t.MountPath, Reachable: t.Reachable, FillPct: t.UsedFraction * 100, SmartHealth: t.Smart.Health, TempC: t.Smart.TemperatureC, WearPct: t.Smart.PercentageUsed, + Kind: t.Kind, PhysicalDisks: t.PhysicalDisks, Identity: t.Identity, IdentityReason: t.IdentityReason, } if t.ThinPool != nil { v.HasThin = true diff --git a/hub/internal/web/hosts_identity_test.go b/hub/internal/web/hosts_identity_test.go new file mode 100644 index 00000000..5d200f09 --- /dev/null +++ b/hub/internal/web/hosts_identity_test.go @@ -0,0 +1,41 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// felhom.eu architecture/16-storage-physical-identity.md §3 (agent v0.157.0): the host page shows, per +// storage place, the physical disk keys and whether the agent proved them. Shown only — no alarm (build A). +func TestHostDetail_ShowsPhysicalDiskIdentity(t *testing.T) { + s, st := newTestServer(t) + if err := st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k"}); err != nil { + t.Fatal(err) + } + report := `{"host_id":"h1","agent_version":"0.157.0","storage_targets":[` + + `{"name":"local-lvm","type":"lvmthin","state":"attached","kind":"system","physical_disks":["serial:ata:SanDisk_X600:182195804614"],"identity":"proven","smart":{"health":"UNKNOWN"}},` + + `{"name":"felhom-usb","type":"usb","state":"attached","kind":"disk","physical_disks":["serial:scsi:QEMU_HARDDISK:drive-scsi1"],"identity":"unknown","identity_reason":"vm","smart":{"health":"UNKNOWN"}},` + + `{"name":"old-agent","type":"local","state":"attached","smart":{"health":"UNKNOWN"}}]}` + if err := st.SaveHostReport("h1", "c1", []byte(report), store.HostReportDenorm{AgentVersion: "0.157.0"}); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/h1", nil), "h1") + if rr.Code != http.StatusOK { + t.Fatalf("status = %d", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{"Physical disk", "serial:ata:SanDisk_X600:182195804614", ">proven<", "unknown (vm)", "· system", "· disk"} { + if !strings.Contains(body, want) { + t.Errorf("host page missing %q", want) + } + } + v := parseHostStorageTargets(report) + if len(v) != 3 || v[0].Identity != "proven" || v[1].IdentityReason != "vm" || v[2].Identity != "" { + t.Errorf("parsed identity rows wrong: %+v", v) + } +} diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index aba73cf2..16eb6c73 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -162,6 +162,8 @@ SMART Temp Wear + Physical disk + Identity @@ -176,6 +178,8 @@ {{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}{{.SmartHealth}}{{else if eq .SmartHealth "FAILED"}}{{.SmartHealth}}{{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}} {{if .TempC}}{{.TempC}}°C{{else}}—{{end}} {{if .WearPct}}{{.WearPct}}%{{else}}—{{end}} + {{if .PhysicalDisks}}{{range $i, $d := .PhysicalDisks}}{{if $i}}
{{end}}{{$d}}{{end}}{{else}}—{{end}} + {{if eq .Identity "proven"}}proven{{else if .Identity}}unknown{{if .IdentityReason}} ({{.IdentityReason}}){{end}}{{else}}—{{end}}{{if .Kind}} · {{.Kind}}{{end}} {{end}}