diff --git a/Agent/cmd/agent/main.go b/Agent/cmd/agent/main.go index 3cf6709..9722a0f 100644 --- a/Agent/cmd/agent/main.go +++ b/Agent/cmd/agent/main.go @@ -208,6 +208,15 @@ func doHeartbeat(ctx context.Context, client *connection.GrpcClient, cfg *config Filesystem: d.Filesystem, }) } + for _, n := range metrics.Networks { + req.Metrics.NetworkInterfaces = append(req.Metrics.NetworkInterfaces, &pb.NetworkInterfaceInfo{ + Name: n.Name, + IpAddress: n.IPAddress, + MacAddress: n.MAC, + BytesSent: int64(n.BytesSent), + BytesRecv: int64(n.BytesRecv), + }) + } resp, err := client.Client.Heartbeat(ctx, req) if err != nil { diff --git a/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs index c936856..c55e812 100644 --- a/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs +++ b/Backend/src/NexusRMM.Api/GrpcServices/AgentGrpcService.cs @@ -14,6 +14,10 @@ namespace NexusRMM.Api.GrpcServices; public class AgentGrpcService : AgentService.AgentServiceBase { + private static readonly JsonSerializerOptions _camelCase = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; private readonly RmmDbContext _db; private readonly ILogger _logger; private readonly IHubContext _hub; @@ -81,7 +85,7 @@ public class AgentGrpcService : AgentService.AgentServiceBase { AgentId = agentId, Timestamp = DateTime.UtcNow, - Metrics = JsonSerializer.SerializeToElement(request.Metrics) + Metrics = JsonSerializer.SerializeToElement(request.Metrics, _camelCase) }); var pendingTasks = await _db.Tasks @@ -104,15 +108,30 @@ public class AgentGrpcService : AgentService.AgentServiceBase await _db.SaveChangesAsync(); - // SignalR: Metriken an agent-Gruppe pushen + // SignalR: Metriken an agent-Gruppe pushen (camelCase durch AddJsonProtocol-Konfiguration) 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, + 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, + disks = request.Metrics?.Disks.Select(d => new + { + mountPoint = d.MountPoint, + totalBytes = d.TotalBytes, + freeBytes = d.FreeBytes, + filesystem = d.Filesystem, + }) ?? [], + networkInterfaces = request.Metrics?.NetworkInterfaces.Select(n => new + { + name = n.Name, + ipAddress = n.IpAddress, + macAddress = n.MacAddress, + bytesSent = n.BytesSent, + bytesRecv = n.BytesRecv, + }) ?? [], }); // SignalR: Status-Änderung an alle Clients pushen @@ -135,11 +154,11 @@ public class AgentGrpcService : AgentService.AgentServiceBase taskItem.CompletedAt = DateTime.UtcNow; taskItem.Result = JsonSerializer.SerializeToElement(new { - request.ExitCode, - request.Stdout, - request.Stderr, - request.Success - }); + exitCode = request.ExitCode, + stdout = request.Stdout, + stderr = request.Stderr, + success = request.Success, + }, _camelCase); await _db.SaveChangesAsync(); diff --git a/Backend/src/NexusRMM.Api/Program.cs b/Backend/src/NexusRMM.Api/Program.cs index 63c6893..164881c 100644 --- a/Backend/src/NexusRMM.Api/Program.cs +++ b/Backend/src/NexusRMM.Api/Program.cs @@ -19,7 +19,12 @@ builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); builder.Services.AddGrpc(); -builder.Services.AddSignalR(); +builder.Services.AddSignalR() + .AddJsonProtocol(options => + { + options.PayloadSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); builder.Services.AddControllers() .AddJsonOptions(options => { diff --git a/Frontend/src/hooks/useAgentSignalR.ts b/Frontend/src/hooks/useAgentSignalR.ts index f627bcc..fe7881b 100644 --- a/Frontend/src/hooks/useAgentSignalR.ts +++ b/Frontend/src/hooks/useAgentSignalR.ts @@ -1,31 +1,23 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import * as signalR from '@microsoft/signalr' import { useSignalR } from './useSignalR' +import type { SystemMetrics } from '../api/types' 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 + connection.on('AgentStatusChanged', (agentId: string, _status: string, _lastSeen: string) => { queryClient.invalidateQueries({ queryKey: ['agents'] }) - // Einzelnen Agent-Cache aktualisieren queryClient.invalidateQueries({ queryKey: ['agent', agentId] }) }) - connection.on('AlertTriggered', (_agentId: string, agentHostname: string, ruleName: string, _message: string, _severity: string) => { - console.debug('[SignalR] AlertTriggered', agentHostname, ruleName) - // Alerts-Liste invalidieren + connection.on('AlertTriggered', () => { queryClient.invalidateQueries({ queryKey: ['alerts'] }) }) }, @@ -35,36 +27,47 @@ export function useGlobalSignalR() { } /** - * Tritt der Agent-spezifischen Gruppe bei und hört auf Metriken + Command-Results. - * Wird in AgentDetailPage verwendet. + * Tritt der Agent-Gruppe bei und empfängt Live-Metriken via SignalR. + * onLiveMetrics wird mit den aktuellen Metriken aufgerufen (direkt, ohne API-Roundtrip). */ -export function useAgentSignalR(agentId: string) { +export function useAgentSignalR( + agentId: string, + onLiveMetrics?: (metrics: SystemMetrics) => void, +) { const queryClient = useQueryClient() const connectionRef = useRef(null) + const onLiveMetricsRef = useRef(onLiveMetrics) + + // Ref aktuell halten ohne Re-Subscribe zu triggern + useEffect(() => { + onLiveMetricsRef.current = onLiveMetrics + }, [onLiveMetrics]) + + const handleMetrics = useCallback((id: string, metrics: SystemMetrics) => { + if (id !== agentId) return + // Live-Callback aufrufen (kein API-Call nötig) + onLiveMetricsRef.current?.(metrics) + // Query-Cache auch invalidieren damit historische Daten aktuell bleiben + queryClient.invalidateQueries({ queryKey: ['agentMetrics', agentId] }) + }, [agentId, queryClient]) 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('AgentMetricsUpdated', handleMetrics) connection.on('CommandResultUpdated', (_taskId: string, id: string) => { if (id === agentId) { - // Tasks neu laden queryClient.invalidateQueries({ queryKey: ['agentTasks', agentId] }) + queryClient.invalidateQueries({ queryKey: ['agent', agentId] }) } }) }, @@ -73,7 +76,6 @@ export function useAgentSignalR(agentId: string) { }, }) - // Gruppe verlassen wenn Komponente unmountet useEffect(() => { return () => { connectionRef.current?.invoke('LeaveAgentGroup', agentId).catch(() => {}) diff --git a/Frontend/src/pages/AgentDetailPage.tsx b/Frontend/src/pages/AgentDetailPage.tsx index 7dd8c36..46feba3 100644 --- a/Frontend/src/pages/AgentDetailPage.tsx +++ b/Frontend/src/pages/AgentDetailPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useCallback } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { ChevronLeft, @@ -9,6 +9,11 @@ import { Terminal, AlertCircle, CheckCircle, + Wifi, + ArrowDown, + ArrowUp, + Server, + Info, } from 'lucide-react' import { RemoteDesktopButton } from '../components/RemoteDesktopButton' import { @@ -21,7 +26,7 @@ import { ResponsiveContainer, } from 'recharts' import { agentsApi, tasksApi } from '../api/client' -import type { TaskItem, TaskType } from '../api/types' +import type { TaskItem, TaskType, SystemMetrics } from '../api/types' import { cn } from '../lib/utils' import { useAgentSignalR } from '../hooks/useAgentSignalR' @@ -30,35 +35,113 @@ interface AgentDetailPageProps { onBack?: () => void } +// ─── Hilfsfunktionen ─────────────────────────────────────────────────────── + +function formatUptime(seconds: number): string { + if (!seconds || isNaN(seconds)) return '–' + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + const parts = [] + if (d > 0) parts.push(`${d}d`) + if (h > 0) parts.push(`${h}h`) + if (m > 0) parts.push(`${m}m`) + return parts.length ? parts.join(' ') : '< 1m' +} + +function formatBytes(bytes: number, decimals = 1): string { + if (!bytes || isNaN(bytes) || bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}` +} + +function safePercent(value: number): number { + if (!value || isNaN(value) || !isFinite(value)) return 0 + return Math.min(100, Math.max(0, value)) +} + +function memPercent(totalBytes: number, availableBytes: number): number { + if (!totalBytes || totalBytes === 0) return 0 + return safePercent(((totalBytes - availableBytes) / totalBytes) * 100) +} + +// ─── Komponenten ─────────────────────────────────────────────────────────── + +interface MetricCardProps { + title: string + value: string + sub?: string + icon: React.ComponentType<{ size?: number; className?: string }> + accent?: string + live?: boolean +} + +function MetricCard({ title, value, sub, icon: Icon, accent = 'text-primary', live }: MetricCardProps) { + return ( +
+
+ +
+
+
+

{title}

+ {live && ( + + )} +
+

{value}

+ {sub &&

{sub}

} +
+
+ ) +} + +function ProgressBar({ value, color = 'bg-primary' }: { value: number; color?: string }) { + const safe = safePercent(value) + const barColor = safe >= 90 ? 'bg-red-500' : safe >= 70 ? 'bg-yellow-500' : color + return ( +
+
+
+ ) +} + +// ─── Hauptkomponente ─────────────────────────────────────────────────────── + export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { const [command, setCommand] = useState('') const [isExecuting, setIsExecuting] = useState(false) const [lastResult, setLastResult] = useState(null) + const [liveMetrics, setLiveMetrics] = useState(null) const queryClient = useQueryClient() - // SignalR: Live-Updates für Metriken und Command-Results - const { status: signalRStatus } = useAgentSignalR(agentId) + const handleLiveMetrics = useCallback((m: SystemMetrics) => { + setLiveMetrics(m) + }, []) + + const { status: signalRStatus } = useAgentSignalR(agentId, handleLiveMetrics) - // Fetch Agent Details const { data: agent, isLoading: agentLoading } = useQuery({ queryKey: ['agent', agentId], queryFn: () => agentsApi.get(agentId), }) - // Fetch Agent Metrics - const { data: metrics = [] } = useQuery({ + const { data: metricsHistory = [] } = useQuery({ queryKey: ['agentMetrics', agentId], - queryFn: () => agentsApi.getMetrics(agentId, 50), + queryFn: () => agentsApi.getMetrics(agentId, 60), }) - // Fetch Agent Tasks const { data: tasks = [] } = useQuery({ queryKey: ['agentTasks', agentId], queryFn: () => tasksApi.listForAgent(agentId), - refetchInterval: 5000, + refetchInterval: 10000, }) - // Create Task Mutation const createTaskMutation = useMutation({ mutationFn: (data: { agentId: string; type: TaskType; payload: Record }) => tasksApi.create(data), @@ -66,25 +149,62 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { setLastResult(task) setCommand('') setIsExecuting(false) - // Invalidate tasks query to refetch queryClient.invalidateQueries({ queryKey: ['agentTasks', agentId] }) }, - onError: () => { - setIsExecuting(false) - }, + onError: () => setIsExecuting(false), }) if (agentLoading) { - return
Agent-Details werden geladen...
+ return ( +
+ Lade Agent-Details... +
+ ) } - if (!agent) { - return
Agent nicht gefunden
+ return ( +
+ Agent nicht gefunden +
+ ) } - const handleExecuteCommand = async () => { - if (!command.trim()) return + // Metriken: Live (SignalR) hat Vorrang, sonst neuester historischer Eintrag + const latestHistoric = metricsHistory.length > 0 ? metricsHistory[0] : null + const currentMetrics: SystemMetrics | null = liveMetrics ?? latestHistoric?.metrics ?? null + const isLive = liveMetrics !== null + const cpuUsage = safePercent(currentMetrics?.cpuUsagePercent ?? 0) + const memTotal = currentMetrics?.memoryTotalBytes ?? 0 + const memAvailable = currentMetrics?.memoryAvailableBytes ?? 0 + const memUsage = memPercent(memTotal, memAvailable) + const uptimeSec = currentMetrics?.uptimeSeconds ?? 0 + const disks = currentMetrics?.disks ?? [] + const networkInterfaces = currentMetrics?.networkInterfaces ?? [] + + const isOnline = agent.status === 'Online' + + // Chart-Daten: Älteste zuerst für korrekte Links-nach-Rechts-Zeitachse + const chartData = [...metricsHistory] + .reverse() + .map((entry) => ({ + time: new Date(entry.timestamp).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }), + cpu: safePercent(entry.metrics?.cpuUsagePercent ?? 0), + memory: memPercent( + entry.metrics?.memoryTotalBytes ?? 0, + entry.metrics?.memoryAvailableBytes ?? 0, + ), + })) + + // X-Axis: Nur jeden 10. Tick anzeigen um Überlappung zu vermeiden + const xAxisInterval = Math.max(1, Math.floor(chartData.length / 6)) + + const handleExecuteCommand = () => { + if (!command.trim()) return setIsExecuting(true) createTaskMutation.mutate({ agentId, @@ -93,426 +213,427 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { }) } - // Calculate metrics from latest data - const latestMetric = metrics.length > 0 ? metrics[metrics.length - 1] : null - const cpuUsage = latestMetric?.metrics.cpuUsagePercent ?? 0 - const memoryUsage = latestMetric - ? ((latestMetric.metrics.memoryTotalBytes - latestMetric.metrics.memoryAvailableBytes) / - latestMetric.metrics.memoryTotalBytes) * - 100 - : 0 - const memoryTotalGB = latestMetric - ? (latestMetric.metrics.memoryTotalBytes / (1024 * 1024 * 1024)).toFixed(1) - : '0' - const uptimeSeconds = latestMetric?.metrics.uptimeSeconds ?? 0 - const uptimeFormatted = formatUptime(uptimeSeconds) - - const isOnline = agent.status === 'Online' - - // Format chart data - const chartData = metrics - .slice(0, 50) - .map((metric) => ({ - time: new Date(metric.timestamp).toLocaleTimeString('de-DE', { - hour: '2-digit', - minute: '2-digit', - }), - cpu: metric.metrics.cpuUsagePercent, - memory: - ((metric.metrics.memoryTotalBytes - metric.metrics.memoryAvailableBytes) / - metric.metrics.memoryTotalBytes) * - 100, - })) - - // Get disks - const disks = latestMetric?.metrics.disks ?? [] - - // Get last 10 tasks - const lastTasks = tasks.slice(0, 10) + const statusColors = { + Online: 'text-green-400', + Offline: 'text-red-400', + Degraded: 'text-yellow-400', + Pending: 'text-gray-400', + } return ( -
+
{/* Header */} -
+
-
+
-

{agent.hostname}

-
- IP: {agent.ipAddress} - OS: {agent.osType} {agent.osVersion} - Agent: v{agent.agentVersion} -
-
-
- - - {agent.status} - - - {signalRStatus === 'connected' ? 'Live' : signalRStatus === 'reconnecting' ? 'Verbindet...' : 'Offline'} - - -
-
-
- - {/* Info Cards */} -
- - - - -
- - {/* Charts */} -
- {/* CPU Chart */} -
-

CPU-Auslastung

- {chartData.length > 0 ? ( - - - - - - typeof value === 'number' ? `${value.toFixed(1)}%` : ''} - /> - - - - ) : ( -
- Keine Daten verfügbar -
- )} -
- - {/* RAM Chart */} -
-

Arbeitsspeicher-Auslastung

- {chartData.length > 0 ? ( - - - - - - typeof value === 'number' ? `${value.toFixed(1)}%` : ''} - /> - - - - ) : ( -
- Keine Daten verfügbar -
- )} -
-
- - {/* Disk Display */} - {disks.length > 0 && ( -
-

Festplattenspeicher

-
- {disks.map((disk, idx) => { - const usedBytes = disk.totalBytes - disk.freeBytes - const usagePercent = (usedBytes / disk.totalBytes) * 100 - const usedGB = (usedBytes / (1024 * 1024 * 1024)).toFixed(1) - const totalGB = (disk.totalBytes / (1024 * 1024 * 1024)).toFixed(1) - - let barColor = 'bg-green-500' - if (usagePercent >= 90) { - barColor = 'bg-red-500' - } else if (usagePercent >= 70) { - barColor = 'bg-yellow-500' - } - - return ( -
-
- {disk.mountPoint} -
-
-
-
-
- {usedGB} GB / {totalGB} GB ({usagePercent.toFixed(0)}%) -
-
- ) - })} -
-
- )} - - {/* Shell Command Executor */} -
-

- - Befehl ausführen -

- -
-