From e1ffc7ace2bda47a0de74d026b291cab8519b6d8 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 3 Oct 2025 18:46:10 +0000 Subject: [PATCH] Add friendly node display names across dashboard #476 --- .../components/Dashboard/CompactNodeCard.tsx | 17 ++++++-- .../src/components/Dashboard/NodeCard.tsx | 9 +++- .../src/components/shared/NodeGroupHeader.tsx | 8 +++- .../components/shared/NodeSummaryTable.tsx | 24 +++++++++-- frontend-modern/src/types/api.ts | 1 + frontend-modern/src/types/monitoring.ts | 1 + frontend-modern/src/utils/nodes.ts | 19 ++++++++ internal/mock/generator.go | 3 ++ internal/models/converters.go | 5 +++ internal/models/models.go | 1 + internal/models/models_frontend.go | 1 + internal/monitoring/monitor.go | 43 ++++++++++++++++--- 12 files changed, 117 insertions(+), 15 deletions(-) create mode 100644 frontend-modern/src/utils/nodes.ts diff --git a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx b/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx index 903ab8485..24bec8b3d 100644 --- a/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx +++ b/frontend-modern/src/components/Dashboard/CompactNodeCard.tsx @@ -5,6 +5,7 @@ import { getAlertStyles, getResourceAlerts } from '@/utils/alerts'; import { AlertIndicator } from '@/components/shared/AlertIndicators'; import { useWebSocket } from '@/App'; import { Card } from '@/components/shared/Card'; +import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; interface CompactNodeCardProps { node: Node; @@ -25,6 +26,9 @@ const CompactNodeCard: Component = (props) => { return Math.round((props.node.disk.used / props.node.disk.total) * 100); }); + const displayName = () => getNodeDisplayName(props.node); + const showActualName = () => hasAlternateDisplayName(props.node); + const alertStyles = getAlertStyles(props.node.id || props.node.name, activeAlerts); const nodeAlerts = createMemo(() => getResourceAlerts(props.node.id || props.node.name, activeAlerts), @@ -90,9 +94,13 @@ const CompactNodeCard: Component = (props) => { href={props.node.host || `https://${props.node.name}:8006`} target="_blank" class="font-medium text-sm w-24 truncate hover:text-blue-600 dark:hover:text-blue-400" - title={props.node.name} + title={ + showActualName() + ? `${displayName()} • ${props.node.name}` + : props.node.name + } > - {props.node.name} + {displayName()} {/* Cluster/Standalone indicator */} @@ -172,8 +180,11 @@ const CompactNodeCard: Component = (props) => { target="_blank" class="font-semibold text-sm hover:text-blue-600 dark:hover:text-blue-400" > - {props.node.name} + {displayName()} + + ({props.node.name}) + {/* Cluster/Standalone indicator */} = (props) => { return Math.round((props.node.disk.used / props.node.disk.total) * 100); }); + const displayName = () => getNodeDisplayName(props.node); + const showActualName = () => hasAlternateDisplayName(props.node); + // Calculate normalized load (load average / cpu count) const normalizedLoad = () => { if (props.node.loadAverage && props.node.loadAverage.length > 0) { @@ -162,8 +166,11 @@ const NodeCard: Component = (props) => { class="hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer" title={`Open ${props.node.name} web interface`} > - {props.node.name} + {displayName()} + + ({props.node.name}) + {/* Cluster/Standalone indicator - more compact */} = (props) => { const isOnline = () => props.node.status === 'online' && (props.node.uptime || 0) > 0; const nodeUrl = () => props.node.host || `https://${props.node.name}:8006`; + const displayName = () => getNodeDisplayName(props.node); + const showActualName = () => hasAlternateDisplayName(props.node); return ( @@ -29,8 +32,11 @@ export const NodeGroupHeader: Component = (props) => { class="transition-colors duration-150 hover:text-sky-600 dark:hover:text-sky-400" title={`Open ${props.node.name} web interface`} > - {props.node.name} + {displayName()} + + ({props.node.name}) + = (props) => { const getSortValue = (item: SortableItem, key: SortKey): number | string | null => { switch (key) { case 'name': - return item.data.name; + return item.type === 'pve' + ? getNodeDisplayName(item.data as Node) + : (item.data as PBSInstance).name; case 'uptime': return item.type === 'pve' ? (item.data as Node).uptime ?? 0 @@ -262,7 +265,14 @@ export const NodeSummaryTable: Component = (props) => { const bOnline = isItemOnline(b); if (aOnline !== bOnline) return aOnline ? -1 : 1; - return a.data.name.localeCompare(b.data.name); + const aName = a.type === 'pve' + ? getNodeDisplayName(a.data as Node) + : (a.data as PBSInstance).name; + const bName = b.type === 'pve' + ? getNodeDisplayName(b.data as Node) + : (b.data as PBSInstance).name; + + return aName.localeCompare(bName); }; const compareValues = (valueA: number | string | null, valueB: number | string | null) => { @@ -396,6 +406,9 @@ export const NodeSummaryTable: Component = (props) => { const diskSublabel = getDiskSublabel(item); const temperatureValue = getTemperatureValue(item); const uptimeValue = isPVE ? node?.uptime ?? 0 : pbs?.uptime ?? 0; + const displayName = () => + isPVE ? getNodeDisplayName(node as Node) : (pbs as PBSInstance).name; + const showActualName = () => isPVE && hasAlternateDisplayName(node as Node); // Use unique node ID (not hostname) to handle duplicate node names const nodeId = isPVE ? node!.id : pbs!.name; @@ -472,8 +485,13 @@ export const NodeSummaryTable: Component = (props) => { onClick={(e) => e.stopPropagation()} class="font-medium text-[11px] text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400" > - {item.data.name} + {displayName()} + + + ({(node as Node).name}) + + PVE diff --git a/frontend-modern/src/types/api.ts b/frontend-modern/src/types/api.ts index abf8cc1a0..68bbacc63 100644 --- a/frontend-modern/src/types/api.ts +++ b/frontend-modern/src/types/api.ts @@ -21,6 +21,7 @@ export interface State { export interface Node { id: string; name: string; + displayName?: string; instance: string; host: string; status: string; diff --git a/frontend-modern/src/types/monitoring.ts b/frontend-modern/src/types/monitoring.ts index ed0de3911..fd3c6452c 100644 --- a/frontend-modern/src/types/monitoring.ts +++ b/frontend-modern/src/types/monitoring.ts @@ -32,6 +32,7 @@ export interface NetworkInfo { export interface Node { id: string; name: string; + displayName?: string; type: string; status: 'online' | 'offline' | 'unknown'; instance: string; diff --git a/frontend-modern/src/utils/nodes.ts b/frontend-modern/src/utils/nodes.ts new file mode 100644 index 000000000..116329e59 --- /dev/null +++ b/frontend-modern/src/utils/nodes.ts @@ -0,0 +1,19 @@ +import type { Node } from '@/types/api'; + +type DisplayableNode = Pick & Partial>; + +export function getNodeDisplayName(node: T): string { + const display = typeof node.displayName === 'string' ? node.displayName.trim() : ''; + if (display) return display; + + const instance = typeof node.instance === 'string' ? node.instance.trim() : ''; + if (instance) return instance; + + return node.name; +} + +export function hasAlternateDisplayName(node: T): boolean { + const display = typeof node.displayName === 'string' ? node.displayName.trim() : ''; + if (!display) return false; + return display.toLowerCase() !== node.name.trim().toLowerCase(); +} diff --git a/internal/mock/generator.go b/internal/mock/generator.go index e5caaa747..8abad239d 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -305,6 +305,7 @@ func generateNodes(config MockConfig) []models.Node { node := generateNode(nodeName, isHighLoad, config) node.Instance = "mock-cluster" // Part of cluster + node.DisplayName = fmt.Sprintf("%s (%s)", node.Instance, nodeName) node.IsClusterMember = true node.ClusterName = "mock-cluster" // ID format matches real system: instance-nodename @@ -347,6 +348,7 @@ func generateNodes(config MockConfig) []models.Node { node := generateNode(nodeName, isHighLoad, config) node.Instance = nodeName // Standalone - instance matches name + node.DisplayName = node.Instance node.IsClusterMember = false node.ClusterName = "" // Empty for standalone node.ConnectionHealth = "healthy" // Standalone nodes are healthy if online @@ -396,6 +398,7 @@ func generateNode(name string, highLoad bool, config MockConfig) models.Node { return models.Node{ Name: name, + DisplayName: name, Instance: "", // Set by generateNodes based on cluster/standalone Type: "pve", Status: "online", diff --git a/internal/models/converters.go b/internal/models/converters.go index c9a854a95..b42474eb2 100644 --- a/internal/models/converters.go +++ b/internal/models/converters.go @@ -15,6 +15,7 @@ func (n Node) ToFrontend() NodeFrontend { ID: n.ID, Node: n.Name, Name: n.Name, + DisplayName: n.DisplayName, Instance: n.Instance, Host: n.Host, Status: n.Status, @@ -49,6 +50,10 @@ func (n Node) ToFrontend() NodeFrontend { nf.Temperature = n.Temperature } + if nf.DisplayName == "" { + nf.DisplayName = nf.Name + } + return nf } diff --git a/internal/models/models.go b/internal/models/models.go index 51f782e74..f14a9ab3d 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -55,6 +55,7 @@ type ResolvedAlert struct { type Node struct { ID string `json:"id"` Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` Instance string `json:"instance"` Host string `json:"host"` // Full host URL from config Status string `json:"status"` diff --git a/internal/models/models_frontend.go b/internal/models/models_frontend.go index 7c88dad63..0931378de 100644 --- a/internal/models/models_frontend.go +++ b/internal/models/models_frontend.go @@ -8,6 +8,7 @@ type NodeFrontend struct { ID string `json:"id"` Node string `json:"node"` // Maps to Name Name string `json:"name"` + DisplayName string `json:"displayName"` Instance string `json:"instance"` Host string `json:"host,omitempty"` Status string `json:"status"` diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index c0b6525f9..802b21a32 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -50,6 +50,31 @@ type PVEClientInterface interface { GetDisks(ctx context.Context, node string) ([]proxmox.Disk, error) } +func getNodeDisplayName(instance *config.PVEInstance, nodeName string) string { + baseName := strings.TrimSpace(nodeName) + if baseName == "" { + baseName = "unknown-node" + } + + if instance == nil { + return baseName + } + + friendly := strings.TrimSpace(instance.Name) + if friendly == "" { + return baseName + } + + if instance.IsCluster { + if strings.EqualFold(friendly, baseName) { + return baseName + } + return fmt.Sprintf("%s (%s)", friendly, baseName) + } + + return friendly +} + // Monitor handles all monitoring operations type Monitor struct { config *config.Config @@ -1187,14 +1212,17 @@ func (m *Monitor) pollPVEInstance(ctx context.Context, instanceName string, clie // Convert to models var modelNodes []models.Node for _, node := range nodes { + displayName := getNodeDisplayName(instanceCfg, node.Node) + modelNode := models.Node{ - ID: instanceName + "-" + node.Node, - Name: node.Node, - Instance: instanceName, - Host: instanceCfg.Host, // Add the actual host URL - Status: node.Status, - Type: "node", - CPU: safeFloat(node.CPU), // Already in percentage + ID: instanceName + "-" + node.Node, + Name: node.Node, + DisplayName: displayName, + Instance: instanceName, + Host: instanceCfg.Host, // Add the actual host URL + Status: node.Status, + Type: "node", + CPU: safeFloat(node.CPU), // Already in percentage Memory: models.Memory{ Total: int64(node.MaxMem), Used: int64(node.Mem), @@ -3861,6 +3889,7 @@ func (m *Monitor) removeFailedPVENode(instanceName string) { failedNode := models.Node{ ID: instanceName + "-failed", Name: instanceName, + DisplayName: instanceName, Instance: instanceName, Host: hostURL, // Include host URL even for failed nodes Status: "offline",