From b8a39cfdc180126226bf1eefb92658c4d0428576 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 3 Oct 2025 17:52:33 +0000 Subject: [PATCH] Address shared storage node metadata (#504) --- .../src/components/Storage/Storage.tsx | 79 +++++++++++++++---- frontend-modern/src/types/api.ts | 1 + internal/models/converters.go | 35 ++++---- internal/models/models.go | 33 ++++---- internal/models/models_frontend.go | 35 ++++---- internal/monitoring/monitor_optimized.go | 58 +++++++++++--- 6 files changed, 169 insertions(+), 72 deletions(-) diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 2f93b4707..e9bd16d18 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -100,30 +100,81 @@ const Storage: Component = () => { } if (!storageMap.has(key)) { + const backendNodes = (s.nodes ?? []).filter((node): node is string => Boolean(node)); + const backendNodeIds = ((s as { nodeIds?: string[] }).nodeIds ?? []).filter( + (id): id is string => Boolean(id), + ); + + const rawNodes = backendNodes.length > 0 ? backendNodes : [s.node].filter(Boolean); + const uniqueNodes = Array.from(new Set(rawNodes)); + const normalizedInitialNodes = + uniqueNodes.length > 1 ? uniqueNodes.filter((node) => node !== 'cluster') : uniqueNodes; + const nodesForStorage = + normalizedInitialNodes.length > 0 ? normalizedInitialNodes : uniqueNodes; + const initialNodeIds = Array.from( + new Set(backendNodeIds.length > 0 ? backendNodeIds : [nodeId].filter(Boolean)), + ); + + const initialPBSNames = + s.type === 'pbs' + ? Array.from(new Set((s.pbsNames ?? [s.name]).filter((name): name is string => Boolean(name)))) + : undefined; + // First occurrence - store it with node list storageMap.set(key, { ...s, name: s.type === 'pbs' ? 'PBS Storage' : s.name, // Generic name for PBS - nodes: [s.node], - nodeIds: [nodeId], - nodeCount: 1, - pbsNames: s.type === 'pbs' ? [s.name] : undefined, // Track individual PBS names + nodes: nodesForStorage, + nodeIds: initialNodeIds, + nodeCount: nodesForStorage.length, + pbsNames: initialPBSNames, // Track individual PBS names }); } else { // Duplicate - just add to node list const existing = storageMap.get(key); - if (!existing.nodes.includes(s.node)) { - existing.nodes.push(s.node); - existing.nodeCount = existing.nodes.length; - } + + const backendNodes = (s.nodes ?? []).filter((node): node is string => Boolean(node)); + const rawCandidateNodes = + backendNodes.length > 0 ? backendNodes : [s.node].filter(Boolean); + const candidateNodes = + rawCandidateNodes.length > 1 + ? rawCandidateNodes.filter((node) => node !== 'cluster') + : rawCandidateNodes; + candidateNodes.forEach((node) => { + if (!existing.nodes.includes(node)) { + existing.nodes.push(node); + } + }); + existing.nodeCount = existing.nodes.length; + + const backendNodeIds = ((s as { nodeIds?: string[] }).nodeIds ?? []).filter( + (id): id is string => Boolean(id), + ); + const candidateNodeIds = + backendNodeIds.length > 0 ? backendNodeIds : [nodeId].filter(Boolean); + if (!existing.nodeIds) { - existing.nodeIds = [nodeId]; - } else if (!existing.nodeIds.includes(nodeId)) { - existing.nodeIds.push(nodeId); + existing.nodeIds = []; } - // For PBS, collect all namespace names - if (s.type === 'pbs' && existing.pbsNames && !existing.pbsNames.includes(s.name)) { - existing.pbsNames.push(s.name); + candidateNodeIds.forEach((id) => { + if (!existing.nodeIds!.includes(id)) { + existing.nodeIds!.push(id); + } + }); + + // For PBS, collect all namespace names (merging backend-provided data) + if (s.type === 'pbs') { + if (!existing.pbsNames) { + existing.pbsNames = []; + } + const incomingPBSNames = (s.pbsNames ?? [s.name]).filter( + (name): name is string => Boolean(name), + ); + incomingPBSNames.forEach((name) => { + if (!existing.pbsNames!.includes(name)) { + existing.pbsNames!.push(name); + } + }); } } }); diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index 08a486a6d..abf8cc1a0 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -124,6 +124,7 @@ export interface Storage { active: boolean; // Added for deduplication in storage view nodes?: string[]; + nodeIds?: string[]; nodeCount?: number; pbsNames?: string[]; // ZFS pool status diff --git a/internal/models/converters.go b/internal/models/converters.go index bca27ff1f..c9a854a95 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -177,22 +177,25 @@ func (c Container) ToFrontend() ContainerFrontend { // ToFrontend converts Storage to StorageFrontend func (s Storage) ToFrontend() StorageFrontend { return StorageFrontend{ - ID: s.ID, - Storage: s.Name, - Name: s.Name, - Node: s.Node, - Instance: s.Instance, - Type: s.Type, - Status: s.Status, - Total: s.Total, - Used: s.Used, - Avail: s.Free, - Free: s.Free, - Usage: s.Usage, - Content: s.Content, - Shared: s.Shared, - Enabled: s.Enabled, - Active: s.Active, + ID: s.ID, + Storage: s.Name, + Name: s.Name, + Node: s.Node, + Instance: s.Instance, + Nodes: s.Nodes, + NodeIDs: s.NodeIDs, + NodeCount: s.NodeCount, + Type: s.Type, + Status: s.Status, + Total: s.Total, + Used: s.Used, + Avail: s.Free, + Free: s.Free, + Usage: s.Usage, + Content: s.Content, + Shared: s.Shared, + Enabled: s.Enabled, + Active: s.Active, } } diff --git a/internal/models/models.go b/internal/models/models.go index 7e5c57035..51f782e74 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -133,21 +133,24 @@ type Container struct { // Storage represents a storage resource type Storage struct { - ID string `json:"id"` - Name string `json:"name"` - Node string `json:"node"` - Instance string `json:"instance"` - Type string `json:"type"` - Status string `json:"status"` - Total int64 `json:"total"` - Used int64 `json:"used"` - Free int64 `json:"free"` - Usage float64 `json:"usage"` - Content string `json:"content"` - Shared bool `json:"shared"` - Enabled bool `json:"enabled"` - Active bool `json:"active"` - ZFSPool *ZFSPool `json:"zfsPool,omitempty"` // ZFS pool details if this is ZFS storage + ID string `json:"id"` + Name string `json:"name"` + Node string `json:"node"` + Instance string `json:"instance"` + Nodes []string `json:"nodes,omitempty"` + NodeIDs []string `json:"nodeIds,omitempty"` + NodeCount int `json:"nodeCount,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + Total int64 `json:"total"` + Used int64 `json:"used"` + Free int64 `json:"free"` + Usage float64 `json:"usage"` + Content string `json:"content"` + Shared bool `json:"shared"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + ZFSPool *ZFSPool `json:"zfsPool,omitempty"` // ZFS pool details if this is ZFS storage } // ZFSPool represents a ZFS pool with health and error information diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index bf437ebde..7c88dad63 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -93,22 +93,25 @@ type ContainerFrontend struct { // StorageFrontend represents Storage with frontend-friendly field names type StorageFrontend struct { - ID string `json:"id"` - Storage string `json:"storage"` // Maps to Name - Name string `json:"name"` - Node string `json:"node"` - Instance string `json:"instance"` - Type string `json:"type"` - Status string `json:"status"` - Total int64 `json:"total"` - Used int64 `json:"used"` - Avail int64 `json:"avail"` // Maps to Free - Free int64 `json:"free"` - Usage float64 `json:"usage"` - Content string `json:"content"` - Shared bool `json:"shared"` - Enabled bool `json:"enabled"` - Active bool `json:"active"` + ID string `json:"id"` + Storage string `json:"storage"` // Maps to Name + Name string `json:"name"` + Node string `json:"node"` + Instance string `json:"instance"` + Nodes []string `json:"nodes,omitempty"` + NodeIDs []string `json:"nodeIds,omitempty"` + NodeCount int `json:"nodeCount,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + Total int64 `json:"total"` + Used int64 `json:"used"` + Avail int64 `json:"avail"` // Maps to Free + Free int64 `json:"free"` + Usage float64 `json:"usage"` + Content string `json:"content"` + Shared bool `json:"shared"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` } // StateFrontend represents the state with frontend-friendly field names diff --git a/internal/monitoring/monitor_optimized.go b/internal/monitoring/monitor_optimized.go index 3b5f23022..e36095ea5 100644 --- a/internal/monitoring/monitor_optimized.go +++ b/internal/monitoring/monitor_optimized.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sort" "strings" "sync" "time" @@ -966,7 +967,21 @@ func (m *Monitor) pollStorageWithNodesOptimized(ctx context.Context, instanceNam // Collect results from all nodes var allStorage []models.Storage - sharedStorageMap := make(map[string]models.Storage) // Map to keep best shared storage entry + type sharedStorageAggregation struct { + storage models.Storage + nodes map[string]struct{} + nodeIDs map[string]struct{} + } + sharedStorageMap := make(map[string]*sharedStorageAggregation) // Map to keep shared storage entries with node affiliations + + toSortedSlice := func(set map[string]struct{}) []string { + slice := make([]string, 0, len(set)) + for value := range set { + slice = append(slice, value) + } + sort.Strings(slice) + return slice + } successfulNodes := 0 failedNodes := 0 @@ -978,16 +993,33 @@ func (m *Monitor) pollStorageWithNodesOptimized(ctx context.Context, instanceNam polledNodes[result.node] = true // Mark this node as successfully polled for _, storage := range result.storage { if storage.Shared { - // For shared storage, use just the storage name as key - // This ensures consistent deduplication regardless of which node reports first + // For shared storage, aggregate by storage name so we can retain the reporting nodes key := storage.Name + nodeIdentifier := fmt.Sprintf("%s-%s", storage.Instance, storage.Node) - // Keep the entry with the most complete data (highest usage) - // or the first one if all are equal - if existing, exists := sharedStorageMap[key]; !exists || storage.Used > existing.Used { - // Update the Node field to indicate it's shared across cluster - storage.Node = "cluster" - sharedStorageMap[key] = storage + if entry, exists := sharedStorageMap[key]; exists { + entry.nodes[storage.Node] = struct{}{} + entry.nodeIDs[nodeIdentifier] = struct{}{} + + // Prefer the entry with the most up-to-date utilization data + if storage.Used > entry.storage.Used || (storage.Total > entry.storage.Total && storage.Used == entry.storage.Used) { + entry.storage.Total = storage.Total + entry.storage.Used = storage.Used + entry.storage.Free = storage.Free + entry.storage.Usage = storage.Usage + entry.storage.ZFSPool = storage.ZFSPool + entry.storage.Status = storage.Status + entry.storage.Enabled = storage.Enabled + entry.storage.Active = storage.Active + entry.storage.Content = storage.Content + entry.storage.Type = storage.Type + } + } else { + sharedStorageMap[key] = &sharedStorageAggregation{ + storage: storage, + nodes: map[string]struct{}{storage.Node: {}}, + nodeIDs: map[string]struct{}{nodeIdentifier: {}}, + } } } else { // Non-shared storage goes directly to results @@ -998,8 +1030,12 @@ func (m *Monitor) pollStorageWithNodesOptimized(ctx context.Context, instanceNam } // Add deduplicated shared storage to results - for _, storage := range sharedStorageMap { - allStorage = append(allStorage, storage) + for _, entry := range sharedStorageMap { + entry.storage.Node = "cluster" + entry.storage.Nodes = toSortedSlice(entry.nodes) + entry.storage.NodeIDs = toSortedSlice(entry.nodeIDs) + entry.storage.NodeCount = len(entry.storage.Nodes) + allStorage = append(allStorage, entry.storage) } // Preserve existing storage data for nodes that weren't polled (offline or error)