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