diff --git a/frontend-modern/src/api/monitoring.ts b/frontend-modern/src/api/monitoring.ts index 225197ad2..695f1ddfa 100644 --- a/frontend-modern/src/api/monitoring.ts +++ b/frontend-modern/src/api/monitoring.ts @@ -480,6 +480,45 @@ export class MonitoringAPI { } } + static async unlinkHostAgent(hostId: string): Promise { + if (!hostId) { + throw new Error('Host ID is required to unlink an agent.'); + } + + const url = `${this.baseUrl}/agents/host/unlink`; + const response = await apiFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ hostId }), + }); + + if (!response.ok) { + let message = `Failed with status ${response.status}`; + try { + const text = await response.text(); + if (text?.trim()) { + message = text.trim(); + try { + const parsed = JSON.parse(text); + if (typeof parsed?.error === 'string' && parsed.error.trim()) { + message = parsed.error.trim(); + } else if (typeof parsed?.message === 'string' && parsed.message.trim()) { + message = parsed.message.trim(); + } + } catch (_err) { + // Ignore JSON parse errors. + } + } + } catch (_err) { + // Ignore body read errors. + } + + throw new Error(message); + } + } + static async lookupHost(params: { id?: string; hostname?: string }): Promise { const search = new URLSearchParams(); if (params.id) search.set('id', params.id); diff --git a/frontend-modern/src/components/Settings/UnifiedAgents.tsx b/frontend-modern/src/components/Settings/UnifiedAgents.tsx index 85ed9f516..7fb39c215 100644 --- a/frontend-modern/src/components/Settings/UnifiedAgents.tsx +++ b/frontend-modern/src/components/Settings/UnifiedAgents.tsx @@ -383,6 +383,21 @@ export const UnifiedAgents: Component = () => { }); const hasRemovedKubernetesClusters = createMemo(() => removedKubernetesClusters().length > 0); + // Host agents linked to PVE nodes (shown separately with unlink option) + const linkedHostAgents = createMemo(() => { + const hosts = state.hosts || []; + return hosts.filter(h => h.linkedNodeId).map(h => ({ + id: h.id, + hostname: h.hostname || 'Unknown', + displayName: h.displayName, + linkedNodeId: h.linkedNodeId, + status: h.status, + version: h.agentVersion, + lastSeen: h.lastSeen ? new Date(h.lastSeen).getTime() : undefined, + })); + }); + const hasLinkedAgents = createMemo(() => linkedHostAgents().length > 0); + const getUpgradeCommand = (_hostname: string) => { const token = resolvedToken(); const url = customAgentUrl() || agentUrl(); @@ -462,6 +477,7 @@ export const UnifiedAgents: Component = () => { } }; + // Clear pending state when agent reports matching the expected value, or after timeout createEffect(() => { const pending = pendingCommandConfig(); @@ -870,6 +886,19 @@ export const UnifiedAgents: Component = () => {

+ {/* Note about linked agents */} + +
+ + + +

+ {linkedHostAgents().length} host agent{linkedHostAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node{linkedHostAgents().length > 1 ? 's' : ''} and shown in the Dashboard with a +Agent badge. + Manage linked agents → +

+
+
+
diff --git a/internal/api/host_agents.go b/internal/api/host_agents.go index 852fa3f60..a5e139df9 100644 --- a/internal/api/host_agents.go +++ b/internal/api/host_agents.go @@ -343,3 +343,44 @@ func (h *HostAgentHandlers) HandleUninstall(w http.ResponseWriter, r *http.Reque } } +// HandleUnlink removes the link between a host agent and its PVE node. +// The agent continues to report but appears in the Managed Agents table. +func (h *HostAgentHandlers) HandleUnlink(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only POST is allowed", nil) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + defer r.Body.Close() + + var req struct { + HostID string `json:"hostId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorResponse(w, http.StatusBadRequest, "invalid_json", "Failed to decode request body", map[string]string{"error": err.Error()}) + return + } + + hostID := strings.TrimSpace(req.HostID) + if hostID == "" { + writeErrorResponse(w, http.StatusBadRequest, "missing_host_id", "Host ID is required", nil) + return + } + + if err := h.monitor.UnlinkHostAgent(hostID); err != nil { + writeErrorResponse(w, http.StatusNotFound, "unlink_failed", err.Error(), nil) + return + } + + go h.wsHub.BroadcastState(h.monitor.GetState().ToFrontend()) + + if err := utils.WriteJSONResponse(w, map[string]any{ + "success": true, + "hostId": hostID, + "message": "Host agent unlinked from PVE node", + }); err != nil { + log.Error().Err(err).Msg("Failed to serialize host unlink response") + } +} + diff --git a/internal/api/router.go b/internal/api/router.go index d3814dba5..75064c677 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -206,6 +206,7 @@ func (r *Router) setupRoutes() { r.mux.HandleFunc("/api/agents/host/report", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleReport))) r.mux.HandleFunc("/api/agents/host/lookup", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleLookup))) r.mux.HandleFunc("/api/agents/host/uninstall", RequireAuth(r.config, RequireScope(config.ScopeHostReport, r.hostAgentHandlers.HandleUninstall))) + r.mux.HandleFunc("/api/agents/host/unlink", RequireAdmin(r.config, RequireScope(config.ScopeHostManage, r.hostAgentHandlers.HandleUnlink))) // Host agent management routes - config endpoint is accessible by agents (GET) and admins (PATCH) r.mux.HandleFunc("/api/agents/host/", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) { // Route /api/agents/host/{id}/config to HandleConfig diff --git a/internal/models/models.go b/internal/models/models.go index 926684db0..dfa6c15ff 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -2054,6 +2054,44 @@ func (s *State) UnlinkNodesFromHostAgent(hostAgentID string) int { return count } +// UnlinkHostAgent removes the bidirectional link between a host agent and its PVE node. +// Clears LinkedNodeID on the host and LinkedHostAgentID on the node. +// Returns true if the host was found and unlinked. +func (s *State) UnlinkHostAgent(hostID string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + // Find the host + var hostIdx int = -1 + var linkedNodeID string + for i, host := range s.Hosts { + if host.ID == hostID { + hostIdx = i + linkedNodeID = host.LinkedNodeID + break + } + } + + if hostIdx < 0 || linkedNodeID == "" { + return false + } + + // Clear the link on the host + s.Hosts[hostIdx].LinkedNodeID = "" + s.Hosts[hostIdx].LinkedVMID = "" + s.Hosts[hostIdx].LinkedContainerID = "" + + // Clear the link on the node + for i, node := range s.Nodes { + if node.ID == linkedNodeID || node.LinkedHostAgentID == hostID { + s.Nodes[i].LinkedHostAgentID = "" + } + } + + s.LastUpdate = time.Now() + return true +} + // UpsertCephCluster inserts or updates a Ceph cluster in the state. // Uses ID (typically the FSID) for matching. func (s *State) UpsertCephCluster(cluster CephCluster) { diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 7db302a28..c676a8df7 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1232,6 +1232,26 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) { return host, nil } +// UnlinkHostAgent removes the link between a host agent and its PVE node. +// The agent will continue to report but will appear in the Managed Agents table +// instead of being merged with the PVE node in the Dashboard. +func (m *Monitor) UnlinkHostAgent(hostID string) error { + hostID = strings.TrimSpace(hostID) + if hostID == "" { + return fmt.Errorf("host id is required") + } + + if !m.state.UnlinkHostAgent(hostID) { + return fmt.Errorf("host not found or not linked to a node") + } + + log.Info(). + Str("hostID", hostID). + Msg("Unlinked host agent from PVE node") + + return nil +} + // HostAgentConfig represents server-side configuration for a host agent. type HostAgentConfig struct { CommandsEnabled *bool `json:"commandsEnabled,omitempty"` // nil = use agent default