diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 07a90376e..f102356f0 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -189,6 +189,11 @@ Own canonical runtime payload shapes between backend and frontend. account/billing shell must remain understandable from the primary header, section title, and factual body content alone instead of depending on a second context-chip strip to restate the same scope. +30. Keep storage wire metadata lossless across shared API payload types. + `frontend-modern/src/types/api.ts` must continue to expose provider-backed + storage metadata such as Proxmox `pool` and `zfsPool` fields when the + backend emits them, instead of silently dropping that detail from the + shared runtime contract. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index 2bfeba45c..398b352b4 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -82,6 +82,11 @@ truth for live infrastructure data. declared ingestion mode on an admitted first-class platform, not a license to create new platform ids from secondary pollers or optional agent augmentation paths. +6. Preserve Proxmox storage backing-pool truth through the canonical storage + poller path. `pkg/proxmox.Storage`, `internal/monitoring/monitor_polling_storage.go`, + and the attached ZFS health model must carry the provider-reported `pool` + field through to runtime storage snapshots and use it before name/path + heuristics when matching ZFS pool health on multi-storage hosts. ## Current State diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 7476b532a..5d1674474 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -219,7 +219,13 @@ querying, and the operator-facing storage health presentation layer. that retained-value behavior must stay route-owned and filter-owned through the canonical recovery state model instead of recreating page-local suspense escape hatches in `Recovery.tsx` or the recovery sections. -18. Keep storage route writes on the shared route-state scheduler. Storage page +18. Keep storage/recovery-adjacent resource metadata on the shared unified + resource contract. When canonical storage resources expose provider-backed + identity such as Proxmox storage `pool`, storage and recovery consumers + must inherit that field through `frontend-modern/src/hooks/useUnifiedResources.ts` + and `frontend-modern/src/types/resource.ts` instead of rebuilding backing + pool identity from labels, paths, or storage-row-local heuristics. +19. Keep storage route writes on the shared route-state scheduler. Storage page filter and tab updates may still own their query keys locally, but `frontend-modern/src/components/Storage/useStorageRouteState.ts` must route same-route replace navigation through the shared diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a97a34f2f..6fa0d694a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -208,6 +208,14 @@ assembly branch. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` must match the user-visible safe label for governed resources instead of reintroducing redacted hostnames through search-only fallback candidates. +15. Preserve provider-backed storage backing-pool identity on canonical + storage resources. `internal/unifiedresources/types.go`, + `internal/unifiedresources/adapters.go`, `internal/unifiedresources/views.go`, + `frontend-modern/src/types/resource.ts`, and + `frontend-modern/src/hooks/useUnifiedResources.ts` must carry the + provider-reported storage `pool` metadata alongside path and ZFS health so + storage consumers do not have to recover backing-pool identity from names + or path heuristics. ## Current State diff --git a/frontend-modern/src/hooks/useUnifiedResources.ts b/frontend-modern/src/hooks/useUnifiedResources.ts index fdcce1584..7ecf2ea38 100644 --- a/frontend-modern/src/hooks/useUnifiedResources.ts +++ b/frontend-modern/src/hooks/useUnifiedResources.ts @@ -275,6 +275,7 @@ type APIResource = { rebuildInProgress?: boolean; rebuildSummary?: string; nodes?: string[]; + pool?: string; path?: string; zfsPoolState?: string; zfsReadErrors?: number; diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 1a32886ef..29263a19c 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -613,6 +613,7 @@ export interface Storage { nodeIds?: string[]; nodeCount?: number; pbsNames?: string[]; + pool?: string; // ZFS pool status zfsPool?: ZFSPool; } diff --git a/frontend-modern/src/types/resource.ts b/frontend-modern/src/types/resource.ts index 341661eb0..86dee7473 100644 --- a/frontend-modern/src/types/resource.ts +++ b/frontend-modern/src/types/resource.ts @@ -260,6 +260,7 @@ export interface ResourceStorageMeta { rebuildInProgress?: boolean; rebuildSummary?: string; nodes?: string[]; + pool?: string; path?: string; zfsPoolState?: string; zfsReadErrors?: number; diff --git a/internal/api/resources_test.go b/internal/api/resources_test.go index 8734d2c58..e1366acf3 100644 --- a/internal/api/resources_test.go +++ b/internal/api/resources_test.go @@ -2113,6 +2113,7 @@ func TestResourceListIncludesStorageMetadata(t *testing.T) { Node: "pve-1", Instance: "cluster-a", Type: "rbd", + Pool: "ceph/rbd-a", Content: "images,backup", Shared: true, Status: "available", @@ -2159,6 +2160,9 @@ func TestResourceListIncludesStorageMetadata(t *testing.T) { if got, want := resource.Storage.ContentTypes, []string{"images", "backup"}; len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { t.Fatalf("storage.contentTypes = %v, want %v", got, want) } + if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want { + t.Fatalf("storage.pool = %q, want %q", got, want) + } if !resource.Storage.Shared { t.Fatalf("expected storage.shared=true") } diff --git a/internal/models/converters.go b/internal/models/converters.go index 3ca2e7c33..f65e6caf6 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -816,6 +816,7 @@ func (s Storage) ToFrontend() StorageFrontend { NodeCount: s.NodeCount, Type: s.Type, Status: s.Status, + Pool: s.Pool, Total: s.Total, Used: s.Used, Avail: s.Free, @@ -825,6 +826,7 @@ func (s Storage) ToFrontend() StorageFrontend { Shared: s.Shared, Enabled: s.Enabled, Active: s.Active, + ZFSPool: s.ZFSPool, }.NormalizeCollections() } diff --git a/internal/models/converters_test.go b/internal/models/converters_test.go index e5b64ed88..45664b510 100644 --- a/internal/models/converters_test.go +++ b/internal/models/converters_test.go @@ -516,6 +516,7 @@ func TestStorageToFrontend(t *testing.T) { NodeCount: 2, Type: "lvmthin", Status: "available", + Pool: "rpool/data", Total: 1000000000000, Used: 500000000000, Free: 500000000000, @@ -524,6 +525,10 @@ func TestStorageToFrontend(t *testing.T) { Shared: false, Enabled: true, Active: true, + ZFSPool: &ZFSPool{ + Name: "rpool", + State: "ONLINE", + }, } frontend := storage.ToFrontend() @@ -537,6 +542,9 @@ func TestStorageToFrontend(t *testing.T) { if frontend.Type != storage.Type { t.Errorf("Type = %q, want %q", frontend.Type, storage.Type) } + if frontend.Pool != storage.Pool { + t.Errorf("Pool = %q, want %q", frontend.Pool, storage.Pool) + } if frontend.Total != storage.Total { t.Errorf("Total = %d, want %d", frontend.Total, storage.Total) } @@ -549,6 +557,9 @@ func TestStorageToFrontend(t *testing.T) { if frontend.Avail != storage.Free { t.Errorf("Avail = %d, want %d", frontend.Avail, storage.Free) } + if frontend.ZFSPool == nil || frontend.ZFSPool.Name != "rpool" { + t.Fatalf("ZFSPool = %#v, want rpool", frontend.ZFSPool) + } } func TestHostSensorSummaryToFrontend(t *testing.T) { @@ -601,7 +612,6 @@ func TestHostSensorSummaryToFrontend(t *testing.T) { } } - func TestToDockerHostCommandFrontend(t *testing.T) { now := time.Now() dispatched := now.Add(-time.Minute) diff --git a/internal/models/models.go b/internal/models/models.go index f4a07c792..cf04b2f56 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1073,6 +1073,7 @@ type Storage struct { NodeCount int `json:"nodeCount,omitempty"` Type string `json:"type"` Status string `json:"status"` + Pool string `json:"pool,omitempty"` Path string `json:"path,omitempty"` Total int64 `json:"total"` Used int64 `json:"used"` diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 5a8c70c0f..91c6ca502 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -695,6 +695,7 @@ type StorageFrontend struct { NodeCount int `json:"nodeCount,omitempty"` Type string `json:"type"` Status string `json:"status"` + Pool string `json:"pool,omitempty"` Total int64 `json:"total"` Used int64 `json:"used"` Avail int64 `json:"avail"` // Maps to Free @@ -704,6 +705,7 @@ type StorageFrontend struct { Shared bool `json:"shared"` Enabled bool `json:"enabled"` Active bool `json:"active"` + ZFSPool *ZFSPool `json:"zfsPool,omitempty"` } func (s StorageFrontend) NormalizeCollections() StorageFrontend { @@ -713,6 +715,10 @@ func (s StorageFrontend) NormalizeCollections() StorageFrontend { if s.NodeIDs == nil { s.NodeIDs = []string{} } + if s.ZFSPool != nil { + pool := s.ZFSPool.NormalizeCollections() + s.ZFSPool = &pool + } return s } diff --git a/internal/monitoring/canonical_guardrails_test.go b/internal/monitoring/canonical_guardrails_test.go index 598899082..ec0055fca 100644 --- a/internal/monitoring/canonical_guardrails_test.go +++ b/internal/monitoring/canonical_guardrails_test.go @@ -220,6 +220,26 @@ func TestProxmoxGuestDiskCarryForwardUsesCanonicalStabilityHelper(t *testing.T) } } +func TestStoragePollingUsesCanonicalPoolMetadataForZFSAttachment(t *testing.T) { + data, err := os.ReadFile("monitor_polling_storage.go") + if err != nil { + t.Fatalf("failed to read monitor_polling_storage.go: %v", err) + } + source := string(data) + + for _, snippet := range []string{ + "func matchZFSPoolForStorage(storage models.Storage, zfsPoolMap map[string]*models.ZFSPool) *models.ZFSPool {", + "storage.Pool,", + "Pool: storage.Pool,", + "if modelStorage.Pool == \"\" && clusterConfig.Pool != \"\" {", + "if pool := matchZFSPoolForStorage(modelStorage, zfsPoolMap); pool != nil {", + } { + if !strings.Contains(source, snippet) { + t.Fatalf("monitor_polling_storage.go must contain %q", snippet) + } + } +} + func TestMonitoringTemperatureFallbackUsesSMARTAwareSSHSkipRule(t *testing.T) { requiredSnippets := map[string][]string{ "host_agent_temps.go": { diff --git a/internal/monitoring/monitor_polling_storage.go b/internal/monitoring/monitor_polling_storage.go index 8ddbf98ff..7950be861 100644 --- a/internal/monitoring/monitor_polling_storage.go +++ b/internal/monitoring/monitor_polling_storage.go @@ -79,6 +79,71 @@ func convertPoolInfoToModel(poolInfo *proxmox.ZFSPoolInfo) *models.ZFSPool { return modelPool } +func matchZFSPoolForStorage(storage models.Storage, zfsPoolMap map[string]*models.ZFSPool) *models.ZFSPool { + if len(zfsPoolMap) == 0 { + return nil + } + + normalizedPools := make(map[string]*models.ZFSPool, len(zfsPoolMap)) + var solePool *models.ZFSPool + for name, pool := range zfsPoolMap { + normalized := strings.ToLower(strings.Trim(strings.TrimSpace(name), "/")) + if normalized == "" || pool == nil { + continue + } + normalizedPools[normalized] = pool + solePool = pool + } + + lookupCandidate := func(candidate string) *models.ZFSPool { + normalized := strings.ToLower(strings.Trim(strings.TrimSpace(candidate), "/")) + if normalized == "" { + return nil + } + if pool, ok := normalizedPools[normalized]; ok { + return pool + } + if idx := strings.Index(normalized, "/"); idx > 0 { + if pool, ok := normalizedPools[normalized[:idx]]; ok { + return pool + } + } + return nil + } + + candidates := []string{ + storage.Pool, + storage.Name, + storage.Path, + } + + trimmedPath := strings.Trim(strings.TrimSpace(storage.Path), "/") + if trimmedPath != "" { + candidates = append(candidates, trimmedPath) + if idx := strings.Index(trimmedPath, "/"); idx > 0 { + candidates = append(candidates, trimmedPath[:idx]) + } + } + + normalizedName := strings.TrimSpace(storage.Name) + if strings.HasSuffix(strings.ToLower(normalizedName), "-zfs") { + candidates = append(candidates, strings.TrimSuffix(normalizedName, "-zfs")) + candidates = append(candidates, strings.TrimSuffix(normalizedName, "-ZFS")) + } + + for _, candidate := range candidates { + if pool := lookupCandidate(candidate); pool != nil { + return pool + } + } + + if len(normalizedPools) == 1 { + return solePool + } + + return nil +} + // pollVMsWithNodes polls VMs from all nodes in parallel using goroutines // When the instance is part of a cluster, the cluster name is used for guest IDs to prevent duplicates // when multiple cluster nodes are configured as separate PVE instances. @@ -304,6 +369,7 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string, Instance: storageInstanceName, Type: storage.Type, Status: "available", + Pool: storage.Pool, Path: storage.Path, Total: int64(storage.Total), Used: int64(storage.Used), @@ -319,6 +385,9 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string, if nodes := parseClusterStorageNodes(clusterConfig.Nodes); len(nodes) > 0 { modelStorage.Nodes = nodes } + if modelStorage.Pool == "" && clusterConfig.Pool != "" { + modelStorage.Pool = clusterConfig.Pool + } if modelStorage.Path == "" && clusterConfig.Path != "" { modelStorage.Path = clusterConfig.Path } @@ -326,28 +395,8 @@ func (m *Monitor) pollStorageWithNodes(ctx context.Context, instanceName string, // If this is ZFS storage, attach pool status information if storage.Type == "zfspool" || storage.Type == "zfs" || storage.Type == "local-zfs" { - // Try to match by storage name or by common ZFS pool names - poolName := storage.Storage - - // Common mappings - if poolName == "local-zfs" { - poolName = "rpool/data" // Common default - } - - // Look for exact match first - if pool, found := zfsPoolMap[poolName]; found { + if pool := matchZFSPoolForStorage(modelStorage, zfsPoolMap); pool != nil { modelStorage.ZFSPool = pool - } else { - // Try partial matches for common patterns - for name, pool := range zfsPoolMap { - if name == "rpool" && strings.Contains(storage.Storage, "rpool") { - modelStorage.ZFSPool = pool - break - } else if name == "data" && strings.Contains(storage.Storage, "data") { - modelStorage.ZFSPool = pool - break - } - } } } diff --git a/internal/monitoring/monitor_storage_test.go b/internal/monitoring/monitor_storage_test.go index 9337495e8..1fc9b5b6a 100644 --- a/internal/monitoring/monitor_storage_test.go +++ b/internal/monitoring/monitor_storage_test.go @@ -15,8 +15,9 @@ import ( // fakeStorageClient provides minimal PVE responses needed by the optimized storage poller. type fakeStorageClient struct { - allStorage []proxmox.Storage - storageByNode map[string][]proxmox.Storage + allStorage []proxmox.Storage + storageByNode map[string][]proxmox.Storage + zfsPoolsByNode map[string][]proxmox.ZFSPoolInfo } func (f *fakeStorageClient) GetNodes(ctx context.Context) ([]proxmox.Node, error) { @@ -122,6 +123,9 @@ func (f *fakeStorageClient) GetZFSPoolStatus(ctx context.Context, node string) ( } func (f *fakeStorageClient) GetZFSPoolsWithDetails(ctx context.Context, node string) ([]proxmox.ZFSPoolInfo, error) { + if pools, ok := f.zfsPoolsByNode[node]; ok { + return pools, nil + } return nil, nil } @@ -296,3 +300,119 @@ func TestPollStorageWithNodesSynthesizesSharedClusterOnlyStorage(t *testing.T) { t.Fatalf("expected cluster storage capacity from config, got %+v", *shared) } } + +func TestPollStorageWithNodesAttachesZFSPoolFromExplicitPoolField(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + + monitor := &Monitor{ + state: &models.State{}, + metricsHistory: NewMetricsHistory(16, time.Hour), + alertManager: alerts.NewManager(), + } + t.Cleanup(func() { + monitor.alertManager.Stop() + }) + + storage := proxmox.Storage{ + Storage: "local-zfs", + Type: "zfspool", + Pool: "rpool/data", + Content: "images,rootdir", + Active: 1, + Enabled: 1, + Shared: 0, + Total: 1000, + Used: 250, + Available: 750, + } + + client := &fakeStorageClient{ + allStorage: []proxmox.Storage{storage}, + storageByNode: map[string][]proxmox.Storage{ + "node1": {storage}, + }, + zfsPoolsByNode: map[string][]proxmox.ZFSPoolInfo{ + "node1": { + {Name: "rpool", Size: 1000, Alloc: 250, Free: 750, Frag: 1, Dedup: 1.0, Health: "ONLINE"}, + }, + }, + } + + nodes := []proxmox.Node{{Node: "node1", Status: "online"}} + monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes) + + if len(monitor.state.Storage) != 1 { + t.Fatalf("expected 1 storage entry, got %d", len(monitor.state.Storage)) + } + if got := monitor.state.Storage[0].Pool; got != "rpool/data" { + t.Fatalf("storage pool = %q, want %q", got, "rpool/data") + } + if monitor.state.Storage[0].ZFSPool == nil { + t.Fatal("expected explicit pool field to attach ZFS pool details") + } + if monitor.state.Storage[0].ZFSPool.Name != "rpool" { + t.Fatalf("ZFS pool name = %q, want rpool", monitor.state.Storage[0].ZFSPool.Name) + } +} + +func TestPollStorageWithNodesUsesClusterStoragePoolFallback(t *testing.T) { + t.Setenv("PULSE_DATA_DIR", t.TempDir()) + + monitor := &Monitor{ + state: models.NewState(), + config: &config.Config{ + PVEInstances: []config.PVEInstance{ + { + Name: "inst1", + IsCluster: true, + ClusterName: "cluster-a", + }, + }, + }, + metricsHistory: NewMetricsHistory(16, time.Hour), + alertManager: alerts.NewManager(), + } + t.Cleanup(func() { + monitor.alertManager.Stop() + }) + + clusterStorage := proxmox.Storage{ + Storage: "local-zfs", + Type: "zfspool", + Pool: "rpool/data", + Content: "images,rootdir", + Shared: 0, + Total: 1000, + Used: 250, + Available: 750, + } + nodeStorage := clusterStorage + nodeStorage.Pool = "" + nodeStorage.Active = 1 + nodeStorage.Enabled = 1 + + client := &fakeStorageClient{ + allStorage: []proxmox.Storage{clusterStorage}, + storageByNode: map[string][]proxmox.Storage{ + "node1": {nodeStorage}, + }, + zfsPoolsByNode: map[string][]proxmox.ZFSPoolInfo{ + "node1": { + {Name: "rpool", Size: 1000, Alloc: 250, Free: 750, Frag: 1, Dedup: 1.0, Health: "ONLINE"}, + }, + }, + } + + nodes := []proxmox.Node{{Node: "node1", Status: "online"}} + monitor.pollStorageWithNodes(context.Background(), "inst1", client, nodes) + + if len(monitor.state.Storage) != 1 { + t.Fatalf("expected 1 storage entry, got %d", len(monitor.state.Storage)) + } + if got := monitor.state.Storage[0].Pool; got != "rpool/data" { + t.Fatalf("storage pool = %q, want %q", got, "rpool/data") + } + if monitor.state.Storage[0].ZFSPool == nil || monitor.state.Storage[0].ZFSPool.Name != "rpool" { + t.Fatalf("expected cluster pool fallback to attach rpool, got %#v", monitor.state.Storage[0].ZFSPool) + } +} diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 4f4a1e130..264f364c1 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -1166,6 +1166,7 @@ func resourceFromStorage(storage models.Storage) (Resource, ResourceIdentity) { IsCeph: isCephStorageType(storageType), IsZFS: isZFSStorageType(storageType) || storage.ZFSPool != nil, Nodes: append([]string(nil), storage.Nodes...), + Pool: storage.Pool, Path: storage.Path, ZFSPoolState: zfsPoolState, ZFSReadErrors: zfsReadErrors, diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 20bcca243..f1f1dc9d3 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -190,6 +190,7 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) { Node: "pve-1", Instance: "cluster-a", Type: "RBD", + Pool: "ceph/rbd-a", Content: "images, rootdir,images", Shared: true, Status: "available", @@ -211,6 +212,9 @@ func TestResourceFromStorageIncludesStorageMetadata(t *testing.T) { if got, want := resource.Storage.Content, "images, rootdir,images"; got != want { t.Fatalf("storage content = %q, want %q", got, want) } + if got, want := resource.Storage.Pool, "ceph/rbd-a"; got != want { + t.Fatalf("storage pool = %q, want %q", got, want) + } wantContentTypes := []string{"images", "rootdir"} if len(resource.Storage.ContentTypes) != len(wantContentTypes) { t.Fatalf("contentTypes length = %d, want %d (%v)", len(resource.Storage.ContentTypes), len(wantContentTypes), resource.Storage.ContentTypes) diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 202a0b985..90f9aa242 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -252,6 +252,40 @@ func TestResourceAPIUsesCanonicalTenantUnifiedSeed(t *testing.T) { } } +func TestCanonicalStorageMetadataPreservesBackingPoolField(t *testing.T) { + requiredSnippets := map[string][]string{ + "types.go": { + "Pool string `json:\"pool,omitempty\"`", + }, + "adapters.go": { + "Pool: storage.Pool,", + }, + "views.go": { + "func (v StoragePoolView) Pool() string {", + "return v.r.Storage.Pool", + }, + filepath.Join("..", "..", "frontend-modern", "src", "types", "resource.ts"): { + "pool?: string;", + }, + filepath.Join("..", "..", "frontend-modern", "src", "hooks", "useUnifiedResources.ts"): { + "pool?: string;", + }, + } + + for path, snippets := range requiredSnippets { + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + source := string(data) + for _, snippet := range snippets { + if !strings.Contains(source, snippet) { + t.Fatalf("%s must contain %q", path, snippet) + } + } + } +} + func TestResourceAPIExposesDedicatedFacetReads(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "api", "resources.go")) if err != nil { diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 0eb6d56f6..39da91323 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -280,6 +280,7 @@ type StorageMeta struct { // Accessibility metadata. Nodes []string `json:"nodes,omitempty"` // PVE nodes where this storage is accessible + Pool string `json:"pool,omitempty"` // Proxmox backing pool/dataset (for example rpool/data) Path string `json:"path,omitempty"` // local mount path on the node // ZFS metadata (when IsZFS is true and the source provides details). diff --git a/internal/unifiedresources/views.go b/internal/unifiedresources/views.go index d6697788e..d4b7f74cb 100644 --- a/internal/unifiedresources/views.go +++ b/internal/unifiedresources/views.go @@ -2082,6 +2082,13 @@ func (v StoragePoolView) AccessibleNodes() []string { return cloneStringSlice(v.r.Storage.Nodes) } +func (v StoragePoolView) Pool() string { + if v.r == nil || v.r.Storage == nil { + return "" + } + return v.r.Storage.Pool +} + func (v StoragePoolView) Path() string { if v.r == nil || v.r.Storage == nil { return "" diff --git a/internal/unifiedresources/views_test.go b/internal/unifiedresources/views_test.go index ba37d9dda..94e315551 100644 --- a/internal/unifiedresources/views_test.go +++ b/internal/unifiedresources/views_test.go @@ -1037,6 +1037,7 @@ func TestView_StoragePoolViewAccessors(t *testing.T) { ProtectionReduced: true, ProtectionSummary: "ZFS pool local-zfs is DEGRADED", Nodes: []string{"pve-a", "pve-b"}, + Pool: "rpool/data", Path: "/mnt/pve/local-zfs", ZFSPoolState: "ONLINE", ZFSReadErrors: 1, @@ -1068,6 +1069,9 @@ func TestView_StoragePoolViewAccessors(t *testing.T) { t.Fatalf("expected ZFS fields to match, got state=%q read=%d write=%d cksum=%d", v.ZFSPoolState(), v.ZFSReadErrors(), v.ZFSWriteErrors(), v.ZFSChecksumErrors()) } assertStringSlice(t, v.AccessibleNodes(), []string{"pve-a", "pve-b"}) + if v.Pool() != "rpool/data" { + t.Fatalf("expected Pool %q, got %q", "rpool/data", v.Pool()) + } if v.Path() != "/mnt/pve/local-zfs" { t.Fatalf("expected Path %q, got %q", "/mnt/pve/local-zfs", v.Path()) } diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 673af0bfc..1943918a0 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -1127,6 +1127,7 @@ type Storage struct { Enabled int `json:"enabled"` Shared int `json:"shared"` Nodes string `json:"nodes,omitempty"` + Pool string `json:"pool,omitempty"` Path string `json:"path,omitempty"` Total uint64 `json:"total"` Used uint64 `json:"used"`