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',
|
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;
|
onTestConnection: (nodeId: string) => void;
|
||||||
onEdit: (node: NodeConfigWithStatus) => void;
|
onEdit: (node: NodeConfigWithStatus) => void;
|
||||||
onDelete: (node: NodeConfigWithStatus) => void;
|
onDelete: (node: NodeConfigWithStatus) => void;
|
||||||
|
onRefreshCluster?: (nodeId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusMeta = { dotClass: string; label: string; labelClass: string };
|
type StatusMeta = { dotClass: string; label: string; labelClass: string };
|
||||||
|
|
@ -381,12 +382,29 @@ export const PveNodesTable: Component<PveNodesTableProps> = (props) => {
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<p class="flex items-center gap-1 text-[0.7rem] text-gray-600 dark:text-gray-400">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<p class="flex items-center gap-1 text-[0.7rem] text-gray-600 dark:text-gray-400">
|
||||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
</svg>
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
Automatic failover enabled between cluster nodes
|
</svg>
|
||||||
</p>
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</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 () => {
|
const checkForUpdates = async () => {
|
||||||
setCheckingForUpdates(true);
|
setCheckingForUpdates(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2800,6 +2820,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
setShowNodeModal(true);
|
setShowNodeModal(true);
|
||||||
}}
|
}}
|
||||||
onDelete={requestDeleteNode}
|
onDelete={requestDeleteNode}
|
||||||
|
onRefreshCluster={refreshClusterNodes}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2318,6 +2318,123 @@ func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
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) {
|
func (h *ConfigHandlers) triggerPVEHostCleanup(host string) {
|
||||||
client := tempproxy.NewClient()
|
client := tempproxy.NewClient()
|
||||||
if client == nil || !client.IsAvailable() {
|
if client == nil || !client.IsAvailable() {
|
||||||
|
|
|
||||||
|
|
@ -310,9 +310,11 @@ func (r *Router) setupRoutes() {
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDeleteNode))(w, req)
|
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleDeleteNode))(w, req)
|
||||||
case http.MethodPost:
|
case http.MethodPost:
|
||||||
// Handle test endpoint
|
// Handle test endpoint and refresh-cluster endpoint
|
||||||
if strings.HasSuffix(req.URL.Path, "/test") {
|
if strings.HasSuffix(req.URL.Path, "/test") {
|
||||||
RequireAdmin(r.configHandlers.config, RequireScope(config.ScopeSettingsWrite, r.configHandlers.HandleTestNode))(w, req)
|
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 {
|
} else {
|
||||||
http.Error(w, "Not found", http.StatusNotFound)
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue