mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
feat: add agent self-metrics collector and platform metrics page
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 <noreply@anthropic.com>
This commit is contained in:
+208
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AgentSelfMetricsCollector : IDisposable
|
||||
{
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly Action<EnrollmentEvent> _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<EnrollmentEvent> 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<string, object>
|
||||
{
|
||||
{ "reason", "max_duration_reached" },
|
||||
{ "maxDurationHours", _maxDurationHours }
|
||||
}
|
||||
});
|
||||
Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var data = new Dictionary<string, object>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the network metrics counters for AgentSelfMetricsCollector to read.
|
||||
/// </summary>
|
||||
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<IngestEventsResponse>(responseJson);
|
||||
var responseJson = await httpResponse.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var response = JsonConvert.DeserializeObject<IngestEventsResponse>(responseJson);
|
||||
|
||||
return response;
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(compressedContent.Length, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<AgentConfigResponse>(responseJson);
|
||||
return result;
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var result = JsonConvert.DeserializeObject<AgentConfigResponse>(responseJson);
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(0, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -170,6 +211,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network
|
||||
private async Task<TResponse> PostAsync<TRequest, TResponse>(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<TResponse>(responseJson);
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
bytesDown = Encoding.UTF8.GetByteCount(responseJson);
|
||||
var result = JsonConvert.DeserializeObject<TResponse>(responseJson);
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
failed = true;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_networkMetrics.RecordRequest(bytesUp, bytesDown, sw.ElapsedMilliseconds, failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Network
|
||||
{
|
||||
/// <summary>
|
||||
/// Thread-safe counters for HTTP request/response tracking.
|
||||
/// Incremented by BackendApiClient on every request, read by AgentSelfMetricsCollector.
|
||||
/// All operations are lock-free (Interlocked).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an immutable snapshot of all counters for delta calculation.
|
||||
/// </summary>
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of network counters at a point in time.
|
||||
/// Used for delta calculation between two collection intervals.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the delta between this snapshot and a previous one.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computed delta values between two snapshots — ready for emission as event data.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,18 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int MaxCollectorDurationHours { get; set; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Enable the agent self-metrics collector (process CPU, memory, network traffic).
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool EnableAgentSelfMetrics { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Interval in seconds for agent self-metrics snapshots.
|
||||
/// Default: 60 seconds
|
||||
/// </summary>
|
||||
public int AgentSelfMetricsIntervalSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Creates default collector configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -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<SessionAgentMetrics[]>([]);
|
||||
const [sampleSize, setSampleSize] = useState(20);
|
||||
const [error, setError] = useState<string | null>(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<string, number> = {};
|
||||
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 (
|
||||
<ProtectedRoute requireGalacticAdmin>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Platform Metrics</h1>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Galactic Admin
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-500">Sessions to analyze:</label>
|
||||
<select
|
||||
value={sampleSize}
|
||||
onChange={(e) => setSampleSize(Number(e.target.value))}
|
||||
className="text-sm border border-gray-300 rounded-md px-2 py-1"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchMetrics}
|
||||
disabled={loading}
|
||||
className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-md hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Loading...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="flex items-center gap-3 text-gray-500">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>Fetching agent metrics from {sampleSize} sessions...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && globalStats && (
|
||||
<>
|
||||
{/* ── Overview Stats ──────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard
|
||||
label="Sessions Analyzed"
|
||||
value={globalStats.sessionsAnalyzed.toString()}
|
||||
detail={`${globalStats.totalSnapshots} snapshots total`}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Agent CPU"
|
||||
value={`${globalStats.cpu.avg.toFixed(2)}%`}
|
||||
detail={`p95 peak: ${globalStats.cpu.p95.toFixed(2)}%, max: ${globalStats.cpu.max.toFixed(2)}%`}
|
||||
color={globalStats.cpu.avg < 2 ? 'green' : globalStats.cpu.avg < 5 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Working Set"
|
||||
value={`${globalStats.memory.avgWs.toFixed(1)} MB`}
|
||||
detail={`p95 peak: ${globalStats.memory.p95Ws.toFixed(1)} MB, max: ${globalStats.memory.maxWs.toFixed(1)} MB`}
|
||||
color={globalStats.memory.avgWs < 30 ? 'green' : globalStats.memory.avgWs < 60 ? 'yellow' : 'red'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Network / Session"
|
||||
value={formatBytes(globalStats.network.avgBytesUpPerSession + globalStats.network.avgBytesDownPerSession)}
|
||||
detail={`${formatBytes(globalStats.network.avgBytesUpPerSession)} up, ${formatBytes(globalStats.network.avgBytesDownPerSession)} down`}
|
||||
color="blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Agent Footprint Assessment ─────────────────────────── */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Agent Footprint Assessment</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* CPU */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">CPU Impact</span>
|
||||
<FootprintBadge value={globalStats.cpu.avg} thresholds={[1, 3, 5]} unit="%" />
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${globalStats.cpu.avg < 1 ? 'bg-green-500' : globalStats.cpu.avg < 3 ? 'bg-green-400' : globalStats.cpu.avg < 5 ? 'bg-yellow-400' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(globalStats.cpu.avg * 10, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Avg: {globalStats.cpu.avg.toFixed(2)}% | Peak p95: {globalStats.cpu.p95.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Memory */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Memory Footprint</span>
|
||||
<FootprintBadge value={globalStats.memory.avgWs} thresholds={[20, 40, 80]} unit=" MB" />
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${globalStats.memory.avgWs < 20 ? 'bg-green-500' : globalStats.memory.avgWs < 40 ? 'bg-green-400' : globalStats.memory.avgWs < 80 ? 'bg-yellow-400' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min((globalStats.memory.avgWs / 100) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Working Set avg: {globalStats.memory.avgWs.toFixed(1)} MB | Private Bytes avg: {globalStats.memory.avgPb.toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Network Usage</span>
|
||||
<FootprintBadge
|
||||
value={(globalStats.network.avgBytesUpPerSession + globalStats.network.avgBytesDownPerSession) / 1024}
|
||||
thresholds={[100, 500, 2048]}
|
||||
unit=" KB"
|
||||
formatFn={(v) => formatBytes(v * 1024)}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${Math.min(((globalStats.network.avgBytesUpPerSession + globalStats.network.avgBytesDownPerSession) / (1024 * 1024)) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Avg {globalStats.network.avgRequestsPerSession.toFixed(0)} requests/session | Latency avg: {globalStats.network.avgLatency.toFixed(0)} ms, p95: {globalStats.network.p95Latency.toFixed(0)} ms
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Detailed Breakdown ─────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* Per-Session CPU Distribution */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">CPU % per Session (avg)</h3>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={sm.session.sessionId} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-24 truncate" title={sm.session.deviceName || sm.session.sessionId}>
|
||||
{sm.session.deviceName || sm.session.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${sm.avgCpu < 1 ? 'bg-green-400' : sm.avgCpu < 3 ? 'bg-green-500' : sm.avgCpu < 5 ? 'bg-yellow-400' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-600 w-14 text-right">
|
||||
{sm.avgCpu.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Session Memory Distribution */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">Working Set per Session (avg MB)</h3>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={sm.session.sessionId} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-24 truncate" title={sm.session.deviceName || sm.session.sessionId}>
|
||||
{sm.session.deviceName || sm.session.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${sm.avgWorkingSet < 20 ? 'bg-green-400' : sm.avgWorkingSet < 40 ? 'bg-yellow-400' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.max(barWidth, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-600 w-16 text-right">
|
||||
{sm.avgWorkingSet.toFixed(1)} MB
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Session Network Usage */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">Network per Session (total bytes)</h3>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div key={sm.session.sessionId} className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 w-24 truncate" title={sm.session.deviceName || sm.session.sessionId}>
|
||||
{sm.session.deviceName || sm.session.sessionId.slice(0, 8)}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${Math.max(barWidth, 2)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-600 w-16 text-right">
|
||||
{formatBytes(total)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Version Distribution */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-4">Agent Version Distribution</h3>
|
||||
{versionDistribution.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No version data available</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{versionDistribution.map((v) => (
|
||||
<div key={v.version} className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-gray-600 w-24 truncate" title={v.version}>
|
||||
{v.version}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-purple-500 rounded-full" style={{ width: `${v.pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 w-16 text-right">
|
||||
{v.count} ({v.pct.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Ideas for future metrics ──────────────────────────── */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Future Platform Metrics Ideas</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>Agent crash rate & restart frequency</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>Event delivery latency (emit → backend)</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>Spool queue depth over time</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>Backend Function execution time & cost</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>SignalR connection health & message throughput</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-300 mt-0.5">●</span>
|
||||
<span>Agent footprint trend over releases</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !error && sessionMetrics.length === 0 && (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-300 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium">No agent metrics data yet</p>
|
||||
<p className="text-sm mt-1">
|
||||
Deploy the agent with <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">AgentSelfMetricsCollector</code> enabled.
|
||||
<br />
|
||||
Metrics will appear here after the first enrollment sessions complete.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, detail, color }: { label: string; value: string; detail: string; color: string }) {
|
||||
const borderColor: Record<string, string> = {
|
||||
green: 'border-green-500',
|
||||
yellow: 'border-yellow-500',
|
||||
red: 'border-red-500',
|
||||
blue: 'border-blue-500',
|
||||
purple: 'border-purple-500',
|
||||
};
|
||||
const textColor: Record<string, string> = {
|
||||
green: 'text-green-700',
|
||||
yellow: 'text-yellow-700',
|
||||
red: 'text-red-700',
|
||||
blue: 'text-blue-700',
|
||||
purple: 'text-purple-700',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white shadow rounded-lg p-4 border-l-4 ${borderColor[color] || 'border-gray-300'}`}>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${textColor[color] || 'text-gray-900'}`}>{value}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -481,6 +481,13 @@ export default function Navbar() {
|
||||
<span className="text-sm text-purple-700">Platform Usage Metrics</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/platform-metrics" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-purple-50 transition-colors" onClick={() => setShowSettings(false)}>
|
||||
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
<span className="text-sm text-purple-700">Platform Metrics</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/health-check" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-purple-50 transition-colors" onClick={() => setShowSettings(false)}>
|
||||
<svg className="w-4 h-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
|
||||
Reference in New Issue
Block a user