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:
parent
d769d6a56c
commit
93223d8283
5 changed files with 183 additions and 7 deletions
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue