From d17df20f5ec46a69889a93575abb4a6593a11bca Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 19 Mar 2026 13:53:40 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Phase=204=20=E2=80=94=20Sig?= =?UTF-8?q?nalR=20real-time=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - RmmHub with typed IRmmHubClient interface (AgentMetricsUpdated, AgentStatusChanged, CommandResultUpdated) - JoinAgentGroup / LeaveAgentGroup for per-agent subscriptions - AgentGrpcService now pushes to SignalR after every Heartbeat and CommandResult - Program.cs maps /hubs/rmm Frontend: - useSignalR hook with exponential backoff reconnect (0s/2s/10s/30s) - useGlobalSignalR: invalidates agents query on AgentStatusChanged - useAgentSignalR: joins agent group, invalidates metrics/tasks on updates - DashboardPage: live agent status updates via SignalR - AgentDetailPage: live metrics/command results + connection status indicator Co-Authored-By: Claude Sonnet 4.6 --- .../GrpcServices/AgentGrpcService.cs | 27 ++++++- .../src/NexusRMM.Api/Hubs/IRmmHubClient.cs | 16 ++++ Backend/src/NexusRMM.Api/Hubs/RmmHub.cs | 38 +++++++++ Backend/src/NexusRMM.Api/Program.cs | 2 + Frontend/src/hooks/useAgentSignalR.ts | 78 +++++++++++++++++++ Frontend/src/hooks/useSignalR.ts | 58 ++++++++++++++ Frontend/src/pages/AgentDetailPage.tsx | 14 ++++ Frontend/src/pages/DashboardPage.tsx | 4 + 8 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 Backend/src/NexusRMM.Api/Hubs/IRmmHubClient.cs create mode 100644 Backend/src/NexusRMM.Api/Hubs/RmmHub.cs create mode 100644 Frontend/src/hooks/useAgentSignalR.ts create mode 100644 Frontend/src/hooks/useSignalR.ts diff --git a/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs index cbfa014..09c8b70 100644 --- a/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs +++ b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Grpc.Core; +using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; +using NexusRMM.Api.Hubs; using NexusRMM.Core.Models; using NexusRMM.Infrastructure.Data; using NexusRMM.Protos; @@ -13,11 +15,13 @@ public class AgentGrpcService : AgentService.AgentServiceBase { private readonly RmmDbContext _db; private readonly ILogger _logger; + private readonly IHubContext _hub; - public AgentGrpcService(RmmDbContext db, ILogger logger) + public AgentGrpcService(RmmDbContext db, ILogger logger, IHubContext hub) { _db = db; _logger = logger; + _hub = hub; } public override async Task Enroll(EnrollRequest request, ServerCallContext context) @@ -82,6 +86,22 @@ public class AgentGrpcService : AgentService.AgentServiceBase } await _db.SaveChangesAsync(); + + // SignalR: Metriken an agent-Gruppe pushen + await _hub.Clients.Group($"agent-{agentId}") + .AgentMetricsUpdated(request.AgentId, new + { + CpuUsagePercent = request.Metrics?.CpuUsagePercent ?? 0, + MemoryUsagePercent = request.Metrics?.MemoryUsagePercent ?? 0, + MemoryTotalBytes = request.Metrics?.MemoryTotalBytes ?? 0, + MemoryAvailableBytes = request.Metrics?.MemoryAvailableBytes ?? 0, + UptimeSeconds = request.Metrics?.UptimeSeconds ?? 0, + }); + + // SignalR: Status-Änderung an alle Clients pushen + await _hub.Clients.All + .AgentStatusChanged(request.AgentId, "Online", DateTime.UtcNow.ToString("O")); + return response; } @@ -102,6 +122,11 @@ public class AgentGrpcService : AgentService.AgentServiceBase }); await _db.SaveChangesAsync(); + + // SignalR: Command-Ergebnis an agent-Gruppe pushen + await _hub.Clients.Group($"agent-{request.AgentId}") + .CommandResultUpdated(request.CommandId, request.AgentId, request.Success, request.ExitCode); + return new CommandResultResponse(); } diff --git a/Backend/src/NexusRMM.Api/Hubs/IRmmHubClient.cs b/Backend/src/NexusRMM.Api/Hubs/IRmmHubClient.cs new file mode 100644 index 0000000..00ca46b --- /dev/null +++ b/Backend/src/NexusRMM.Api/Hubs/IRmmHubClient.cs @@ -0,0 +1,16 @@ +namespace NexusRMM.Api.Hubs; + +/// +/// Typed SignalR client interface — definiert was der Server zum Frontend pushen kann. +/// +public interface IRmmHubClient +{ + /// Neue Metriken für einen Agent verfügbar (an agent-Gruppe gepusht) + Task AgentMetricsUpdated(string agentId, object metrics); + + /// Agent-Status hat sich geändert (an alle Clients gepusht) + Task AgentStatusChanged(string agentId, string status, string lastSeen); + + /// Command-Ergebnis verfügbar (an agent-Gruppe gepusht) + Task CommandResultUpdated(string taskId, string agentId, bool success, int exitCode); +} diff --git a/Backend/src/NexusRMM.Api/Hubs/RmmHub.cs b/Backend/src/NexusRMM.Api/Hubs/RmmHub.cs new file mode 100644 index 0000000..e21c60b --- /dev/null +++ b/Backend/src/NexusRMM.Api/Hubs/RmmHub.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.SignalR; + +namespace NexusRMM.Api.Hubs; + +public class RmmHub : Hub +{ + private readonly ILogger _logger; + + public RmmHub(ILogger logger) + { + _logger = logger; + } + + /// Frontend tritt der Gruppe für einen bestimmten Agent bei + public async Task JoinAgentGroup(string agentId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"agent-{agentId}"); + _logger.LogDebug("Client {ConnectionId} joined group agent-{AgentId}", Context.ConnectionId, agentId); + } + + /// Frontend verlässt die Gruppe für einen Agent + public async Task LeaveAgentGroup(string agentId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"agent-{agentId}"); + } + + public override async Task OnConnectedAsync() + { + _logger.LogInformation("SignalR client connected: {ConnectionId}", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogInformation("SignalR client disconnected: {ConnectionId}", Context.ConnectionId); + await base.OnDisconnectedAsync(exception); + } +} diff --git a/Backend/src/NexusRMM.Api/Program.cs b/Backend/src/NexusRMM.Api/Program.cs index 3f4e328..27a65f9 100644 --- a/Backend/src/NexusRMM.Api/Program.cs +++ b/Backend/src/NexusRMM.Api/Program.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using NexusRMM.Api.GrpcServices; +using NexusRMM.Api.Hubs; using NexusRMM.Infrastructure.Data; var builder = WebApplication.CreateBuilder(args); @@ -43,5 +44,6 @@ if (app.Environment.IsDevelopment()) app.UseCors(); app.MapGrpcService(); app.MapControllers(); +app.MapHub("/hubs/rmm"); app.Run(); diff --git a/Frontend/src/hooks/useAgentSignalR.ts b/Frontend/src/hooks/useAgentSignalR.ts new file mode 100644 index 0000000..15891a6 --- /dev/null +++ b/Frontend/src/hooks/useAgentSignalR.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import * as signalR from '@microsoft/signalr' +import { useSignalR } from './useSignalR' + +const HUB_URL = '/hubs/rmm' + +/** + * Verbindet mit dem SignalR Hub und hört auf globale Agent-Events. + * Invalidiert TanStack Query-Caches bei Updates. + */ +export function useGlobalSignalR() { + const queryClient = useQueryClient() + + const { status } = useSignalR({ + url: HUB_URL, + onConnected: (connection) => { + connection.on('AgentStatusChanged', (agentId: string, status: string, _lastSeen: string) => { + console.debug('[SignalR] AgentStatusChanged', agentId, status) + // Agents-Liste invalidieren damit Dashboard aktuell bleibt + queryClient.invalidateQueries({ queryKey: ['agents'] }) + // Einzelnen Agent-Cache aktualisieren + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }) + }) + }, + }) + + return { status } +} + +/** + * Tritt der Agent-spezifischen Gruppe bei und hört auf Metriken + Command-Results. + * Wird in AgentDetailPage verwendet. + */ +export function useAgentSignalR(agentId: string) { + const queryClient = useQueryClient() + const connectionRef = useRef(null) + + const { status } = useSignalR({ + url: HUB_URL, + onConnected: async (connection) => { + connectionRef.current = connection + + // Gruppe beitreten + try { + await connection.invoke('JoinAgentGroup', agentId) + } catch (err) { + console.warn('[SignalR] JoinAgentGroup failed:', err) + } + + connection.on('AgentMetricsUpdated', (id: string) => { + if (id === agentId) { + // Metriken neu laden + queryClient.invalidateQueries({ queryKey: ['agentMetrics', agentId] }) + } + }) + + connection.on('CommandResultUpdated', (_taskId: string, id: string) => { + if (id === agentId) { + // Tasks neu laden + queryClient.invalidateQueries({ queryKey: ['agentTasks', agentId] }) + } + }) + }, + onDisconnected: () => { + connectionRef.current = null + }, + }) + + // Gruppe verlassen wenn Komponente unmountet + useEffect(() => { + return () => { + connectionRef.current?.invoke('LeaveAgentGroup', agentId).catch(() => {}) + } + }, [agentId]) + + return { status } +} diff --git a/Frontend/src/hooks/useSignalR.ts b/Frontend/src/hooks/useSignalR.ts new file mode 100644 index 0000000..16fd002 --- /dev/null +++ b/Frontend/src/hooks/useSignalR.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef, useState } from 'react' +import * as signalR from '@microsoft/signalr' + +export type SignalRStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' + +export interface UseSignalROptions { + url: string + onConnected?: (connection: signalR.HubConnection) => void + onDisconnected?: () => void +} + +export function useSignalR({ url, onConnected, onDisconnected }: UseSignalROptions) { + const connectionRef = useRef(null) + const [status, setStatus] = useState('disconnected') + + useEffect(() => { + const connection = new signalR.HubConnectionBuilder() + .withUrl(url) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => { + // Exponentielles Backoff: 0s, 2s, 10s, 30s, dann alle 30s + const delays = [0, 2000, 10000, 30000] + return delays[Math.min(retryContext.previousRetryCount, delays.length - 1)] + }, + }) + .configureLogging(signalR.LogLevel.Warning) + .build() + + connectionRef.current = connection + + connection.onreconnecting(() => setStatus('reconnecting')) + connection.onreconnected(() => { + setStatus('connected') + onConnected?.(connection) + }) + connection.onclose(() => { + setStatus('disconnected') + onDisconnected?.() + }) + + setStatus('connecting') + connection.start() + .then(() => { + setStatus('connected') + onConnected?.(connection) + }) + .catch((err) => { + console.warn('SignalR connection failed:', err) + setStatus('disconnected') + }) + + return () => { + connection.stop() + } + }, [url]) // eslint-disable-line react-hooks/exhaustive-deps + + return { connection: connectionRef.current, status } +} diff --git a/Frontend/src/pages/AgentDetailPage.tsx b/Frontend/src/pages/AgentDetailPage.tsx index f494643..924c909 100644 --- a/Frontend/src/pages/AgentDetailPage.tsx +++ b/Frontend/src/pages/AgentDetailPage.tsx @@ -22,6 +22,7 @@ import { import { agentsApi, tasksApi } from '../api/client' import type { TaskItem, TaskType } from '../api/types' import { cn } from '../lib/utils' +import { useAgentSignalR } from '../hooks/useAgentSignalR' interface AgentDetailPageProps { agentId: string @@ -34,6 +35,9 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { const [lastResult, setLastResult] = useState(null) const queryClient = useQueryClient() + // SignalR: Live-Updates für Metriken und Command-Results + const { status: signalRStatus } = useAgentSignalR(agentId) + // Fetch Agent Details const { data: agent, isLoading: agentLoading } = useQuery({ queryKey: ['agent', agentId], @@ -156,6 +160,16 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { {agent.status} + + {signalRStatus === 'connected' ? 'Live' : signalRStatus === 'reconnecting' ? 'Verbindet...' : 'Offline'} + diff --git a/Frontend/src/pages/DashboardPage.tsx b/Frontend/src/pages/DashboardPage.tsx index 279950a..78e7d0e 100644 --- a/Frontend/src/pages/DashboardPage.tsx +++ b/Frontend/src/pages/DashboardPage.tsx @@ -13,6 +13,7 @@ import { import { agentsApi, ticketsApi } from '../api/client' import type { Agent } from '../api/types' import { cn } from '../lib/utils' +import { useGlobalSignalR } from '../hooks/useAgentSignalR' interface DashboardPageProps { onSelectAgent?: (agentId: string) => void @@ -103,6 +104,9 @@ export function DashboardPage({ onSelectAgent }: DashboardPageProps) { const [sortColumn, setSortColumn] = useState('hostname') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') + // SignalR: Live-Updates für Agent-Status + useGlobalSignalR() + // Fetch agents data const { data: agents = [],