mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
+8
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+91
@@ -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"
|
||||
>
|
||||
← 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 →
|
||||
</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() {
|
||||
← 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
Reference in New Issue
Block a user