Address shared storage node metadata (#504)

This commit is contained in:
rcourtman
2025-10-03 17:52:33 +00:00
parent ad3d4b8194
commit b8a39cfdc1
6 changed files with 169 additions and 72 deletions
@@ -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);
}
});
}
}
});
+1
View File
@@ -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
+19 -16
View File
@@ -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,
}
}
+18 -15
View File
@@ -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
+19 -16
View File
@@ -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
+47 -11
View File
@@ -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)