mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
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:
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user