Add friendly node display names across dashboard #476

This commit is contained in:
rcourtman
2025-10-03 18:46:10 +00:00
parent 38a5d869dd
commit e1ffc7ace2
12 changed files with 117 additions and 15 deletions
@@ -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<CompactNodeCardProps> = (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<CompactNodeCardProps> = (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()}
</a>
{/* Cluster/Standalone indicator */}
@@ -172,8 +180,11 @@ const CompactNodeCard: Component<CompactNodeCardProps> = (props) => {
target="_blank"
class="font-semibold text-sm hover:text-blue-600 dark:hover:text-blue-400"
>
{props.node.name}
{displayName()}
</a>
<Show when={showActualName()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
</Show>
{/* Cluster/Standalone indicator */}
<Show when={props.node.isClusterMember !== undefined}>
<span
@@ -5,6 +5,7 @@ import { getAlertStyles, getResourceAlerts } from '@/utils/alerts';
import { AlertIndicator, AlertCountBadge } from '@/components/shared/AlertIndicators';
import { useWebSocket } from '@/App';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
interface NodeCardProps {
node: Node;
@@ -49,6 +50,9 @@ const NodeCard: Component<NodeCardProps> = (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<NodeCardProps> = (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()}
</a>
<Show when={showActualName()}>
<span class="text-[10px] text-gray-500 dark:text-gray-400">({props.node.name})</span>
</Show>
{/* Cluster/Standalone indicator - more compact */}
<Show when={props.node.isClusterMember !== undefined}>
<span
@@ -1,5 +1,6 @@
import { Component, Show } from 'solid-js';
import type { Node } from '@/types/api';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
interface NodeGroupHeaderProps {
node: Node;
@@ -9,6 +10,8 @@ interface NodeGroupHeaderProps {
export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (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 (
<tr class="bg-gray-50 dark:bg-gray-900/40">
@@ -29,8 +32,11 @@ export const NodeGroupHeader: Component<NodeGroupHeaderProps> = (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()}
</a>
<Show when={showActualName()}>
<span class="text-[10px] text-slate-500 dark:text-slate-400">({props.node.name})</span>
</Show>
<Show when={props.node.isClusterMember !== undefined}>
<span
@@ -5,6 +5,7 @@ import { MetricBar } from '@/components/Dashboard/MetricBar';
import { useWebSocket } from '@/App';
import { getAlertStyles } from '@/utils/alerts';
import { Card } from '@/components/shared/Card';
import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes';
interface NodeSummaryTableProps {
nodes: Node[];
@@ -231,7 +232,9 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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<NodeSummaryTableProps> = (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()}
</a>
<Show when={showActualName()}>
<span class="text-[9px] text-gray-500 dark:text-gray-400">
({(node as Node).name})
</span>
</Show>
<Show when={isPVE}>
<span class="text-[9px] px-1 py-0 rounded text-[8px] font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400">
PVE
+1
View File
@@ -21,6 +21,7 @@ export interface State {
export interface Node {
id: string;
name: string;
displayName?: string;
instance: string;
host: string;
status: string;
+1
View File
@@ -32,6 +32,7 @@ export interface NetworkInfo {
export interface Node {
id: string;
name: string;
displayName?: string;
type: string;
status: 'online' | 'offline' | 'unknown';
instance: string;
+19
View File
@@ -0,0 +1,19 @@
import type { Node } from '@/types/api';
type DisplayableNode = Pick<Node, 'name'> & Partial<Pick<Node, 'displayName' | 'instance'>>;
export function getNodeDisplayName<T extends DisplayableNode>(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<T extends DisplayableNode>(node: T): boolean {
const display = typeof node.displayName === 'string' ? node.displayName.trim() : '';
if (!display) return false;
return display.toLowerCase() !== node.name.trim().toLowerCase();
}
+3
View File
@@ -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",
+5
View File
@@ -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
}
+1
View File
@@ -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"`
+1
View File
@@ -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"`
+36 -7
View File
@@ -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",