add Galactic Admin audit logs, audit pagination, and platform metrics backend cache

- Galactic Admin Mode for Audit Logs: banner + cross-tenant endpoint (galactic/audit/logs)
- Audit page pagination (12 entries per page) with Previous/Next controls
- Platform Metrics: moved computation to backend PlatformMetricsService with 5-min cache
- New endpoint GET /api/galactic/metrics/platform replaces N+1 client-side fetches
- IME log tracker enhancements, DO telemetry pattern, and download progress improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-02 19:40:37 +01:00
parent 75d74a8519
commit eae2898ce2
17 changed files with 839 additions and 118 deletions
+8
View File
@@ -57,6 +57,14 @@
"description": "Detects when an app is detected as installed (old log format).",
"enabled": true
},
{
"patternId": "IME-DO-TEL",
"category": "currentPhase",
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
"enabled": true
},
{
"patternId": "IME-DO-TIMEOUT-1",
"category": "currentPhase",
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DO-TEL",
"category": "currentPhase",
"pattern": "\\[DO TEL\\] = (?<doTelJson>\\{.*\\})",
"action": "updateDoTelemetry",
"description": "Captures Delivery Optimization telemetry JSON after a DO download completes. Contains file size, peer caching stats, download mode, and duration.",
"enabled": true
}
+2 -1
View File
@@ -61,7 +61,8 @@
"cancelStuckAndSetCurrent",
"imeSessionChange",
"imeImpersonation",
"enrollmentCompleted"
"enrollmentCompleted",
"updateDoTelemetry"
]
},
"parameters": {
@@ -91,6 +91,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
public string ErrorPatternId { get; private set; }
public string ErrorDetail { get; private set; }
// Delivery Optimization telemetry (populated from [DO TEL] log entries)
public long DoFileSize { get; private set; }
public long DoTotalBytesDownloaded { get; private set; }
public long DoBytesFromPeers { get; private set; }
public int DoPercentPeerCaching { get; private set; }
public long DoBytesFromLanPeers { get; private set; }
public long DoBytesFromGroupPeers { get; private set; }
public long DoBytesFromInternetPeers { get; private set; }
public int DoDownloadMode { get; private set; } = -1;
public string DoDownloadDuration { get; private set; }
public long DoBytesFromHttp { get; private set; }
public bool HasDoTelemetry { get; private set; }
private static readonly Dictionary<Win32AppState, AppInstallationState> Win32StateMap =
new Dictionary<Win32AppState, AppInstallationState>
{
@@ -123,7 +136,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
HashSet<string> dependsOn,
AppInstallationState installationState, bool downloadingOrInstallingSeen,
int? progressPercent, long bytesDownloaded, long bytesTotal,
string errorPatternId = null, string errorDetail = null)
string errorPatternId = null, string errorDetail = null,
long doFileSize = 0, long doTotalBytesDownloaded = 0,
long doBytesFromPeers = 0, int doPercentPeerCaching = 0,
long doBytesFromLanPeers = 0, long doBytesFromGroupPeers = 0,
long doBytesFromInternetPeers = 0, int doDownloadMode = -1,
string doDownloadDuration = null, long doBytesFromHttp = 0,
bool hasDoTelemetry = false)
{
var pkg = new AppPackageState(id, listPos)
{
@@ -139,7 +158,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
BytesTotal = bytesTotal,
ErrorPatternId = errorPatternId,
ErrorDetail = errorDetail,
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp()
InstallationStateLastChangedTicks = Stopwatch.GetTimestamp(),
DoFileSize = doFileSize,
DoTotalBytesDownloaded = doTotalBytesDownloaded,
DoBytesFromPeers = doBytesFromPeers,
DoPercentPeerCaching = doPercentPeerCaching,
DoBytesFromLanPeers = doBytesFromLanPeers,
DoBytesFromGroupPeers = doBytesFromGroupPeers,
DoBytesFromInternetPeers = doBytesFromInternetPeers,
DoDownloadMode = doDownloadMode,
DoDownloadDuration = doDownloadDuration,
DoBytesFromHttp = doBytesFromHttp,
HasDoTelemetry = hasDoTelemetry
};
return pkg;
}
@@ -210,6 +240,28 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
ErrorDetail = detail;
}
/// <summary>
/// Updates Delivery Optimization telemetry data for this app package.
/// Called when a [DO TEL] log entry is matched and linked to this app.
/// </summary>
public void UpdateDoTelemetry(long fileSize, long totalBytesDownloaded,
long bytesFromPeers, int percentPeerCaching,
long bytesFromLanPeers, long bytesFromGroupPeers, long bytesFromInternetPeers,
int downloadMode, string downloadDuration, long bytesFromHttp)
{
DoFileSize = fileSize;
DoTotalBytesDownloaded = totalBytesDownloaded;
DoBytesFromPeers = bytesFromPeers;
DoPercentPeerCaching = percentPeerCaching;
DoBytesFromLanPeers = bytesFromLanPeers;
DoBytesFromGroupPeers = bytesFromGroupPeers;
DoBytesFromInternetPeers = bytesFromInternetPeers;
DoDownloadMode = downloadMode;
DoDownloadDuration = downloadDuration;
DoBytesFromHttp = bytesFromHttp;
HasDoTelemetry = true;
}
/// <summary>
/// Updates the installation state with transition protection.
/// Returns true if the state actually changed.
@@ -364,6 +416,20 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
if (!string.IsNullOrEmpty(ErrorDetail))
data["errorDetail"] = ErrorDetail;
if (HasDoTelemetry)
{
data["doFileSize"] = DoFileSize;
data["doTotalBytesDownloaded"] = DoTotalBytesDownloaded;
data["doBytesFromPeers"] = DoBytesFromPeers;
data["doPercentPeerCaching"] = DoPercentPeerCaching;
data["doBytesFromLanPeers"] = DoBytesFromLanPeers;
data["doBytesFromGroupPeers"] = DoBytesFromGroupPeers;
data["doBytesFromInternetPeers"] = DoBytesFromInternetPeers;
data["doDownloadMode"] = DoDownloadMode;
data["doDownloadDuration"] = DoDownloadDuration ?? "";
data["doBytesFromHttp"] = DoBytesFromHttp;
}
return data;
}
}
@@ -105,6 +105,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
_imeLogTracker.OnPoliciesDiscovered = HandlePoliciesDiscovered;
_imeLogTracker.OnAllAppsCompleted = HandleAllAppsCompleted;
_imeLogTracker.OnUserSessionCompleted = HandleUserSessionCompleted;
_imeLogTracker.OnDoTelemetryReceived = HandleDoTelemetryReceived;
// Subscribe to EspAndHelloTracker completion event if available
if (_espAndHelloTracker != null)
@@ -432,6 +433,39 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
}
}
private void HandleDoTelemetryReceived(AppPackageState app)
{
_logger.Info($"EnrollmentTracker: DO telemetry received for {app.Name ?? app.Id}");
var phase = EnrollmentPhase.Unknown;
// Dedicated do_telemetry event for backend aggregation
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "do_telemetry",
Severity = EventSeverity.Info,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO complete - {app.DoPercentPeerCaching}% peers, mode={app.DoDownloadMode}",
Data = app.ToEventData()
});
// Also emit download_progress so the UI picks up DO stats
_emitEvent(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
EventType = "download_progress",
Severity = EventSeverity.Debug,
Source = "ImeLogTracker",
Phase = phase,
Message = $"{app.Name ?? app.Id}: DO telemetry received",
Data = app.ToEventData()
});
}
private void HandleUserSessionCompleted()
{
_logger.Info("EnrollmentTracker: User session completed (detected from IME log)");
@@ -93,6 +93,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
public Action<string> OnPoliciesDiscovered { get; set; }
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<AppPackageState> OnDoTelemetryReceived { get; set; }
/// <summary>
/// Access to the tracked package states
@@ -289,7 +290,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
(AppRunAs)p.RunAs, (AppIntent)p.Intent, (AppTargeted)p.Targeted,
p.DependsOn != null ? new HashSet<string>(p.DependsOn) : new HashSet<string>(),
(AppInstallationState)p.InstallationState, p.DownloadingOrInstallingSeen,
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal);
p.ProgressPercent, p.BytesDownloaded, p.BytesTotal,
doFileSize: p.DoFileSize, doTotalBytesDownloaded: p.DoTotalBytesDownloaded,
doBytesFromPeers: p.DoBytesFromPeers, doPercentPeerCaching: p.DoPercentPeerCaching,
doBytesFromLanPeers: p.DoBytesFromLanPeers, doBytesFromGroupPeers: p.DoBytesFromGroupPeers,
doBytesFromInternetPeers: p.DoBytesFromInternetPeers, doDownloadMode: p.DoDownloadMode,
doDownloadDuration: p.DoDownloadDuration, doBytesFromHttp: p.DoBytesFromHttp,
hasDoTelemetry: p.HasDoTelemetry);
_packageStates.Add(pkg);
}
}
@@ -343,7 +350,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
DownloadingOrInstallingSeen = p.DownloadingOrInstallingSeen,
ProgressPercent = p.ProgressPercent,
BytesDownloaded = p.BytesDownloaded,
BytesTotal = p.BytesTotal
BytesTotal = p.BytesTotal,
DoFileSize = p.DoFileSize,
DoTotalBytesDownloaded = p.DoTotalBytesDownloaded,
DoBytesFromPeers = p.DoBytesFromPeers,
DoPercentPeerCaching = p.DoPercentPeerCaching,
DoBytesFromLanPeers = p.DoBytesFromLanPeers,
DoBytesFromGroupPeers = p.DoBytesFromGroupPeers,
DoBytesFromInternetPeers = p.DoBytesFromInternetPeers,
DoDownloadMode = p.DoDownloadMode,
DoDownloadDuration = p.DoDownloadDuration,
DoBytesFromHttp = p.DoBytesFromHttp,
HasDoTelemetry = p.HasDoTelemetry
}).ToList(),
FilePositions = new Dictionary<string, FilePositionData>()
};
@@ -605,6 +623,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
HandleCancelStuckAndSetCurrent(id);
break;
case "updatedotelemetry":
var doTelJson = match.Groups["doTelJson"]?.Value;
if (!string.IsNullOrEmpty(doTelJson))
HandleDoTelemetry(doTelJson);
break;
default:
_logger.Debug($"ImeLogTracker: unhandled action '{pattern.Action}' for pattern {pattern.PatternId}");
break;
@@ -616,6 +640,104 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
}
}
/// <summary>
/// Parses [DO TEL] JSON and links it to the correct app via FileId.
/// The FileId contains the app GUID in the format: ...intunewin-bin_{appGuid}_{number}
/// </summary>
private void HandleDoTelemetry(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
// Extract app GUID from FileId
string fileId = root.TryGetProperty("FileId", out var fileIdProp)
? fileIdProp.GetString() : null;
if (string.IsNullOrEmpty(fileId))
{
_logger.Debug("ImeLogTracker: DO TEL entry has no FileId, skipping");
return;
}
string appId = ExtractAppIdFromDoFileId(fileId);
if (string.IsNullOrEmpty(appId))
{
appId = _packageStates.CurrentPackageId;
_logger.Debug($"ImeLogTracker: Could not extract app ID from DO FileId, using current app: {appId}");
}
if (string.IsNullOrEmpty(appId)) return;
var pkg = _packageStates.GetPackage(appId);
if (pkg == null)
{
_logger.Debug($"ImeLogTracker: DO TEL for unknown app {appId}, skipping");
return;
}
// Extract all DO fields
long fileSize = root.TryGetProperty("FileSize", out var p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long totalBytes = root.TryGetProperty("TotalBytesDownloaded", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesFromPeers = root.TryGetProperty("BytesFromPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int peerCachingPct = root.TryGetProperty("PercentPeerCaching", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : 0;
long bytesLanPeers = root.TryGetProperty("BytesFromLanPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesGroupPeers = root.TryGetProperty("BytesFromGroupPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
long bytesInternetPeers = root.TryGetProperty("BytesFromInternetPeers", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
int downloadMode = root.TryGetProperty("DownloadMode", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt32() : -1;
string downloadDuration = root.TryGetProperty("DownloadDuration", out p) && p.ValueKind == JsonValueKind.String ? p.GetString() : null;
long bytesFromHttp = root.TryGetProperty("BytesFromHttp", out p) && p.ValueKind == JsonValueKind.Number ? p.GetInt64() : 0;
pkg.UpdateDoTelemetry(fileSize, totalBytes, bytesFromPeers, peerCachingPct,
bytesLanPeers, bytesGroupPeers, bytesInternetPeers,
downloadMode, downloadDuration, bytesFromHttp);
_logger.Info($"ImeLogTracker: DO TEL for {pkg.Name ?? appId}: " +
$"size={fileSize}, peers={bytesFromPeers} ({peerCachingPct}%), " +
$"http={bytesFromHttp}, mode={downloadMode}, duration={downloadDuration}");
OnDoTelemetryReceived?.Invoke(pkg);
_stateDirty = true;
}
catch (Exception ex)
{
_logger.Warning($"ImeLogTracker: Failed to parse DO TEL JSON: {ex.Message}");
}
}
/// <summary>
/// Extracts app GUID from DO FileId string.
/// Format: ...intunewin-bin_{appGuid}_{number}
/// Falls back to trying the second-to-last GUID-like segment.
/// </summary>
private static string ExtractAppIdFromDoFileId(string fileId)
{
// Primary: look for "intunewin-bin_" marker
const string marker = "intunewin-bin_";
var idx = fileId.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var afterMarker = fileId.Substring(idx + marker.Length);
if (afterMarker.Length >= 36)
{
var candidate = afterMarker.Substring(0, 36);
if (Guid.TryParse(candidate, out var guid))
return guid.ToString().ToLowerInvariant();
}
}
// Fallback: split by underscore and find GUIDs, take the second-to-last one
var segments = fileId.Split('_');
for (int i = segments.Length - 2; i >= 0; i--)
{
if (Guid.TryParse(segments[i], out var guid))
return guid.ToString().ToLowerInvariant();
}
return null;
}
private void HandleImeStarted()
{
_logger.Info("ImeLogTracker: IME Agent Started detected");
@@ -122,6 +122,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking
public int? ProgressPercent { get; set; }
public long BytesDownloaded { get; set; }
public long BytesTotal { get; set; }
// Delivery Optimization telemetry
public long DoFileSize { get; set; }
public long DoTotalBytesDownloaded { get; set; }
public long DoBytesFromPeers { get; set; }
public int DoPercentPeerCaching { get; set; }
public long DoBytesFromLanPeers { get; set; }
public long DoBytesFromGroupPeers { get; set; }
public long DoBytesFromInternetPeers { get; set; }
public int DoDownloadMode { get; set; } = -1;
public string DoDownloadDuration { get; set; }
public long DoBytesFromHttp { get; set; }
public bool HasDoTelemetry { get; set; }
}
public class FilePositionData
@@ -1 +1 @@
180
182
@@ -0,0 +1,91 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions
{
/// <summary>
/// Function for retrieving platform agent metrics (Galactic Admin only).
/// Returns per-session CPU, memory, network metrics with 5-minute backend cache.
/// </summary>
public class GetGalacticPlatformMetricsFunction
{
private readonly ILogger<GetGalacticPlatformMetricsFunction> _logger;
private readonly PlatformMetricsService _platformMetricsService;
private readonly GalacticAdminService _galacticAdminService;
public GetGalacticPlatformMetricsFunction(
ILogger<GetGalacticPlatformMetricsFunction> logger,
PlatformMetricsService platformMetricsService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_platformMetricsService = platformMetricsService;
_galacticAdminService = galacticAdminService;
}
[Function("GetGalacticPlatformMetrics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "galactic/metrics/platform")] HttpRequestData req)
{
_logger.LogInformation("Platform agent metrics requested");
try
{
// Validate authentication
if (!TenantHelper.IsAuthenticated(req))
{
_logger.LogWarning("Unauthenticated platform metrics attempt");
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauthorizedResponse.WriteAsJsonAsync(new
{
success = false,
message = "Authentication required. Please provide a valid JWT token."
});
return unauthorizedResponse;
}
// Check if user is Galactic Admin
var userEmail = TenantHelper.GetUserIdentifier(req);
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail);
if (!isGalacticAdmin)
{
_logger.LogWarning($"Non-Galactic Admin user {userEmail} attempted to access platform metrics");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new
{
success = false,
message = "Access denied. Galactic Admin role required."
});
return forbiddenResponse;
}
_logger.LogInformation($"Platform agent metrics accessed by Galactic Admin: {userEmail}");
var metrics = await _platformMetricsService.ComputePlatformMetricsAsync();
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(metrics);
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error computing platform agent metrics");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Failed to compute platform agent metrics"
});
return errorResponse;
}
}
}
}
@@ -702,7 +702,8 @@ namespace AutopilotMonitor.Functions.Functions
evt.EventType == "app_install_failed" ||
evt.EventType == "app_download_started" ||
evt.EventType == "app_install_skipped" ||
evt.EventType == "download_progress";
evt.EventType == "download_progress" ||
evt.EventType == "do_telemetry";
if (!isRelevant) return;
@@ -776,6 +777,30 @@ namespace AutopilotMonitor.Functions.Functions
if (bytesKey != null && long.TryParse(evt.Data![bytesKey]?.ToString(), out var bytes))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, bytes);
break;
case "do_telemetry":
if (evt.Data != null)
{
if (evt.Data.ContainsKey("doFileSize") && long.TryParse(evt.Data["doFileSize"]?.ToString(), out var doFs))
summary.DownloadBytes = Math.Max(summary.DownloadBytes, doFs);
if (evt.Data.ContainsKey("doBytesFromPeers") && long.TryParse(evt.Data["doBytesFromPeers"]?.ToString(), out var doPeers))
summary.DoBytesFromPeers = doPeers;
if (evt.Data.ContainsKey("doBytesFromHttp") && long.TryParse(evt.Data["doBytesFromHttp"]?.ToString(), out var doHttp))
summary.DoBytesFromHttp = doHttp;
if (evt.Data.ContainsKey("doPercentPeerCaching") && int.TryParse(evt.Data["doPercentPeerCaching"]?.ToString(), out var doPct))
summary.DoPercentPeerCaching = doPct;
if (evt.Data.ContainsKey("doDownloadMode") && int.TryParse(evt.Data["doDownloadMode"]?.ToString(), out var doMode))
summary.DoDownloadMode = doMode;
if (evt.Data.ContainsKey("doDownloadDuration"))
summary.DoDownloadDuration = evt.Data["doDownloadDuration"]?.ToString() ?? string.Empty;
if (evt.Data.ContainsKey("doBytesFromLanPeers") && long.TryParse(evt.Data["doBytesFromLanPeers"]?.ToString(), out var doLan))
summary.DoBytesFromLanPeers = doLan;
if (evt.Data.ContainsKey("doBytesFromGroupPeers") && long.TryParse(evt.Data["doBytesFromGroupPeers"]?.ToString(), out var doGroup))
summary.DoBytesFromGroupPeers = doGroup;
if (evt.Data.ContainsKey("doBytesFromInternetPeers") && long.TryParse(evt.Data["doBytesFromInternetPeers"]?.ToString(), out var doInet))
summary.DoBytesFromInternetPeers = doInet;
}
break;
}
RecalculateAppDurations(state);
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<TenantConfigurationService>();
builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<UsageMetricsService>();
builder.Services.AddSingleton<PlatformMetricsService>();
builder.Services.AddSingleton<GalacticAdminService>();
builder.Services.AddSingleton<PreviewWhitelistService>();
builder.Services.AddSingleton<TenantAdminsService>();
@@ -0,0 +1,204 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Service for computing platform agent metrics (CPU, memory, network per session).
/// Fetches sessions + their events server-side and caches the result for 5 minutes.
/// </summary>
public class PlatformMetricsService
{
private readonly TableStorageService _storageService;
private readonly ILogger<PlatformMetricsService> _logger;
// In-memory cache (same pattern as UsageMetricsService)
private static PlatformAgentMetricsResponse? _cachedMetrics;
private static DateTime _cacheExpiry = DateTime.MinValue;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private static readonly object _cacheLock = new object();
public PlatformMetricsService(
TableStorageService storageService,
ILogger<PlatformMetricsService> logger)
{
_storageService = storageService;
_logger = logger;
}
/// <summary>
/// Computes platform agent metrics (with 5-minute cache)
/// </summary>
public async Task<PlatformAgentMetricsResponse> ComputePlatformMetricsAsync()
{
// Check cache first
lock (_cacheLock)
{
if (_cachedMetrics != null && DateTime.UtcNow < _cacheExpiry)
{
_logger.LogInformation("Returning cached platform metrics (expires in {Seconds}s)",
(_cacheExpiry - DateTime.UtcNow).TotalSeconds);
_cachedMetrics.FromCache = true;
return _cachedMetrics;
}
}
// Compute fresh metrics
_logger.LogInformation("Computing fresh platform agent metrics...");
var stopwatch = Stopwatch.StartNew();
var metrics = await ComputePlatformMetricsInternalAsync();
stopwatch.Stop();
metrics.ComputeDurationMs = (int)stopwatch.ElapsedMilliseconds;
metrics.ComputedAt = DateTime.UtcNow;
metrics.FromCache = false;
_logger.LogInformation("Platform agent metrics computed in {Ms}ms", metrics.ComputeDurationMs);
// Update cache
lock (_cacheLock)
{
_cachedMetrics = metrics;
_cacheExpiry = DateTime.UtcNow.Add(CacheDuration);
}
return metrics;
}
private async Task<PlatformAgentMetricsResponse> ComputePlatformMetricsInternalAsync()
{
// 1. Fetch the 100 most recent sessions across all tenants
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 100);
if (allSessions.Count == 0)
{
return new PlatformAgentMetricsResponse { Sessions = new List<SessionAgentMetric>() };
}
// 2. For each session, fetch events and extract agent_metrics_snapshot data
var sessionMetrics = new List<SessionAgentMetric>();
var tasks = allSessions.Select(async session =>
{
try
{
var events = await _storageService.GetSessionEventsAsync(session.TenantId, session.SessionId);
var snapshots = events
.Where(e => e.EventType == "agent_metrics_snapshot" && e.Data != null)
.Select(e => e.Data)
.ToList();
if (snapshots.Count == 0) return null;
var cpuValues = snapshots.Select(s => GetDouble(s, "agent_cpu_percent")).ToList();
var wsValues = snapshots.Select(s => GetDouble(s, "agent_working_set_mb")).ToList();
var pbValues = snapshots.Select(s => GetDouble(s, "agent_private_bytes_mb")).ToList();
var latValues = snapshots.Select(s => GetDouble(s, "net_avg_latency_ms")).Where(v => v > 0).ToList();
var lastSnapshot = snapshots.Last();
// Resolve agent version: prefer from snapshot, fallback to session
var agentVersion = snapshots
.Select(s => GetString(s, "agent_version"))
.FirstOrDefault(v => !string.IsNullOrEmpty(v))
?? session.AgentVersion
?? "unknown";
return new SessionAgentMetric
{
SessionId = session.SessionId,
TenantId = session.TenantId,
DeviceName = session.DeviceName,
Manufacturer = session.Manufacturer,
Model = session.Model,
StartedAt = session.StartedAt.ToString("o"),
Status = session.Status.ToString(),
AgentVersion = agentVersion,
SnapshotCount = snapshots.Count,
TotalBytesUp = GetDouble(lastSnapshot, "net_total_bytes_up"),
TotalBytesDown = GetDouble(lastSnapshot, "net_total_bytes_down"),
TotalRequests = GetDouble(lastSnapshot, "net_total_requests"),
AvgCpu = cpuValues.Count > 0 ? cpuValues.Average() : 0,
MaxCpu = cpuValues.Count > 0 ? cpuValues.Max() : 0,
AvgWorkingSet = wsValues.Count > 0 ? wsValues.Average() : 0,
MaxWorkingSet = wsValues.Count > 0 ? wsValues.Max() : 0,
AvgPrivateBytes = pbValues.Count > 0 ? pbValues.Average() : 0,
AvgLatency = latValues.Count > 0 ? latValues.Average() : 0
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch events for session {SessionId}", session.SessionId);
return null;
}
}).ToList();
var results = await Task.WhenAll(tasks);
sessionMetrics = results.Where(r => r != null).ToList()!;
return new PlatformAgentMetricsResponse
{
Sessions = sessionMetrics
};
}
private static double GetDouble(Dictionary<string, object> data, string key)
{
if (data.TryGetValue(key, out var value))
{
if (value is double d) return d;
if (value is int i) return i;
if (value is long l) return l;
if (value is float f) return f;
if (double.TryParse(value?.ToString(), out var parsed)) return parsed;
}
return 0;
}
private static string GetString(Dictionary<string, object> data, string key)
{
if (data.TryGetValue(key, out var value))
{
return value?.ToString() ?? string.Empty;
}
return string.Empty;
}
}
// ── Response DTOs ────────────────────────────────────────────────────────────
public class PlatformAgentMetricsResponse
{
public List<SessionAgentMetric> Sessions { get; set; } = new();
public DateTime ComputedAt { get; set; }
public int ComputeDurationMs { get; set; }
public bool FromCache { get; set; }
}
public class SessionAgentMetric
{
public string SessionId { get; set; } = string.Empty;
public string TenantId { get; set; } = string.Empty;
public string? DeviceName { get; set; }
public string? Manufacturer { get; set; }
public string? Model { get; set; }
public string? StartedAt { get; set; }
public string? Status { get; set; }
public string? AgentVersion { get; set; }
public int SnapshotCount { get; set; }
public double TotalBytesUp { get; set; }
public double TotalBytesDown { get; set; }
public double TotalRequests { get; set; }
public double AvgCpu { get; set; }
public double MaxCpu { get; set; }
public double AvgWorkingSet { get; set; }
public double MaxWorkingSet { get; set; }
public double AvgPrivateBytes { get; set; }
public double AvgLatency { get; set; }
}
}
@@ -35,5 +35,30 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>When this app install completed or failed</summary>
public DateTime? CompletedAt { get; set; }
// Delivery Optimization telemetry
/// <summary>DO: bytes from all peer sources</summary>
public long DoBytesFromPeers { get; set; }
/// <summary>DO: bytes from HTTP (CDN)</summary>
public long DoBytesFromHttp { get; set; }
/// <summary>DO: percentage from P2P (0-100)</summary>
public int DoPercentPeerCaching { get; set; }
/// <summary>DO: download mode (0=Background, 1=Foreground, 2=Bypass/LAN, 99=Simple)</summary>
public int DoDownloadMode { get; set; } = -1;
/// <summary>DO: actual download duration (TimeSpan string)</summary>
public string DoDownloadDuration { get; set; } = string.Empty;
/// <summary>DO: bytes from LAN peers</summary>
public long DoBytesFromLanPeers { get; set; }
/// <summary>DO: bytes from group peers</summary>
public long DoBytesFromGroupPeers { get; set; }
/// <summary>DO: bytes from internet peers</summary>
public long DoBytesFromInternetPeers { get; set; }
}
}
@@ -34,6 +34,8 @@ export default function AuditPage() {
const [actionFilter, setActionFilter] = useState<ActionFilter>('ALL');
const [entityTypeFilter, setEntityTypeFilter] = useState<EntityTypeFilter>('ALL');
const [expandedRow, setExpandedRow] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const logsPerPage = 12;
const [galacticAdminMode] = useState(() => {
if (typeof window !== 'undefined') {
@@ -108,6 +110,23 @@ export default function AuditPage() {
return true;
});
// Reset to page 1 when filters change
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, actionFilter, entityTypeFilter]);
const totalPages = Math.ceil(filteredLogs.length / logsPerPage);
const startIndex = (currentPage - 1) * logsPerPage;
const paginatedLogs = filteredLogs.slice(startIndex, startIndex + logsPerPage);
const handlePreviousPage = () => {
setCurrentPage(prev => Math.max(1, prev - 1));
};
const handleNextPage = () => {
setCurrentPage(prev => Math.min(totalPages, prev + 1));
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleString();
@@ -235,7 +254,7 @@ export default function AuditPage() {
{/* Audit Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{filteredLogs.length === 0 ? (
{paginatedLogs.length === 0 ? (
<div className="p-12 text-center">
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -271,7 +290,7 @@ export default function AuditPage() {
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredLogs.map((log) => {
{paginatedLogs.map((log) => {
const details = parseDetails(log.details);
const isExpanded = expandedRow === log.id;
@@ -333,6 +352,31 @@ export default function AuditPage() {
</div>
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {currentPage} of {totalPages} ({filteredLogs.length} total entries)
</div>
<div className="flex gap-2">
<button
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
&larr; Previous
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next &rarr;
</button>
</div>
</div>
)}
</div>
</div>
</ProtectedRoute>
@@ -9,46 +9,37 @@ 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;
agent_version?: string;
}
interface EnrollmentEvent {
eventType: string;
timestamp: string;
data?: AgentMetricsData;
sessionId?: string;
}
interface Session {
interface SessionAgentMetricDTO {
sessionId: string;
tenantId: string;
serialNumber?: string;
deviceName?: string;
manufacturer?: string;
model?: string;
deviceName?: string;
startedAt?: string;
status?: string;
agentVersion?: string;
snapshotCount: number;
totalBytesUp: number;
totalBytesDown: number;
totalRequests: number;
avgCpu: number;
maxCpu: number;
avgWorkingSet: number;
maxWorkingSet: number;
avgPrivateBytes: number;
avgLatency: number;
}
interface PlatformMetricsResponse {
sessions: SessionAgentMetricDTO[];
computedAt: string;
computeDurationMs: number;
fromCache: boolean;
}
interface SessionAgentMetrics {
session: Session;
snapshots: AgentMetricsData[];
// Final cumulative values from the last snapshot
session: { sessionId: string; tenantId: string; deviceName?: string; manufacturer?: string; model?: string; startedAt?: string; status?: string; agentVersion?: string };
snapshotCount: number;
totalBytesUp: number;
totalBytesDown: number;
totalRequests: number;
@@ -99,8 +90,9 @@ export default function PlatformMetricsPage() {
const [sampleSize, setSampleSize] = useState(20);
const [error, setError] = useState<string | null>(null);
const [versionFilter, setVersionFilter] = useState<string>('all');
const [cacheInfo, setCacheInfo] = useState<{ fromCache: boolean; computeDurationMs: number; computedAt: string } | null>(null);
// ── Fetch sessions + their events ──────────────────────────────────────────
// ── Fetch pre-computed metrics from backend (with 5-min cache) ────────────
const fetchMetrics = useCallback(async () => {
setLoading(true);
@@ -113,100 +105,62 @@ export default function PlatformMetricsPage() {
return;
}
// 1. Fetch all sessions (galactic endpoint)
const sessionsRes = await fetch(`${API_BASE_URL}/api/galactic/sessions`, {
const res = await fetch(`${API_BASE_URL}/api/galactic/metrics/platform`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!sessionsRes.ok) {
if (sessionsRes.status === 403) {
if (!res.ok) {
if (res.status === 403) {
setError('Access denied. Galactic Admin privileges required.');
} else {
setError(`Failed to fetch sessions: ${sessionsRes.status}`);
setError(`Failed to fetch platform metrics: ${res.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);
const data: PlatformMetricsResponse = await res.json();
setCacheInfo({ fromCache: data.fromCache, computeDurationMs: data.computeDurationMs, computedAt: data.computedAt });
if (sorted.length === 0) {
setSessionMetrics([]);
return;
}
const mapped: SessionAgentMetrics[] = (data.sessions || []).map((s) => ({
session: {
sessionId: s.sessionId,
tenantId: s.tenantId,
deviceName: s.deviceName,
manufacturer: s.manufacturer,
model: s.model,
startedAt: s.startedAt,
status: s.status,
agentVersion: s.agentVersion,
},
snapshotCount: s.snapshotCount,
totalBytesUp: s.totalBytesUp,
totalBytesDown: s.totalBytesDown,
totalRequests: s.totalRequests,
avgCpu: s.avgCpu,
maxCpu: s.maxCpu,
avgWorkingSet: s.avgWorkingSet,
maxWorkingSet: s.maxWorkingSet,
avgPrivateBytes: s.avgPrivateBytes,
avgLatency: s.avgLatency,
}));
// 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);
setSessionMetrics(mapped);
} catch (err) {
console.error('Platform metrics fetch error:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [getAccessToken, sampleSize, addNotification]);
}, [getAccessToken, addNotification]);
useEffect(() => {
fetchMetrics();
}, [fetchMetrics]);
// ── Resolve agent version per session (prefer snapshot data, fallback to session registration) ──
// ── Resolve agent version per session (pre-resolved by backend) ──
const resolveVersion = useCallback((sm: SessionAgentMetrics): string => {
const fromSnapshot = sm.snapshots.find((s) => s.agent_version)?.agent_version;
return fromSnapshot || sm.session.agentVersion || 'unknown';
return sm.session.agentVersion || 'unknown';
}, []);
// ── Available versions for filter dropdown ─────────────────────────────────
@@ -219,12 +173,16 @@ export default function PlatformMetricsPage() {
return Array.from(versions).sort();
}, [sessionMetrics, resolveVersion]);
// ── Filtered metrics based on version filter ───────────────────────────────
// ── Filtered metrics based on sample size and version filter ────────────────
const filteredMetrics = useMemo(() => {
if (versionFilter === 'all') return sessionMetrics;
return sessionMetrics.filter((sm) => resolveVersion(sm) === versionFilter);
}, [sessionMetrics, versionFilter, resolveVersion]);
// Sort by startedAt desc and take sampleSize most recent
const sorted = [...sessionMetrics]
.sort((a, b) => new Date(b.session.startedAt || 0).getTime() - new Date(a.session.startedAt || 0).getTime())
.slice(0, sampleSize);
if (versionFilter === 'all') return sorted;
return sorted.filter((sm) => resolveVersion(sm) === versionFilter);
}, [sessionMetrics, sampleSize, versionFilter, resolveVersion]);
// ── Aggregated stats across filtered sessions ──────────────────────────────
@@ -240,7 +198,7 @@ export default function PlatformMetricsPage() {
const allBytesUp = filteredMetrics.map((s) => s.totalBytesUp);
const allBytesDown = filteredMetrics.map((s) => s.totalBytesDown);
const allRequests = filteredMetrics.map((s) => s.totalRequests);
const totalSnapshots = filteredMetrics.reduce((sum, s) => sum + s.snapshots.length, 0);
const totalSnapshots = filteredMetrics.reduce((sum, s) => sum + s.snapshotCount, 0);
return {
sessionsAnalyzed: filteredMetrics.length,
@@ -296,6 +254,13 @@ export default function PlatformMetricsPage() {
&larr; Back to Dashboard
</button>
<h1 className="text-3xl font-bold text-gray-900">Platform Metrics</h1>
{cacheInfo && (
<p className="text-xs text-gray-400 mt-1">
{cacheInfo.fromCache ? 'From cache' : `Computed in ${cacheInfo.computeDurationMs}ms`}
{' · '}
{new Date(cacheInfo.computedAt).toLocaleTimeString()}
</p>
)}
</div>
<div className="flex items-center gap-3">
<label className="text-sm text-gray-500">Sessions to analyze:</label>
@@ -350,7 +315,7 @@ export default function PlatformMetricsPage() {
<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>
<span>Fetching agent metrics...</span>
</div>
</div>
)}
@@ -23,6 +23,18 @@ interface DownloadProgressProps {
summaryStats?: SummaryStats | null;
}
interface DoStats {
fileSize: number;
bytesFromPeers: number;
bytesFromHttp: number;
percentPeerCaching: number;
downloadMode: number;
downloadDuration: string;
bytesFromLanPeers: number;
bytesFromGroupPeers: number;
bytesFromInternetPeers: number;
}
interface DownloadItem {
appName: string;
bytesDownloaded: number;
@@ -34,6 +46,7 @@ interface DownloadItem {
isSkipped: boolean;
firstSeenIndex: number;
eventData?: Record<string, any>;
doStats?: DoStats | null;
}
function formatBytes(bytes: number): string {
@@ -52,6 +65,31 @@ function formatSpeed(bps: number): string {
return `${(bps / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatDoMode(mode: number): string {
switch (mode) {
case 0: return "Background";
case 1: return "Foreground";
case 2: return "Bypass/LAN Only";
case 99: return "Simple";
case 100: return "Bypass";
default: return `Mode ${mode}`;
}
}
function formatDoDuration(duration: string): string {
if (!duration) return "N/A";
const match = duration.match(/^(\d+):(\d+):(\d+)\.?(\d+)?$/);
if (!match) return duration;
const hours = parseInt(match[1], 10);
const mins = parseInt(match[2], 10);
const secs = parseInt(match[3], 10);
const ms = match[4] ? parseInt(match[4].substring(0, 3), 10) : 0;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
if (secs > 0) return ms > 0 ? `${secs}.${ms}s` : `${secs}s`;
return `${ms}ms`;
}
export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) {
const downloads = useMemo(() => {
if (events.length === 0) return [];
@@ -123,6 +161,22 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
}
}
// Extract DO telemetry if present in event data
let doStats: DoStats | null = existing?.doStats ?? null;
if (d.doFileSize !== undefined || d.doBytesFromPeers !== undefined || d.doDownloadMode !== undefined) {
doStats = {
fileSize: parseInt(d.doFileSize ?? "0", 10),
bytesFromPeers: parseInt(d.doBytesFromPeers ?? "0", 10),
bytesFromHttp: parseInt(d.doBytesFromHttp ?? "0", 10),
percentPeerCaching: parseInt(d.doPercentPeerCaching ?? "0", 10),
downloadMode: parseInt(d.doDownloadMode ?? "-1", 10),
downloadDuration: d.doDownloadDuration ?? "",
bytesFromLanPeers: parseInt(d.doBytesFromLanPeers ?? "0", 10),
bytesFromGroupPeers: parseInt(d.doBytesFromGroupPeers ?? "0", 10),
bytesFromInternetPeers: parseInt(d.doBytesFromInternetPeers ?? "0", 10),
};
}
downloadMap.set(appName, {
appName,
bytesDownloaded: isNaN(bytesDownloaded) ? 0 : bytesDownloaded,
@@ -134,6 +188,7 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
isSkipped: isSkippedEvent || (existing?.isSkipped ?? false),
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
eventData: d,
doStats,
});
}
@@ -291,6 +346,64 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
</div>
)}
{/* Delivery Optimization stats */}
{dl.doStats && dl.doStats.fileSize > 0 && (
<div className="mt-2 p-2 bg-blue-50 rounded border border-blue-100">
<div className="flex items-center space-x-1 mb-1">
<svg className="w-3.5 h-3.5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-xs font-medium text-blue-700">Delivery Optimization</span>
<span className="text-xs text-blue-500">
({formatDoMode(dl.doStats.downloadMode)})
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
<div>
<span className="text-gray-500">Duration:</span>{" "}
<span className="font-medium">{formatDoDuration(dl.doStats.downloadDuration)}</span>
</div>
<div>
<span className="text-gray-500">File Size:</span>{" "}
<span className="font-medium">{formatBytes(dl.doStats.fileSize)}</span>
</div>
<div>
<span className="text-gray-500">From HTTP:</span>{" "}
<span className="font-medium">{formatBytes(dl.doStats.bytesFromHttp)}</span>
</div>
<div>
<span className="text-gray-500">From Peers:</span>{" "}
<span className="font-medium">
{formatBytes(dl.doStats.bytesFromPeers)}
{dl.doStats.percentPeerCaching > 0 && (
<span className="text-green-600 ml-1">({dl.doStats.percentPeerCaching}%)</span>
)}
</span>
</div>
{dl.doStats.bytesFromPeers > 0 && (
<div className="col-span-2 mt-1 text-xs text-gray-500">
Peer breakdown: LAN {formatBytes(dl.doStats.bytesFromLanPeers)}
{" | "}Group {formatBytes(dl.doStats.bytesFromGroupPeers)}
{" | "}Internet {formatBytes(dl.doStats.bytesFromInternetPeers)}
</div>
)}
</div>
{/* Peer vs HTTP visual bar */}
<div className="mt-1.5 w-full h-1.5 bg-gray-200 rounded-full overflow-hidden flex">
<div
className="h-full bg-green-400"
style={{ width: `${dl.doStats.percentPeerCaching}%` }}
title={`${dl.doStats.percentPeerCaching}% from peers`}
/>
<div
className="h-full bg-blue-400"
style={{ width: `${100 - dl.doStats.percentPeerCaching}%` }}
title={`${100 - dl.doStats.percentPeerCaching}% from HTTP`}
/>
</div>
</div>
)}
{/* Event details (expandable) */}
{showDetails && dl.eventData && (
<div className="mt-3 p-2 bg-gray-900 rounded text-xs text-gray-100 font-mono overflow-x-auto">
File diff suppressed because one or more lines are too long