From c6227f546fe57880f2dfd2e9c0b0f028d796bf2a Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 1 Mar 2026 01:11:20 +0100 Subject: [PATCH] feat: add agent self-metrics collector and platform metrics page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AgentSelfMetricsCollector that measures the agent's own CPU, memory, threads, handles, and HTTP network traffic (bytes up/down, latency, request count) with zero overhead — no WMI, no PerformanceCounters, just Process properties and lock-free Interlocked counters. NetworkMetrics tracks all BackendApiClient HTTP calls via Stopwatch and byte counting in finally blocks. The collector emits agent_metrics_snapshot events (Severity.Debug) through the existing spool/upload pipeline. Add Galactic Admin-only /platform-metrics page that aggregates agent footprint data across sessions with CPU/memory/network assessment badges, per-session breakdowns, and agent version distribution. Co-Authored-By: Claude Opus 4.6 --- .../Collectors/AgentSelfMetricsCollector.cs | 208 ++++++ .../Monitoring/Core/MonitoringService.cs | 22 + .../Monitoring/Network/BackendApiClient.cs | 93 ++- .../Monitoring/Network/NetworkMetrics.cs | 102 +++ .../Models/AgentConfigResponse.cs | 12 + .../app/platform-metrics/page.tsx | 637 ++++++++++++++++++ .../components/Navbar.tsx | 7 + 7 files changed, 1064 insertions(+), 17 deletions(-) create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/AgentSelfMetricsCollector.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/NetworkMetrics.cs create mode 100644 src/Web/autopilot-monitor-web/app/platform-metrics/page.tsx diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/AgentSelfMetricsCollector.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/AgentSelfMetricsCollector.cs new file mode 100644 index 00000000..6466a690 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/AgentSelfMetricsCollector.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Network; +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors +{ + /// + /// Measures the agent's own resource footprint: process CPU, memory, threads, handles, + /// and HTTP network traffic. Emits agent_metrics_snapshot events via the standard event pipeline. + /// No WMI, no PerformanceCounters — only Process properties and Interlocked counters. + /// + public class AgentSelfMetricsCollector : IDisposable + { + private readonly AgentLogger _logger; + private readonly string _sessionId; + private readonly string _tenantId; + private readonly Action _onEventCollected; + private readonly NetworkMetrics _networkMetrics; + private readonly int _intervalSeconds; + private readonly int _maxDurationHours; + + private Timer _pollTimer; + private DateTime _startedAt; + + // Previous sample for delta calculations + private TimeSpan _prevCpuTime; + private DateTime _prevWallTime; + private NetworkMetricsSnapshot _prevNetSnapshot; + + public AgentSelfMetricsCollector( + string sessionId, + string tenantId, + Action onEventCollected, + NetworkMetrics networkMetrics, + AgentLogger logger, + int intervalSeconds = 60, + int maxDurationHours = 4) + { + _sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId)); + _tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId)); + _onEventCollected = onEventCollected ?? throw new ArgumentNullException(nameof(onEventCollected)); + _networkMetrics = networkMetrics ?? throw new ArgumentNullException(nameof(networkMetrics)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _intervalSeconds = intervalSeconds; + _maxDurationHours = maxDurationHours; + } + + public void Start() + { + var limitInfo = _maxDurationHours > 0 ? $", max duration: {_maxDurationHours}h" : ", no duration limit"; + _logger.Info($"Starting AgentSelfMetrics collector (interval: {_intervalSeconds}s{limitInfo})"); + + _startedAt = DateTime.UtcNow; + + // Prime the baseline for delta calculations + try + { + var proc = Process.GetCurrentProcess(); + _prevCpuTime = proc.TotalProcessorTime; + _prevWallTime = DateTime.UtcNow; + } + catch (Exception ex) + { + _logger.Warning($"Failed to prime CPU baseline: {ex.Message}"); + _prevCpuTime = TimeSpan.Zero; + _prevWallTime = DateTime.UtcNow; + } + _prevNetSnapshot = _networkMetrics.GetSnapshot(); + + _pollTimer = new Timer( + _ => CollectMetrics(), + null, + TimeSpan.FromSeconds(_intervalSeconds), // first tick after one full interval + TimeSpan.FromSeconds(_intervalSeconds) + ); + } + + public void Stop() + { + _logger.Info("Stopping AgentSelfMetrics collector"); + _pollTimer?.Dispose(); + _pollTimer = null; + } + + private void CollectMetrics() + { + try + { + // Max duration guard — same pattern as PerformanceCollector + if (_maxDurationHours > 0 && (DateTime.UtcNow - _startedAt).TotalHours >= _maxDurationHours) + { + _logger.Info($"AgentSelfMetrics collector max duration reached ({_maxDurationHours}h) — stopping"); + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = DateTime.UtcNow, + EventType = "agent_metrics_collector_stopped", + Severity = EventSeverity.Info, + Source = "AgentSelfMetricsCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"AgentSelfMetrics collector stopped after {_maxDurationHours}h (max duration policy)", + Data = new Dictionary + { + { "reason", "max_duration_reached" }, + { "maxDurationHours", _maxDurationHours } + } + }); + Stop(); + return; + } + + var data = new Dictionary(); + var now = DateTime.UtcNow; + + // --- Process metrics (no WMI, no PerformanceCounter) --- + try + { + var proc = Process.GetCurrentProcess(); + proc.Refresh(); // ensure fresh values + + // CPU %: (delta CPU time) / (delta wall time) / cores * 100 + var currentCpuTime = proc.TotalProcessorTime; + var cpuDelta = currentCpuTime - _prevCpuTime; + var wallDelta = now - _prevWallTime; + + if (wallDelta.TotalMilliseconds > 0) + { + var cpuPercent = cpuDelta.TotalMilliseconds / wallDelta.TotalMilliseconds + / Environment.ProcessorCount * 100.0; + data["agent_cpu_percent"] = Math.Round(cpuPercent, 2); + } + + _prevCpuTime = currentCpuTime; + _prevWallTime = now; + + // Memory + data["agent_working_set_mb"] = Math.Round(proc.WorkingSet64 / (1024.0 * 1024), 1); + data["agent_private_bytes_mb"] = Math.Round(proc.PrivateMemorySize64 / (1024.0 * 1024), 1); + + // Threads & handles + data["agent_thread_count"] = proc.Threads.Count; + data["agent_handle_count"] = proc.HandleCount; + } + catch (Exception ex) + { + _logger.Debug($"Process metrics read failed: {ex.Message}"); + } + + // --- Network delta --- + try + { + var currentNet = _networkMetrics.GetSnapshot(); + var delta = currentNet.DeltaFrom(_prevNetSnapshot); + _prevNetSnapshot = currentNet; + + data["net_requests"] = delta.Requests; + data["net_failures"] = delta.Failures; + data["net_bytes_up"] = delta.BytesUp; + data["net_bytes_down"] = delta.BytesDown; + data["net_avg_latency_ms"] = Math.Round(delta.AvgLatencyMs, 1); + + // Cumulative totals for easy "total cost of this session" view + data["net_total_bytes_up"] = currentNet.TotalBytesUp; + data["net_total_bytes_down"] = currentNet.TotalBytesDown; + data["net_total_requests"] = currentNet.RequestCount; + } + catch (Exception ex) + { + _logger.Debug($"Network metrics read failed: {ex.Message}"); + } + + if (data.Count > 0) + { + _onEventCollected(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + Timestamp = now, + EventType = "agent_metrics_snapshot", + Severity = EventSeverity.Debug, + Source = "AgentSelfMetricsCollector", + Phase = EnrollmentPhase.Unknown, + Message = $"Agent CPU: {(data.ContainsKey("agent_cpu_percent") ? data["agent_cpu_percent"] : "?")}%, " + + $"WS: {(data.ContainsKey("agent_working_set_mb") ? data["agent_working_set_mb"] : "?")} MB, " + + $"Net: {(data.ContainsKey("net_requests") ? data["net_requests"] : "?")} req, " + + $"↑{(data.ContainsKey("net_bytes_up") ? data["net_bytes_up"] : "?")} B, " + + $"↓{(data.ContainsKey("net_bytes_down") ? data["net_bytes_down"] : "?")} B", + Data = data + }); + } + } + catch (Exception ex) + { + _logger.Warning($"AgentSelfMetrics collection failed: {ex.Message}"); + } + } + + public void Dispose() + { + Stop(); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index f45a8bf1..74bda573 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -39,6 +39,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core // Optional collectors (toggled via remote config) private PerformanceCollector _performanceCollector; + private AgentSelfMetricsCollector _agentSelfMetricsCollector; // Smart enrollment tracking (replaces DownloadProgressCollector + EspUiStateCollector) private EnrollmentTracker _enrollmentTracker; @@ -311,6 +312,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core ); _performanceCollector.Start(); + // AgentSelfMetricsCollector: measures the agent's own CPU, memory, and network footprint + if (collectors?.EnableAgentSelfMetrics != false) + { + var selfMetricsInterval = collectors?.AgentSelfMetricsIntervalSeconds ?? 60; + _agentSelfMetricsCollector = new AgentSelfMetricsCollector( + _configuration.SessionId, + _configuration.TenantId, + EmitEvent, + _apiClient.NetworkMetrics, + _logger, + selfMetricsInterval, + maxCollectorDurationHours + ); + _agentSelfMetricsCollector.Start(); + } + // EnrollmentTracker: smart enrollment tracking with IME log parsing // (replaces DownloadProgressCollector + EspUiStateCollector) // Skip when log replay is active - LogReplayService starts its own EnrollmentTracker @@ -349,6 +366,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _performanceCollector?.Dispose(); _performanceCollector = null; + _agentSelfMetricsCollector?.Stop(); + _agentSelfMetricsCollector?.Dispose(); + _agentSelfMetricsCollector = null; + _enrollmentTracker?.Stop(); _enrollmentTracker?.Dispose(); _enrollmentTracker = null; @@ -991,6 +1012,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _espAndHelloTracker?.Dispose(); _logReplay?.Dispose(); _performanceCollector?.Dispose(); + _agentSelfMetricsCollector?.Dispose(); _enrollmentTracker?.Dispose(); _gatherRuleExecutor?.Dispose(); _remoteConfigService?.Dispose(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs index 7ba59bf5..b4da03ea 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; @@ -26,6 +27,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network private readonly string _model; private readonly string _serialNumber; private readonly Logging.AgentLogger _logger; + private readonly NetworkMetrics _networkMetrics = new NetworkMetrics(); + + /// + /// Exposes the network metrics counters for AgentSelfMetricsCollector to read. + /// + public NetworkMetrics NetworkMetrics => _networkMetrics; public BackendApiClient(string baseUrl, AgentConfiguration configuration = null, Logging.AgentLogger logger = null) { @@ -120,14 +127,31 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) AddSecurityHeaders(httpRequest); - var httpResponse = await _httpClient.SendAsync(httpRequest); - ThrowOnAuthFailure(httpResponse); - httpResponse.EnsureSuccessStatusCode(); + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + var httpResponse = await _httpClient.SendAsync(httpRequest); + ThrowOnAuthFailure(httpResponse); + httpResponse.EnsureSuccessStatusCode(); - var responseJson = await httpResponse.Content.ReadAsStringAsync(); - var response = JsonConvert.DeserializeObject(responseJson); + var responseJson = await httpResponse.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var response = JsonConvert.DeserializeObject(responseJson); - return response; + return response; + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed); + } } /// @@ -140,12 +164,29 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network var httpRequest = new HttpRequestMessage(HttpMethod.Get, url); AddSecurityHeaders(httpRequest); - var response = await _httpClient.SendAsync(httpRequest); - response.EnsureSuccessStatusCode(); + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + var response = await _httpClient.SendAsync(httpRequest); + response.EnsureSuccessStatusCode(); - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(responseJson); - return result; + var responseJson = await response.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var result = JsonConvert.DeserializeObject(responseJson); + return result; + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed); + } } /// @@ -170,6 +211,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network private async Task PostAsync(string url, TRequest data) { var json = JsonConvert.SerializeObject(data); + var bytesUp = Encoding.UTF8.GetByteCount(json); var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) { @@ -179,14 +221,31 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network // Add additional security headers for device authorization (client cert is at TLS layer, but we can add hardware info in headers for whitelist validation and Autopilot device verification) AddSecurityHeaders(httpRequest); - var response = await _httpClient.SendAsync(httpRequest); - ThrowOnAuthFailure(response); - response.EnsureSuccessStatusCode(); + var sw = Stopwatch.StartNew(); + var failed = false; + long bytesDown = 0; + try + { + var response = await _httpClient.SendAsync(httpRequest); + ThrowOnAuthFailure(response); + response.EnsureSuccessStatusCode(); - var responseJson = await response.Content.ReadAsStringAsync(); - var result = JsonConvert.DeserializeObject(responseJson); + var responseJson = await response.Content.ReadAsStringAsync(); + bytesDown = Encoding.UTF8.GetByteCount(responseJson); + var result = JsonConvert.DeserializeObject(responseJson); - return result; + return result; + } + catch + { + failed = true; + throw; + } + finally + { + sw.Stop(); + _networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed); + } } /// diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/NetworkMetrics.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/NetworkMetrics.cs new file mode 100644 index 00000000..40eb43e4 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/NetworkMetrics.cs @@ -0,0 +1,102 @@ +using System.Threading; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Network +{ + /// + /// Thread-safe counters for HTTP request/response tracking. + /// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector. + /// All operations are lock-free (Interlocked). + /// + public class NetworkMetrics + { + private long _requestCount; + private long _failureCount; + private long _totalBytesUp; + private long _totalBytesDown; + private long _totalLatencyMs; + + public void RecordRequest(long bytesUp, long bytesDown, long latencyMs, bool failed) + { + Interlocked.Increment(ref _requestCount); + Interlocked.Add(ref _totalBytesUp, bytesUp); + Interlocked.Add(ref _totalBytesDown, bytesDown); + Interlocked.Add(ref _totalLatencyMs, latencyMs); + if (failed) + Interlocked.Increment(ref _failureCount); + } + + /// + /// Returns an immutable snapshot of all counters for delta calculation. + /// + public NetworkMetricsSnapshot GetSnapshot() + { + return new NetworkMetricsSnapshot( + Interlocked.Read(ref _requestCount), + Interlocked.Read(ref _failureCount), + Interlocked.Read(ref _totalBytesUp), + Interlocked.Read(ref _totalBytesDown), + Interlocked.Read(ref _totalLatencyMs) + ); + } + } + + /// + /// Immutable snapshot of network counters at a point in time. + /// Used for delta calculation between two collection intervals. + /// + public class NetworkMetricsSnapshot + { + public long RequestCount { get; } + public long FailureCount { get; } + public long TotalBytesUp { get; } + public long TotalBytesDown { get; } + public long TotalLatencyMs { get; } + + public NetworkMetricsSnapshot(long requestCount, long failureCount, + long totalBytesUp, long totalBytesDown, long totalLatencyMs) + { + RequestCount = requestCount; + FailureCount = failureCount; + TotalBytesUp = totalBytesUp; + TotalBytesDown = totalBytesDown; + TotalLatencyMs = totalLatencyMs; + } + + /// + /// Computes the delta between this snapshot and a previous one. + /// + public NetworkMetricsDelta DeltaFrom(NetworkMetricsSnapshot previous) + { + var requests = RequestCount - previous.RequestCount; + var failures = FailureCount - previous.FailureCount; + var bytesUp = TotalBytesUp - previous.TotalBytesUp; + var bytesDown = TotalBytesDown - previous.TotalBytesDown; + var latencyDelta = TotalLatencyMs - previous.TotalLatencyMs; + var avgLatency = requests > 0 ? (double)latencyDelta / requests : 0; + + return new NetworkMetricsDelta(requests, failures, bytesUp, bytesDown, avgLatency); + } + } + + /// + /// Computed delta values between two snapshots — ready for emission as event data. + /// + public class NetworkMetricsDelta + { + public long Requests { get; } + public long Failures { get; } + public long BytesUp { get; } + public long BytesDown { get; } + public double AvgLatencyMs { get; } + + public NetworkMetricsDelta(long requests, long failures, + long bytesUp, long bytesDown, double avgLatencyMs) + { + Requests = requests; + Failures = failures; + BytesUp = bytesUp; + BytesDown = bytesDown; + AvgLatencyMs = avgLatencyMs; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs index c7dcc33e..ac1c6ded 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/AgentConfigResponse.cs @@ -136,6 +136,18 @@ namespace AutopilotMonitor.Shared.Models /// public int MaxCollectorDurationHours { get; set; } = 4; + /// + /// Enable the agent self-metrics collector (process CPU, memory, network traffic). + /// Default: true + /// + public bool EnableAgentSelfMetrics { get; set; } = true; + + /// + /// Interval in seconds for agent self-metrics snapshots. + /// Default: 60 seconds + /// + public int AgentSelfMetricsIntervalSeconds { get; set; } = 60; + /// /// Creates default collector configuration /// diff --git a/src/Web/autopilot-monitor-web/app/platform-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/platform-metrics/page.tsx new file mode 100644 index 00000000..dd8bd5bb --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/platform-metrics/page.tsx @@ -0,0 +1,637 @@ +'use client'; + +import { useEffect, useState, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { API_BASE_URL } from '@/lib/config'; +import { ProtectedRoute } from '../../components/ProtectedRoute'; +import { useAuth } from '../../contexts/AuthContext'; +import { useNotifications } from '../../contexts/NotificationContext'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface AgentMetricsData { + agent_cpu_percent?: number; + agent_working_set_mb?: number; + agent_private_bytes_mb?: number; + agent_thread_count?: number; + agent_handle_count?: number; + net_requests?: number; + net_failures?: number; + net_bytes_up?: number; + net_bytes_down?: number; + net_avg_latency_ms?: number; + net_total_bytes_up?: number; + net_total_bytes_down?: number; + net_total_requests?: number; +} + +interface EnrollmentEvent { + eventType: string; + timestamp: string; + data?: AgentMetricsData; + sessionId?: string; +} + +interface Session { + sessionId: string; + tenantId: string; + serialNumber?: string; + manufacturer?: string; + model?: string; + deviceName?: string; + startedAt?: string; + status?: string; + agentVersion?: string; +} + +interface SessionAgentMetrics { + session: Session; + snapshots: AgentMetricsData[]; + // Final cumulative values from the last snapshot + totalBytesUp: number; + totalBytesDown: number; + totalRequests: number; + avgCpu: number; + maxCpu: number; + avgWorkingSet: number; + maxWorkingSet: number; + avgPrivateBytes: number; + avgLatency: number; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const val = bytes / Math.pow(1024, i); + return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function avg(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => a + b, 0) / values.length; +} + +function max(values: number[]): number { + if (values.length === 0) return 0; + return Math.max(...values); +} + +function pN(values: number[], percentile: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const idx = Math.ceil((percentile / 100) * sorted.length) - 1; + return sorted[Math.max(0, idx)]; +} + +// ── Component ────────────────────────────────────────────────────────────────── + +export default function PlatformMetricsPage() { + const router = useRouter(); + const { getAccessToken, user } = useAuth(); + const { addNotification } = useNotifications(); + + const [loading, setLoading] = useState(true); + const [sessionMetrics, setSessionMetrics] = useState([]); + const [sampleSize, setSampleSize] = useState(20); + const [error, setError] = useState(null); + + // ── Fetch sessions + their events ────────────────────────────────────────── + + const fetchMetrics = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const token = await getAccessToken(); + if (!token) { + addNotification('error', 'Auth Error', 'No access token', 'pm-auth'); + return; + } + + // 1. Fetch all sessions (galactic endpoint) + const sessionsRes = await fetch(`${API_BASE_URL}/api/galactic/sessions`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!sessionsRes.ok) { + if (sessionsRes.status === 403) { + setError('Access denied. Galactic Admin privileges required.'); + } else { + setError(`Failed to fetch sessions: ${sessionsRes.status}`); + } + return; + } + const sessionsData = await sessionsRes.json(); + const allSessions: Session[] = Array.isArray(sessionsData) + ? sessionsData + : sessionsData.sessions || []; + + // Sort by startedAt desc, take N most recent + const sorted = allSessions + .sort((a, b) => new Date(b.startedAt || 0).getTime() - new Date(a.startedAt || 0).getTime()) + .slice(0, sampleSize); + + if (sorted.length === 0) { + setSessionMetrics([]); + return; + } + + // 2. Fetch events for each session in parallel + const results = await Promise.allSettled( + sorted.map(async (session) => { + const eventsRes = await fetch( + `${API_BASE_URL}/api/sessions/${session.sessionId}/events?tenantId=${session.tenantId}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + if (!eventsRes.ok) return { session, events: [] as EnrollmentEvent[] }; + const eventsData = await eventsRes.json(); + const events: EnrollmentEvent[] = Array.isArray(eventsData.events) ? eventsData.events : []; + return { session, events }; + }) + ); + + // 3. Extract agent_metrics_snapshot events and compute per-session aggregates + const metricsPerSession: SessionAgentMetrics[] = []; + + for (const result of results) { + if (result.status !== 'fulfilled') continue; + const { session, events } = result.value; + const snapshots = events + .filter((e) => e.eventType === 'agent_metrics_snapshot' && e.data) + .map((e) => e.data!); + + if (snapshots.length === 0) continue; + + const cpuValues = snapshots.map((s) => s.agent_cpu_percent ?? 0); + const wsValues = snapshots.map((s) => s.agent_working_set_mb ?? 0); + const pbValues = snapshots.map((s) => s.agent_private_bytes_mb ?? 0); + const latValues = snapshots.map((s) => s.net_avg_latency_ms ?? 0).filter((v) => v > 0); + + // Last snapshot has cumulative totals + const last = snapshots[snapshots.length - 1]; + + metricsPerSession.push({ + session, + snapshots, + totalBytesUp: last.net_total_bytes_up ?? 0, + totalBytesDown: last.net_total_bytes_down ?? 0, + totalRequests: last.net_total_requests ?? 0, + avgCpu: avg(cpuValues), + maxCpu: max(cpuValues), + avgWorkingSet: avg(wsValues), + maxWorkingSet: max(wsValues), + avgPrivateBytes: avg(pbValues), + avgLatency: avg(latValues), + }); + } + + setSessionMetrics(metricsPerSession); + } catch (err) { + console.error('Platform metrics fetch error:', err); + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [getAccessToken, sampleSize, addNotification]); + + useEffect(() => { + fetchMetrics(); + }, [fetchMetrics]); + + // ── Aggregated stats across all sessions ─────────────────────────────────── + + const globalStats = useMemo(() => { + if (sessionMetrics.length === 0) return null; + + const allCpuAvgs = sessionMetrics.map((s) => s.avgCpu); + const allCpuMaxes = sessionMetrics.map((s) => s.maxCpu); + const allWsAvgs = sessionMetrics.map((s) => s.avgWorkingSet); + const allWsMaxes = sessionMetrics.map((s) => s.maxWorkingSet); + const allPbAvgs = sessionMetrics.map((s) => s.avgPrivateBytes); + const allLatAvgs = sessionMetrics.filter((s) => s.avgLatency > 0).map((s) => s.avgLatency); + const allBytesUp = sessionMetrics.map((s) => s.totalBytesUp); + const allBytesDown = sessionMetrics.map((s) => s.totalBytesDown); + const allRequests = sessionMetrics.map((s) => s.totalRequests); + const totalSnapshots = sessionMetrics.reduce((sum, s) => sum + s.snapshots.length, 0); + + return { + sessionsAnalyzed: sessionMetrics.length, + totalSnapshots, + cpu: { + avg: avg(allCpuAvgs), + max: max(allCpuMaxes), + p95: pN(allCpuMaxes, 95), + }, + memory: { + avgWs: avg(allWsAvgs), + maxWs: max(allWsMaxes), + p95Ws: pN(allWsMaxes, 95), + avgPb: avg(allPbAvgs), + }, + network: { + avgBytesUpPerSession: avg(allBytesUp), + avgBytesDownPerSession: avg(allBytesDown), + maxBytesUp: max(allBytesUp), + avgRequestsPerSession: avg(allRequests), + avgLatency: avg(allLatAvgs), + p95Latency: pN(allLatAvgs, 95), + }, + }; + }, [sessionMetrics]); + + // ── Agent version distribution ───────────────────────────────────────────── + + const versionDistribution = useMemo(() => { + const counts: Record = {}; + for (const sm of sessionMetrics) { + const v = sm.session.agentVersion || 'unknown'; + counts[v] = (counts[v] || 0) + 1; + } + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .map(([version, count]) => ({ version, count, pct: (count / sessionMetrics.length) * 100 })); + }, [sessionMetrics]); + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( + +
+
+
+
+
+ +

Platform Metrics

+ + Galactic Admin + +
+
+ + + +
+
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {loading && ( +
+
+ + + + + Fetching agent metrics from {sampleSize} sessions... +
+
+ )} + + {!loading && !error && globalStats && ( + <> + {/* ── Overview Stats ──────────────────────────────────────── */} +
+ + + + +
+ + {/* ── Agent Footprint Assessment ─────────────────────────── */} +
+

Agent Footprint Assessment

+
+ {/* CPU */} +
+
+ CPU Impact + +
+
+
+
+

+ Avg: {globalStats.cpu.avg.toFixed(2)}% | Peak p95: {globalStats.cpu.p95.toFixed(2)}% +

+
+ + {/* Memory */} +
+
+ Memory Footprint + +
+
+
+
+

+ Working Set avg: {globalStats.memory.avgWs.toFixed(1)} MB | Private Bytes avg: {globalStats.memory.avgPb.toFixed(1)} MB +

+
+ + {/* Network */} +
+
+ Network Usage + formatBytes(v * 1024)} + /> +
+
+
+
+

+ Avg {globalStats.network.avgRequestsPerSession.toFixed(0)} requests/session | Latency avg: {globalStats.network.avgLatency.toFixed(0)} ms, p95: {globalStats.network.p95Latency.toFixed(0)} ms +

+
+
+
+ + {/* ── Detailed Breakdown ─────────────────────────────────── */} +
+ {/* Per-Session CPU Distribution */} +
+

CPU % per Session (avg)

+
+ {sessionMetrics + .sort((a, b) => b.avgCpu - a.avgCpu) + .slice(0, 10) + .map((sm) => { + const barWidth = globalStats.cpu.max > 0 ? (sm.avgCpu / globalStats.cpu.max) * 100 : 0; + return ( +
+ + {sm.session.deviceName || sm.session.sessionId.slice(0, 8)} + +
+
+
+ + {sm.avgCpu.toFixed(2)}% + +
+ ); + })} +
+
+ + {/* Per-Session Memory Distribution */} +
+

Working Set per Session (avg MB)

+
+ {sessionMetrics + .sort((a, b) => b.avgWorkingSet - a.avgWorkingSet) + .slice(0, 10) + .map((sm) => { + const barWidth = globalStats.memory.maxWs > 0 ? (sm.avgWorkingSet / globalStats.memory.maxWs) * 100 : 0; + return ( +
+ + {sm.session.deviceName || sm.session.sessionId.slice(0, 8)} + +
+
+
+ + {sm.avgWorkingSet.toFixed(1)} MB + +
+ ); + })} +
+
+ + {/* Per-Session Network Usage */} +
+

Network per Session (total bytes)

+
+ {sessionMetrics + .sort((a, b) => (b.totalBytesUp + b.totalBytesDown) - (a.totalBytesUp + a.totalBytesDown)) + .slice(0, 10) + .map((sm) => { + const total = sm.totalBytesUp + sm.totalBytesDown; + const maxTotal = globalStats.network.maxBytesUp + max(sessionMetrics.map(s => s.totalBytesDown)); + const barWidth = maxTotal > 0 ? (total / maxTotal) * 100 : 0; + return ( +
+ + {sm.session.deviceName || sm.session.sessionId.slice(0, 8)} + +
+
+
+ + {formatBytes(total)} + +
+ ); + })} +
+
+ + {/* Agent Version Distribution */} +
+

Agent Version Distribution

+ {versionDistribution.length === 0 ? ( +

No version data available

+ ) : ( +
+ {versionDistribution.map((v) => ( +
+ + {v.version} + +
+
+
+ + {v.count} ({v.pct.toFixed(0)}%) + +
+ ))} +
+ )} +
+
+ + {/* ── Ideas for future metrics ──────────────────────────── */} +
+

Future Platform Metrics Ideas

+
+
+ ● + Agent crash rate & restart frequency +
+
+ ● + Event delivery latency (emit → backend) +
+
+ ● + Spool queue depth over time +
+
+ ● + Backend Function execution time & cost +
+
+ ● + SignalR connection health & message throughput +
+
+ ● + Agent footprint trend over releases +
+
+
+ + )} + + {!loading && !error && sessionMetrics.length === 0 && ( +
+ + + +

No agent metrics data yet

+

+ Deploy the agent with AgentSelfMetricsCollector enabled. +
+ Metrics will appear here after the first enrollment sessions complete. +

+
+ )} +
+
+
+ ); +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +function StatCard({ label, value, detail, color }: { label: string; value: string; detail: string; color: string }) { + const borderColor: Record = { + green: 'border-green-500', + yellow: 'border-yellow-500', + red: 'border-red-500', + blue: 'border-blue-500', + purple: 'border-purple-500', + }; + const textColor: Record = { + green: 'text-green-700', + yellow: 'text-yellow-700', + red: 'text-red-700', + blue: 'text-blue-700', + purple: 'text-purple-700', + }; + + return ( +
+

{label}

+

{value}

+

{detail}

+
+ ); +} + +function FootprintBadge({ + value, + thresholds, + unit, + formatFn, +}: { + value: number; + thresholds: [number, number, number]; + unit: string; + formatFn?: (v: number) => string; +}) { + let label: string; + let className: string; + + if (value < thresholds[0]) { + label = 'Minimal'; + className = 'bg-green-100 text-green-800'; + } else if (value < thresholds[1]) { + label = 'Light'; + className = 'bg-green-50 text-green-700'; + } else if (value < thresholds[2]) { + label = 'Moderate'; + className = 'bg-yellow-100 text-yellow-800'; + } else { + label = 'Heavy'; + className = 'bg-red-100 text-red-800'; + } + + return ( + + {label} + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/Navbar.tsx b/src/Web/autopilot-monitor-web/components/Navbar.tsx index 71312d4a..8a4b30a3 100644 --- a/src/Web/autopilot-monitor-web/components/Navbar.tsx +++ b/src/Web/autopilot-monitor-web/components/Navbar.tsx @@ -481,6 +481,13 @@ export default function Navbar() { Platform Usage Metrics + setShowSettings(false)}> + + + + Platform Metrics + + setShowSettings(false)}>