Add refresh-cluster button to detect new Proxmox cluster members

When new nodes are added to a Proxmox cluster after Pulse was
initially configured, they weren't showing up in Settings. The
existing "Refresh" button only triggered network discovery, not
cluster membership re-detection.

Changes:
- Add POST /api/config/nodes/{id}/refresh-cluster endpoint
- Add "Refresh" button in cluster node panel in Settings
- Re-detect cluster membership and update stored endpoints

Related to #799
This commit is contained in:
rcourtman 2025-12-02 22:01:00 +00:00
parent d769d6a56c
commit 93223d8283
5 changed files with 183 additions and 7 deletions

View file

@ -58,4 +58,22 @@ export class NodesAPI {
method: 'POST',
});
}
static async refreshClusterNodes(nodeId: string): Promise<{
status: string;
clusterName?: string;
oldNodeCount?: number;
newNodeCount?: number;
nodesAdded?: number;
clusterNodes?: Array<{
nodeId: string;
nodeName: string;
host: string;
online: boolean;
}>;
}> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}/refresh-cluster`, {
method: 'POST',
});
}
}

View file

@ -28,6 +28,7 @@ interface PveNodesTableProps {
onTestConnection: (nodeId: string) => void;
onEdit: (node: NodeConfigWithStatus) => void;
onDelete: (node: NodeConfigWithStatus) => void;
onRefreshCluster?: (nodeId: string) => void;
}
type StatusMeta = { dotClass: string; label: string; labelClass: string };
@ -381,12 +382,29 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
</For>
</div>
</Show>
<p class="flex items-center gap-1 text-[0.7rem] text-gray-600 dark:text-gray-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Automatic failover enabled between cluster nodes
</p>
<div class="flex items-center justify-between gap-2">
<p class="flex items-center gap-1 text-[0.7rem] text-gray-600 dark:text-gray-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
Automatic failover enabled
</p>
<Show when={props.onRefreshCluster}>
<button
type="button"
onClick={() => props.onRefreshCluster?.(node.id)}
class="flex items-center gap-1 px-2 py-1 text-[0.65rem] font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
title="Re-detect cluster membership (use if nodes were added to the Proxmox cluster)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
Refresh
</button>
</Show>
</div>
</div>
</Show>
</div>

View file

@ -2208,6 +2208,26 @@ const Settings: Component<SettingsProps> = (props) => {
}
};
const refreshClusterNodes = async (nodeId: string) => {
try {
notificationStore.info('Refreshing cluster membership...', 2000);
const result = await NodesAPI.refreshClusterNodes(nodeId);
if (result.status === 'success') {
if (result.nodesAdded && result.nodesAdded > 0) {
showSuccess(`Found ${result.nodesAdded} new node(s) in cluster "${result.clusterName}"`);
} else {
showSuccess(`Cluster "${result.clusterName}" membership verified (${result.newNodeCount} nodes)`);
}
// Refresh nodes list to show updated cluster info
await loadNodes();
} else {
throw new Error('Failed to refresh cluster');
}
} catch (error) {
showError(error instanceof Error ? error.message : 'Failed to refresh cluster membership');
}
};
const checkForUpdates = async () => {
setCheckingForUpdates(true);
try {
@ -2800,6 +2820,7 @@ const Settings: Component<SettingsProps> = (props) => {
setShowNodeModal(true);
}}
onDelete={requestDeleteNode}
onRefreshCluster={refreshClusterNodes}
/>
</Show>

View file

@ -2318,6 +2318,123 @@ func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
// HandleRefreshClusterNodes re-detects cluster membership and updates endpoints
// This handles the case where nodes are added to a Proxmox cluster after initial configuration
func (h *ConfigHandlers) HandleRefreshClusterNodes(w http.ResponseWriter, r *http.Request) {
// Prevent modifications in mock mode
if mock.IsMockEnabled() {
http.Error(w, "Cannot refresh cluster in mock mode", http.StatusForbidden)
return
}
// Path format: /api/config/nodes/{id}/refresh-cluster
path := strings.TrimPrefix(r.URL.Path, "/api/config/nodes/")
path = strings.TrimSuffix(path, "/refresh-cluster")
nodeID := path
if nodeID == "" {
http.Error(w, "Node ID required", http.StatusBadRequest)
return
}
// Parse node ID
parts := strings.Split(nodeID, "-")
if len(parts) != 2 {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
nodeType := parts[0]
index := 0
if _, err := fmt.Sscanf(parts[1], "%d", &index); err != nil {
http.Error(w, "Invalid node ID", http.StatusBadRequest)
return
}
// Only PVE nodes can have clusters
if nodeType != "pve" {
http.Error(w, "Only PVE nodes can be cluster members", http.StatusBadRequest)
return
}
if index >= len(h.config.PVEInstances) {
http.Error(w, "Node not found", http.StatusNotFound)
return
}
pve := &h.config.PVEInstances[index]
// Create client config for cluster detection
clientConfig := config.CreateProxmoxConfig(pve)
// Force cluster re-detection (ignore existing endpoints)
isCluster, clusterName, clusterEndpoints := detectPVECluster(clientConfig, pve.Name, pve.ClusterEndpoints)
if !isCluster {
http.Error(w, "Node is not part of a cluster", http.StatusBadRequest)
return
}
if len(clusterEndpoints) == 0 {
http.Error(w, "Could not detect cluster nodes", http.StatusInternalServerError)
return
}
oldEndpointCount := len(pve.ClusterEndpoints)
newEndpointCount := len(clusterEndpoints)
// Update cluster info
pve.IsCluster = true
if clusterName != "" && !strings.EqualFold(clusterName, "unknown cluster") {
pve.ClusterName = clusterName
}
pve.ClusterEndpoints = clusterEndpoints
// Save configuration
if err := h.persistence.SaveNodesConfig(h.config.PVEInstances, h.config.PBSInstances, h.config.PMGInstances); err != nil {
log.Error().Err(err).Msg("Failed to save nodes configuration after cluster refresh")
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
return
}
log.Info().
Str("instance", pve.Name).
Str("cluster", pve.ClusterName).
Int("old_endpoints", oldEndpointCount).
Int("new_endpoints", newEndpointCount).
Msg("Refreshed cluster membership")
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
log.Error().Err(err).Msg("Failed to reload monitor after cluster refresh")
// Don't fail the request, config was saved successfully
}
}
// Broadcast update to refresh frontend
if h.wsHub != nil {
h.wsHub.BroadcastMessage(websocket.Message{
Type: "nodes_updated",
Data: map[string]interface{}{
"nodeType": "pve",
"action": "cluster_refresh",
},
Timestamp: time.Now().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "success",
"clusterName": pve.ClusterName,
"oldNodeCount": oldEndpointCount,
"newNodeCount": newEndpointCount,
"nodesAdded": newEndpointCount - oldEndpointCount,
"clusterNodes": clusterEndpoints,
})
}
func (h *ConfigHandlers) triggerPVEHostCleanup(host string) {
client := tempproxy.NewClient()
if client == nil || !client.IsAvailable() {

View file

@ -310,9 +310,11 @@ func (r *Router) setupRoutes() {
case http.MethodDelete:
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDeleteNode))(w, req)
case http.MethodPost:
// Handle test endpoint
// Handle test endpoint and refresh-cluster endpoint
if strings.HasSuffix(req.URL.Path, "/test") {
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNode))(w, req)
} else if strings.HasSuffix(req.URL.Path, "/refresh-cluster") {
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleRefreshClusterNodes))(w, req)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}