mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
Add friendly node display names across dashboard #476
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface State {
|
||||
export interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
instance: string;
|
||||
host: string;
|
||||
status: string;
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface NetworkInfo {
|
||||
export interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
type: string;
|
||||
status: 'online' | 'offline' | 'unknown';
|
||||
instance: string;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user