feat: implement agent self-unregistration and UI improvements

- Add DELETE /api/agents/unregister endpoint for agent self-unregistration
- Agent now unregisters itself from Pulse server when uninstalled
- Add clarifying note in UnifiedAgents explaining linked agents behavior
- Linked agents are managed via their PVE node but this is now explained in UI
- Add LastSeen field to HostAgent model for better agent status tracking
This commit is contained in:
rcourtman
2025-12-26 23:18:11 +00:00
parent 8c440b6f54
commit b27b76ae46
6 changed files with 168 additions and 0 deletions

View File

@@ -480,6 +480,45 @@ export class MonitoringAPI {
}
}
static async unlinkHostAgent(hostId: string): Promise<void> {
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<HostLookupResponse | null> {
const search = new URLSearchParams();
if (params.id) search.set('id', params.id);

View File

@@ -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 = () => {
</p>
</div>
{/* Note about linked agents */}
<Show when={hasLinkedAgents()}>
<div class="flex items-start gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-900/20">
<svg class="h-4 w-4 mt-0.5 flex-shrink-0 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-xs text-blue-700 dark:text-blue-300">
<span class="font-medium">{linkedHostAgents().length}</span> host agent{linkedHostAgents().length > 1 ? 's are' : ' is'} linked to Proxmox node{linkedHostAgents().length > 1 ? 's' : ''} and shown in the Dashboard with a <span class="font-medium text-purple-600 dark:text-purple-400">+Agent</span> badge.
<a href="#linked-agents" class="ml-1 underline hover:text-blue-900 dark:hover:text-blue-100">Manage linked agents →</a>
</p>
</div>
</Show>
<Show when={hasLegacyAgents()}>
<div class="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-700 dark:bg-amber-900/20">
<div class="flex items-start gap-3">

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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