hub v0.154.0: the host page shows each storage place's physical disk(s) and whether the agent proved them (16 §3)
gates / gates (push) Successful in 6m43s

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-15 21:28:05 +02:00
parent 6749118cc8
commit 4c9bb5f198
6 changed files with 88 additions and 9 deletions
+10
View File
@@ -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.
+7 -1
View File
@@ -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"`
+7
View File
@@ -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,
+19 -8
View File
@@ -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
+41
View File
@@ -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)
}
}
@@ -162,6 +162,8 @@
<th>SMART</th>
<th>Temp</th>
<th>Wear</th>
<th>Physical disk</th>
<th>Identity</th>
</tr>
</thead>
<tbody>
@@ -176,6 +178,8 @@
<td>{{if .SmartHealth}}{{if eq .SmartHealth "PASSED"}}<span style="color: var(--green)">{{.SmartHealth}}</span>{{else if eq .SmartHealth "FAILED"}}<span style="color: var(--red)">{{.SmartHealth}}</span>{{else}}{{.SmartHealth}}{{end}}{{else}}—{{end}}</td>
<td>{{if .TempC}}{{.TempC}}°C{{else}}—{{end}}</td>
<td>{{if .WearPct}}{{.WearPct}}%{{else}}—{{end}}</td>
<td class="mono" style="font-size:.8rem">{{if .PhysicalDisks}}{{range $i, $d := .PhysicalDisks}}{{if $i}}<br>{{end}}{{$d}}{{end}}{{else}}—{{end}}</td>
<td>{{if eq .Identity "proven"}}<span style="color: var(--green)">proven</span>{{else if .Identity}}unknown{{if .IdentityReason}} ({{.IdentityReason}}){{end}}{{else}}—{{end}}{{if .Kind}} · {{.Kind}}{{end}}</td>
</tr>
{{end}}
</tbody>