mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added Geographic Performance Heatmap
This commit is contained in:
@@ -1 +1 @@
|
||||
219
|
||||
223
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
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
|
||||
{
|
||||
public class GetGalacticGeographicMetricsFunction
|
||||
{
|
||||
private readonly ILogger<GetGalacticGeographicMetricsFunction> _logger;
|
||||
private readonly TableStorageService _storageService;
|
||||
private readonly GalacticAdminService _galacticAdminService;
|
||||
|
||||
public GetGalacticGeographicMetricsFunction(
|
||||
ILogger<GetGalacticGeographicMetricsFunction> logger,
|
||||
TableStorageService storageService,
|
||||
GalacticAdminService galacticAdminService)
|
||||
{
|
||||
_logger = logger;
|
||||
_storageService = storageService;
|
||||
_galacticAdminService = galacticAdminService;
|
||||
}
|
||||
|
||||
[Function("GetGalacticGeographicMetrics")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "galactic/metrics/geographic")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TenantHelper.IsAuthenticated(req))
|
||||
{
|
||||
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
|
||||
return unauthorizedResponse;
|
||||
}
|
||||
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userEmail);
|
||||
|
||||
if (!isGalacticAdmin)
|
||||
{
|
||||
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied. Galactic Admin role required." });
|
||||
return forbiddenResponse;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Fetching galactic geographic metrics (User: {UserEmail})", userEmail);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
var sessions = await _storageService.GetAllSessionsAsync(maxResults: 10000, since: cutoff);
|
||||
var allSummaries = await _storageService.GetAllAppInstallSummariesAsync();
|
||||
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
|
||||
|
||||
var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(result);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching galactic geographic metrics");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
public class GetGeographicMetricsFunction
|
||||
{
|
||||
private readonly ILogger<GetGeographicMetricsFunction> _logger;
|
||||
private readonly TableStorageService _storageService;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
|
||||
public GetGeographicMetricsFunction(ILogger<GetGeographicMetricsFunction> logger, TableStorageService storageService, TenantConfigurationService configService)
|
||||
{
|
||||
_logger = logger;
|
||||
_storageService = storageService;
|
||||
_configService = configService;
|
||||
}
|
||||
|
||||
[Function("GetGeographicMetrics")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "metrics/geographic")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TenantHelper.IsAuthenticated(req))
|
||||
{
|
||||
var unauthorizedResponse = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await unauthorizedResponse.WriteAsJsonAsync(new { success = false, message = "Authentication required." });
|
||||
return unauthorizedResponse;
|
||||
}
|
||||
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
_logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId);
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var daysParam = query["days"];
|
||||
int days = 30;
|
||||
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
|
||||
days = parsedDays;
|
||||
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
// Check if geo-location is enabled for this tenant
|
||||
var tenantConfig = await _configService.GetConfigurationAsync(tenantId);
|
||||
var geoEnabled = tenantConfig?.EnableGeoLocation ?? true;
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 10000, since: cutoff);
|
||||
var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
|
||||
|
||||
var result = ComputeGeographicMetrics(sessions, summaries, groupBy);
|
||||
result.GeoLocationEnabled = geoEnabled;
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(result);
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching geographic metrics");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
internal static GeographicMetricsResponse ComputeGeographicMetrics(
|
||||
List<SessionSummary> sessions, List<AppInstallSummary> appSummaries, string groupBy)
|
||||
{
|
||||
// Filter to sessions with geo data
|
||||
var geoSessions = sessions.Where(s => !string.IsNullOrEmpty(s.GeoCountry)).ToList();
|
||||
|
||||
// Build app install lookup by session
|
||||
var appsBySession = appSummaries
|
||||
.GroupBy(a => a.SessionId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Group sessions by location key
|
||||
var groups = geoSessions.GroupBy(s => GetLocationKey(s, groupBy)).ToList();
|
||||
|
||||
var locations = new List<LocationMetrics>();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var first = group.First();
|
||||
var succeeded = group.Where(s => s.Status == SessionStatus.Succeeded).ToList();
|
||||
var failed = group.Where(s => s.Status == SessionStatus.Failed).ToList();
|
||||
|
||||
// Duration stats from succeeded sessions with valid duration
|
||||
var durations = succeeded
|
||||
.Where(s => s.DurationSeconds.HasValue && s.DurationSeconds.Value > 0)
|
||||
.Select(s => s.DurationSeconds!.Value / 60.0)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
var avgDuration = durations.Count > 0 ? durations.Average() : 0;
|
||||
var medianDuration = durations.Count > 0 ? durations[durations.Count / 2] : 0;
|
||||
var p95Duration = durations.Count > 0 ? durations[(int)(durations.Count * 0.95)] : 0;
|
||||
|
||||
// App count per session
|
||||
var sessionAppCounts = group.Select(s =>
|
||||
appsBySession.ContainsKey(s.SessionId) ? appsBySession[s.SessionId].Count : 0
|
||||
).Where(c => c > 0).ToList();
|
||||
var avgAppCount = sessionAppCounts.Count > 0 ? sessionAppCounts.Average() : 0;
|
||||
var minutesPerApp = avgAppCount > 0 ? avgDuration / avgAppCount : 0;
|
||||
|
||||
// Network throughput from app install summaries
|
||||
var locationApps = group
|
||||
.Where(s => appsBySession.ContainsKey(s.SessionId))
|
||||
.SelectMany(s => appsBySession[s.SessionId])
|
||||
.Where(a => a.DownloadDurationSeconds > 0 && a.DownloadBytes > 0)
|
||||
.ToList();
|
||||
var totalBytes = locationApps.Sum(a => a.DownloadBytes);
|
||||
var totalDownloadSecs = locationApps.Sum(a => (double)a.DownloadDurationSeconds);
|
||||
var avgThroughput = totalDownloadSecs > 0 ? totalBytes / totalDownloadSecs : 0;
|
||||
|
||||
locations.Add(new LocationMetrics
|
||||
{
|
||||
LocationKey = group.Key,
|
||||
Country = first.GeoCountry,
|
||||
Region = first.GeoRegion,
|
||||
City = first.GeoCity,
|
||||
Loc = first.GeoLoc,
|
||||
SessionCount = group.Count(),
|
||||
Succeeded = succeeded.Count,
|
||||
Failed = failed.Count,
|
||||
SuccessRate = group.Count() > 0 ? Math.Round((double)succeeded.Count / group.Count() * 100, 1) : 0,
|
||||
AvgDurationMinutes = Math.Round(avgDuration, 1),
|
||||
MedianDurationMinutes = Math.Round(medianDuration, 1),
|
||||
P95DurationMinutes = Math.Round(p95Duration, 1),
|
||||
AvgAppCount = Math.Round(avgAppCount, 1),
|
||||
MinutesPerApp = Math.Round(minutesPerApp, 2),
|
||||
AvgThroughputBytesPerSec = Math.Round(avgThroughput, 0),
|
||||
TotalDownloadBytes = totalBytes
|
||||
});
|
||||
}
|
||||
|
||||
// Compute global averages
|
||||
var allDurations = locations.Where(l => l.SessionCount >= 3 && l.AvgDurationMinutes > 0)
|
||||
.Select(l => l.AvgDurationMinutes).ToList();
|
||||
var globalAvgDuration = allDurations.Count > 0 ? allDurations.Average() : 0;
|
||||
var globalMedianDuration = allDurations.Count > 0 ? allDurations.OrderBy(d => d).ToList()[allDurations.Count / 2] : 0;
|
||||
var globalStdDev = allDurations.Count > 1
|
||||
? Math.Sqrt(allDurations.Sum(d => Math.Pow(d - globalAvgDuration, 2)) / (allDurations.Count - 1))
|
||||
: 0;
|
||||
|
||||
var allMinutesPerApp = locations.Where(l => l.MinutesPerApp > 0).Select(l => l.MinutesPerApp).ToList();
|
||||
var globalAvgMinutesPerApp = allMinutesPerApp.Count > 0 ? allMinutesPerApp.Average() : 0;
|
||||
var globalMedianMinutesPerApp = allMinutesPerApp.Count > 0
|
||||
? allMinutesPerApp.OrderBy(m => m).ToList()[allMinutesPerApp.Count / 2] : 0;
|
||||
|
||||
var allThroughputs = locations.Where(l => l.AvgThroughputBytesPerSec > 0)
|
||||
.Select(l => l.AvgThroughputBytesPerSec).ToList();
|
||||
var globalAvgThroughput = allThroughputs.Count > 0 ? allThroughputs.Average() : 0;
|
||||
|
||||
// Compute per-location comparisons and outlier detection
|
||||
foreach (var loc in locations)
|
||||
{
|
||||
// Duration vs global
|
||||
loc.DurationVsGlobalPct = globalAvgDuration > 0
|
||||
? Math.Round((loc.AvgDurationMinutes - globalAvgDuration) / globalAvgDuration * 100, 1) : 0;
|
||||
|
||||
// Throughput vs global
|
||||
loc.ThroughputVsGlobalPct = globalAvgThroughput > 0
|
||||
? Math.Round((loc.AvgThroughputBytesPerSec - globalAvgThroughput) / globalAvgThroughput * 100, 1) : 0;
|
||||
|
||||
// AppLoadScore: normalize minutesPerApp to global median = 100
|
||||
loc.AppLoadScore = globalMedianMinutesPerApp > 0
|
||||
? Math.Round(loc.MinutesPerApp / globalMedianMinutesPerApp * 100, 0) : 0;
|
||||
|
||||
// Outlier detection (minimum 3 sessions)
|
||||
if (loc.SessionCount >= 3 && globalStdDev > 0 && loc.AvgDurationMinutes > 0)
|
||||
{
|
||||
var zScore = (loc.AvgDurationMinutes - globalAvgDuration) / globalStdDev;
|
||||
loc.IsOutlier = Math.Abs(zScore) > 2;
|
||||
loc.OutlierDirection = zScore > 2 ? "slow" : zScore < -2 ? "fast" : null;
|
||||
}
|
||||
}
|
||||
|
||||
return new GeographicMetricsResponse
|
||||
{
|
||||
Success = true,
|
||||
Locations = locations.OrderByDescending(l => l.SessionCount).ToList(),
|
||||
GlobalAverages = new GlobalAverages
|
||||
{
|
||||
AvgDurationMinutes = Math.Round(globalAvgDuration, 1),
|
||||
MedianDurationMinutes = Math.Round(globalMedianDuration, 1),
|
||||
AvgMinutesPerApp = Math.Round(globalAvgMinutesPerApp, 2),
|
||||
AvgThroughputBytesPerSec = Math.Round(globalAvgThroughput, 0),
|
||||
StdDevDurationMinutes = Math.Round(globalStdDev, 1)
|
||||
},
|
||||
ComputedAt = DateTime.UtcNow,
|
||||
TotalSessions = sessions.Count,
|
||||
LocationsWithData = geoSessions.Count
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetLocationKey(SessionSummary session, string groupBy)
|
||||
{
|
||||
return groupBy.ToLower() switch
|
||||
{
|
||||
"country" => session.GeoCountry,
|
||||
"region" => $"{session.GeoRegion}, {session.GeoCountry}",
|
||||
_ => !string.IsNullOrEmpty(session.GeoCity)
|
||||
? $"{session.GeoCity}, {session.GeoRegion}, {session.GeoCountry}"
|
||||
: $"{session.GeoRegion}, {session.GeoCountry}"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
EnrollmentEvent? whiteGloveEvent = null;
|
||||
EnrollmentEvent? whiteGloveResumedEvent = null;
|
||||
EnrollmentEvent? espFailureEvent = null;
|
||||
EnrollmentEvent? deviceLocationEvent = null;
|
||||
string? failureReason = null;
|
||||
DateTime? earliestEventTimestamp = null;
|
||||
DateTime? latestEventTimestamp = null;
|
||||
@@ -216,6 +217,10 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
{
|
||||
espFailureEvent = evt;
|
||||
}
|
||||
else if (evt.EventType == "device_location")
|
||||
{
|
||||
deviceLocationEvent = evt;
|
||||
}
|
||||
|
||||
// Track app install events for per-app metrics
|
||||
AggregateAppInstallEvent(evt, request.TenantId, request.SessionId, appInstallUpdates);
|
||||
@@ -227,6 +232,20 @@ namespace AutopilotMonitor.Functions.Functions
|
||||
await _storageService.StoreAppInstallSummaryAsync(summary.Summary);
|
||||
}
|
||||
|
||||
// Extract geo-location data and merge into session row
|
||||
if (deviceLocationEvent?.Data != null)
|
||||
{
|
||||
var geoData = deviceLocationEvent.Data;
|
||||
await _storageService.UpdateSessionGeoAsync(
|
||||
request.TenantId,
|
||||
request.SessionId,
|
||||
geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null,
|
||||
geoData.ContainsKey("region") ? geoData["region"]?.ToString() : null,
|
||||
geoData.ContainsKey("city") ? geoData["city"]?.ToString() : null,
|
||||
geoData.ContainsKey("loc") ? geoData["loc"]?.ToString() : null
|
||||
);
|
||||
}
|
||||
|
||||
// Update session status based on events
|
||||
// statusTransitioned = true only when this is the FIRST time the session reaches Succeeded/Failed
|
||||
// (UpdateSessionStatusAsync returns false if already in a terminal state — idempotency guard)
|
||||
|
||||
@@ -67,6 +67,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
int? durationSeconds = null;
|
||||
string? diagnosticsBlobName = null;
|
||||
DateTime? resumedAt = null;
|
||||
string geoCountry = string.Empty;
|
||||
string geoRegion = string.Empty;
|
||||
string geoCity = string.Empty;
|
||||
string geoLoc = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -92,6 +96,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
durationSeconds = existingEntity.GetInt32("DurationSeconds");
|
||||
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
|
||||
resumedAt = existingEntity.GetDateTimeOffset("ResumedAt")?.UtcDateTime;
|
||||
geoCountry = existingEntity.GetString("GeoCountry") ?? string.Empty;
|
||||
geoRegion = existingEntity.GetString("GeoRegion") ?? string.Empty;
|
||||
geoCity = existingEntity.GetString("GeoCity") ?? string.Empty;
|
||||
geoLoc = existingEntity.GetString("GeoLoc") ?? string.Empty;
|
||||
|
||||
// WhiteGlove resumption: if the existing session was in Pending state,
|
||||
// this re-registration means the user has received the device and booted it.
|
||||
@@ -156,6 +164,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (resumedAt.HasValue)
|
||||
entity["ResumedAt"] = EnsureUtc(resumedAt.Value);
|
||||
|
||||
if (!string.IsNullOrEmpty(geoCountry))
|
||||
entity["GeoCountry"] = geoCountry;
|
||||
if (!string.IsNullOrEmpty(geoRegion))
|
||||
entity["GeoRegion"] = geoRegion;
|
||||
if (!string.IsNullOrEmpty(geoCity))
|
||||
entity["GeoCity"] = geoCity;
|
||||
if (!string.IsNullOrEmpty(geoLoc))
|
||||
entity["GeoLoc"] = geoLoc;
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity);
|
||||
_logger.LogInformation($"Stored session {registration.SessionId}");
|
||||
return true;
|
||||
@@ -864,6 +881,55 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogInformation($"Set IsPreProvisioned={isPreProvisioned}, Status={status?.ToString() ?? "(unchanged)"}, IsUserDriven={isUserDriven?.ToString() ?? "(unchanged)"} for session {sessionId} (unconditional merge)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the session's geo-location fields via unconditional Merge-mode write.
|
||||
/// Only writes non-null values; skips if all values are null/empty or geo is already populated.
|
||||
/// Non-fatal: geo is supplementary data, failures are logged as warnings.
|
||||
/// </summary>
|
||||
public async Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
||||
string? country, string? region, string? city, string? loc)
|
||||
{
|
||||
if (string.IsNullOrEmpty(country) && string.IsNullOrEmpty(region) &&
|
||||
string.IsNullOrEmpty(city) && string.IsNullOrEmpty(loc))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// Check if geo fields are already populated (avoid redundant writes)
|
||||
try
|
||||
{
|
||||
var existing = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
|
||||
var existingCountry = existing.Value.GetString("GeoCountry");
|
||||
if (!string.IsNullOrEmpty(existingCountry))
|
||||
{
|
||||
_logger.LogDebug("Session {SessionId} already has geo data, skipping update", sessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return; // Session does not exist yet
|
||||
}
|
||||
|
||||
var update = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["GeoCountry"] = country ?? string.Empty,
|
||||
["GeoRegion"] = region ?? string.Empty,
|
||||
["GeoCity"] = city ?? string.Empty,
|
||||
["GeoLoc"] = loc ?? string.Empty
|
||||
};
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
|
||||
_logger.LogDebug("Updated geo for session {SessionId}: {City}, {Region}, {Country}", sessionId, city, region, country);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to update geo for session {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a session from storage
|
||||
/// </summary>
|
||||
@@ -955,7 +1021,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
OsEdition = entity.GetString("OsEdition") ?? string.Empty,
|
||||
OsLanguage = entity.GetString("OsLanguage") ?? string.Empty,
|
||||
IsUserDriven = entity.GetBoolean("IsUserDriven") ?? false,
|
||||
AgentVersion = entity.GetString("AgentVersion") ?? string.Empty
|
||||
AgentVersion = entity.GetString("AgentVersion") ?? string.Empty,
|
||||
GeoCountry = entity.GetString("GeoCountry") ?? string.Empty,
|
||||
GeoRegion = entity.GetString("GeoRegion") ?? string.Empty,
|
||||
GeoCity = entity.GetString("GeoCity") ?? string.Empty,
|
||||
GeoLoc = entity.GetString("GeoLoc") ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string OsLanguage { get; set; }
|
||||
public bool IsUserDriven { get; set; }
|
||||
public string AgentVersion { get; set; }
|
||||
|
||||
// Geographic location fields — populated from device_location event geo data
|
||||
public string GeoCountry { get; set; } = string.Empty;
|
||||
public string GeoRegion { get; set; } = string.Empty;
|
||||
public string GeoCity { get; set; } = string.Empty;
|
||||
public string GeoLoc { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -266,4 +272,72 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public DateTime SubmittedAt { get; set; }
|
||||
public string AdminNote { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing geographic performance metrics aggregated by location.
|
||||
/// </summary>
|
||||
public class GeographicMetricsResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public List<LocationMetrics> Locations { get; set; } = new();
|
||||
public GlobalAverages GlobalAverages { get; set; } = new();
|
||||
public DateTime ComputedAt { get; set; }
|
||||
public int TotalSessions { get; set; }
|
||||
public int LocationsWithData { get; set; }
|
||||
/// <summary>Whether geo-location collection is enabled for this tenant</summary>
|
||||
public bool GeoLocationEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performance metrics for a single geographic location.
|
||||
/// </summary>
|
||||
public class LocationMetrics
|
||||
{
|
||||
public string LocationKey { get; set; } = string.Empty;
|
||||
public string Country { get; set; } = string.Empty;
|
||||
public string Region { get; set; } = string.Empty;
|
||||
public string City { get; set; } = string.Empty;
|
||||
public string Loc { get; set; } = string.Empty;
|
||||
|
||||
public int SessionCount { get; set; }
|
||||
public int Succeeded { get; set; }
|
||||
public int Failed { get; set; }
|
||||
public double SuccessRate { get; set; }
|
||||
|
||||
public double AvgDurationMinutes { get; set; }
|
||||
public double MedianDurationMinutes { get; set; }
|
||||
public double P95DurationMinutes { get; set; }
|
||||
|
||||
/// <summary>Average number of apps installed per session at this location</summary>
|
||||
public double AvgAppCount { get; set; }
|
||||
/// <summary>Average minutes per app (AvgDurationMinutes / AvgAppCount)</summary>
|
||||
public double MinutesPerApp { get; set; }
|
||||
/// <summary>Normalized score: 100 = global median, lower is better</summary>
|
||||
public double AppLoadScore { get; set; }
|
||||
|
||||
/// <summary>Average download throughput in bytes/sec at this location</summary>
|
||||
public double AvgThroughputBytesPerSec { get; set; }
|
||||
public long TotalDownloadBytes { get; set; }
|
||||
|
||||
/// <summary>Percentage difference from global avg duration (negative = faster)</summary>
|
||||
public double DurationVsGlobalPct { get; set; }
|
||||
/// <summary>Percentage difference from global avg throughput (positive = faster)</summary>
|
||||
public double ThroughputVsGlobalPct { get; set; }
|
||||
|
||||
public bool IsOutlier { get; set; }
|
||||
/// <summary>"fast", "slow", or null</summary>
|
||||
public string OutlierDirection { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Global average benchmarks for geographic comparison.
|
||||
/// </summary>
|
||||
public class GlobalAverages
|
||||
{
|
||||
public double AvgDurationMinutes { get; set; }
|
||||
public double MedianDurationMinutes { get; set; }
|
||||
public double AvgMinutesPerApp { get; set; }
|
||||
public double AvgThroughputBytesPerSec { get; set; }
|
||||
public double StdDevDurationMinutes { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { MapContainer, TileLayer, CircleMarker, Tooltip, useMap } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
interface LocationMetric {
|
||||
locationKey: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
loc: string;
|
||||
sessionCount: number;
|
||||
avgDurationMinutes: number;
|
||||
appLoadScore: number;
|
||||
successRate: number;
|
||||
avgThroughputBytesPerSec: number;
|
||||
isOutlier: boolean;
|
||||
outlierDirection: string | null;
|
||||
}
|
||||
|
||||
interface GeoMapProps {
|
||||
locations: LocationMetric[];
|
||||
globalAvgDuration: number;
|
||||
selectedLocation: string | null;
|
||||
onLocationSelect: (key: string | null) => void;
|
||||
}
|
||||
|
||||
function parseCoords(loc: string): [number, number] | null {
|
||||
if (!loc) return null;
|
||||
const parts = loc.split(",").map((s) => parseFloat(s.trim()));
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
return [parts[0], parts[1]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMarkerColor(avgDuration: number, globalAvg: number): string {
|
||||
if (globalAvg <= 0) return "#6B7280";
|
||||
const ratio = avgDuration / globalAvg;
|
||||
if (ratio <= 0.7) return "#059669"; // green-600
|
||||
if (ratio <= 0.9) return "#10B981"; // green-500
|
||||
if (ratio <= 1.1) return "#F59E0B"; // yellow-500
|
||||
if (ratio <= 1.3) return "#F97316"; // orange-500
|
||||
return "#EF4444"; // red-500
|
||||
}
|
||||
|
||||
function getMarkerRadius(sessionCount: number, maxSessions: number): number {
|
||||
if (maxSessions <= 0) return 8;
|
||||
const normalized = sessionCount / maxSessions;
|
||||
return Math.max(6, Math.min(25, 6 + normalized * 19));
|
||||
}
|
||||
|
||||
function formatThroughput(bytesPerSec: number): string {
|
||||
if (bytesPerSec <= 0) return "—";
|
||||
if (bytesPerSec >= 1024 * 1024) return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024) return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
|
||||
return `${bytesPerSec.toFixed(0)} B/s`;
|
||||
}
|
||||
|
||||
// Component to auto-fit bounds when locations change
|
||||
function FitBounds({ locations }: { locations: { coords: [number, number] }[] }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (locations.length === 0) return;
|
||||
const bounds = locations.map((l) => l.coords) as [number, number][];
|
||||
if (bounds.length === 1) {
|
||||
map.setView(bounds[0], 6);
|
||||
} else {
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 10 });
|
||||
}
|
||||
}, [locations, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function GeoMap({ locations, globalAvgDuration, selectedLocation, onLocationSelect }: GeoMapProps) {
|
||||
const mappableLocations = locations
|
||||
.map((loc) => {
|
||||
const coords = parseCoords(loc.loc);
|
||||
return coords ? { ...loc, coords } : null;
|
||||
})
|
||||
.filter((l): l is NonNullable<typeof l> => l !== null);
|
||||
|
||||
if (mappableLocations.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No location coordinates available for map display.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxSessions = Math.max(...mappableLocations.map((l) => l.sessionCount));
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[30, 0]}
|
||||
zoom={2}
|
||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<FitBounds locations={mappableLocations} />
|
||||
{mappableLocations.map((loc) => {
|
||||
const isSelected = selectedLocation === loc.locationKey;
|
||||
return (
|
||||
<CircleMarker
|
||||
key={loc.locationKey}
|
||||
center={loc.coords}
|
||||
radius={getMarkerRadius(loc.sessionCount, maxSessions)}
|
||||
pathOptions={{
|
||||
color: isSelected ? "#2563EB" : getMarkerColor(loc.avgDurationMinutes, globalAvgDuration),
|
||||
fillColor: getMarkerColor(loc.avgDurationMinutes, globalAvgDuration),
|
||||
fillOpacity: isSelected ? 0.9 : 0.7,
|
||||
weight: isSelected ? 3 : 1.5,
|
||||
}}
|
||||
eventHandlers={{
|
||||
click: () => {
|
||||
onLocationSelect(loc.locationKey);
|
||||
const el = document.getElementById(
|
||||
`loc-${loc.locationKey.replace(/[^a-zA-Z0-9]/g, "-")}`
|
||||
);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={0.95}>
|
||||
<div className="text-xs">
|
||||
<div className="font-bold text-sm mb-1">{loc.locationKey}</div>
|
||||
<div><strong>Sessions:</strong> {loc.sessionCount} ({loc.successRate}% success)</div>
|
||||
<div><strong>Avg Duration:</strong> {Math.round(loc.avgDurationMinutes)} min</div>
|
||||
{loc.appLoadScore > 0 && (
|
||||
<div><strong>App-Load-Score:</strong> {Math.round(loc.appLoadScore)}</div>
|
||||
)}
|
||||
{loc.avgThroughputBytesPerSec > 0 && (
|
||||
<div><strong>Throughput:</strong> {formatThroughput(loc.avgThroughputBytesPerSec)}</div>
|
||||
)}
|
||||
{loc.isOutlier && (
|
||||
<div className="mt-1 font-bold text-red-600">
|
||||
{loc.outlierDirection === "slow" ? "Slow Outlier" : "Fast Outlier"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ProtectedRoute } from "../../components/ProtectedRoute";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { API_BASE_URL } from "@/lib/config";
|
||||
|
||||
// Dynamically import the map component (Leaflet requires window/document)
|
||||
const GeoMap = dynamic(() => import("./GeoMap"), { ssr: false });
|
||||
|
||||
interface LocationMetric {
|
||||
locationKey: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
loc: string;
|
||||
sessionCount: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
successRate: number;
|
||||
avgDurationMinutes: number;
|
||||
medianDurationMinutes: number;
|
||||
p95DurationMinutes: number;
|
||||
avgAppCount: number;
|
||||
minutesPerApp: number;
|
||||
appLoadScore: number;
|
||||
avgThroughputBytesPerSec: number;
|
||||
totalDownloadBytes: number;
|
||||
durationVsGlobalPct: number;
|
||||
throughputVsGlobalPct: number;
|
||||
isOutlier: boolean;
|
||||
outlierDirection: string | null;
|
||||
}
|
||||
|
||||
interface GlobalAverages {
|
||||
avgDurationMinutes: number;
|
||||
medianDurationMinutes: number;
|
||||
avgMinutesPerApp: number;
|
||||
avgThroughputBytesPerSec: number;
|
||||
stdDevDurationMinutes: number;
|
||||
}
|
||||
|
||||
interface GeographicMetricsResponse {
|
||||
success: boolean;
|
||||
locations: LocationMetric[];
|
||||
globalAverages: GlobalAverages;
|
||||
computedAt: string;
|
||||
totalSessions: number;
|
||||
locationsWithData: number;
|
||||
geoLocationEnabled: boolean;
|
||||
}
|
||||
|
||||
type GroupBy = "city" | "region" | "country";
|
||||
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec";
|
||||
type TimeRange = "7d" | "30d" | "90d";
|
||||
|
||||
const durationColor = (value: number, globalAvg: number) => {
|
||||
if (globalAvg <= 0) return "text-gray-700";
|
||||
const ratio = value / globalAvg;
|
||||
if (ratio <= 0.8) return "bg-green-100 text-green-800";
|
||||
if (ratio <= 1.0) return "bg-green-50 text-green-700";
|
||||
if (ratio <= 1.2) return "bg-yellow-50 text-yellow-700";
|
||||
if (ratio <= 1.5) return "bg-orange-50 text-orange-700";
|
||||
return "bg-red-100 text-red-800";
|
||||
};
|
||||
|
||||
const scoreColor = (score: number) => {
|
||||
if (score <= 0) return "text-gray-400";
|
||||
if (score < 80) return "text-green-600";
|
||||
if (score <= 120) return "text-gray-700";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const formatThroughput = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "—";
|
||||
if (bytesPerSec >= 1024 * 1024) return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`;
|
||||
if (bytesPerSec >= 1024) return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
|
||||
return `${bytesPerSec.toFixed(0)} B/s`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes <= 0) return "—";
|
||||
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(0)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
};
|
||||
|
||||
export default function GeographicPerformancePage() {
|
||||
const router = useRouter();
|
||||
const [geoMetrics, setGeoMetrics] = useState<GeographicMetricsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("30d");
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>("city");
|
||||
const [sortBy, setSortBy] = useState<SortBy>("sessionCount");
|
||||
const [sortDesc, setSortDesc] = useState(true);
|
||||
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
|
||||
|
||||
const hasInitialFetch = useRef(false);
|
||||
const isTimeRangeMount = useRef(true);
|
||||
|
||||
const { tenantId } = useTenant();
|
||||
const { getAccessToken } = useAuth();
|
||||
|
||||
const [galacticAdminMode] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("galacticAdminMode") === "true";
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const fetchGeoMetrics = useCallback(async (range: TimeRange = timeRange, group: GroupBy = groupBy) => {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (!token) return;
|
||||
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = galacticAdminMode
|
||||
? `${API_BASE_URL}/api/galactic/metrics/geographic?days=${days}&groupBy=${group}`
|
||||
: `${API_BASE_URL}/api/metrics/geographic?tenantId=${tenantId}&days=${days}&groupBy=${group}`;
|
||||
const response = await fetch(endpoint, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGeoMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch geographic metrics:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [galacticAdminMode, tenantId, getAccessToken, timeRange, groupBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!galacticAdminMode && !tenantId) return;
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
fetchGeoMetrics();
|
||||
}, [tenantId, galacticAdminMode, fetchGeoMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTimeRangeMount.current) {
|
||||
isTimeRangeMount.current = false;
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
fetchGeoMetrics(timeRange, groupBy);
|
||||
}, [timeRange, groupBy, fetchGeoMetrics]);
|
||||
|
||||
const sortedLocations = useMemo(() => {
|
||||
if (!geoMetrics?.locations) return [];
|
||||
const sorted = [...geoMetrics.locations].sort((a, b) => {
|
||||
const aVal = a[sortBy];
|
||||
const bVal = b[sortBy];
|
||||
return sortDesc ? (bVal as number) - (aVal as number) : (aVal as number) - (bVal as number);
|
||||
});
|
||||
return sorted;
|
||||
}, [geoMetrics, sortBy, sortDesc]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!geoMetrics) return null;
|
||||
const outliers = geoMetrics.locations.filter((l) => l.isOutlier);
|
||||
const withDuration = geoMetrics.locations.filter((l) => l.avgDurationMinutes > 0);
|
||||
const fastest = withDuration.length > 0
|
||||
? withDuration.reduce((a, b) => (a.avgDurationMinutes < b.avgDurationMinutes ? a : b))
|
||||
: null;
|
||||
const slowest = withDuration.length > 0
|
||||
? withDuration.reduce((a, b) => (a.avgDurationMinutes > b.avgDurationMinutes ? a : b))
|
||||
: null;
|
||||
return { outliers: outliers.length, fastest, slowest };
|
||||
}, [geoMetrics]);
|
||||
|
||||
const handleSort = (col: SortBy) => {
|
||||
if (sortBy === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortBy(col);
|
||||
setSortDesc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ col }: { col: SortBy }) => {
|
||||
if (sortBy !== col) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-gray-600">Loading geographic performance data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{galacticAdminMode && (
|
||||
<div className="bg-purple-700 text-white text-sm px-4 py-2 flex items-center justify-center space-x-2">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">Galactic Admin View</span>
|
||||
<span className="text-purple-300">— aggregating data across all tenants</span>
|
||||
</div>
|
||||
)}
|
||||
<header className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="text-sm text-gray-600 hover:text-gray-900 mb-2 flex items-center"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Geographic Performance</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Time Range Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{(["7d", "30d", "90d"] as TimeRange[]).map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
timeRange === range
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Group By Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{(["city", "region", "country"] as GroupBy[]).map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setGroupBy(g)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors capitalize ${
|
||||
groupBy === g
|
||||
? "bg-white text-gray-900 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
{/* Geo Disabled Warning */}
|
||||
{geoMetrics && !geoMetrics.geoLocationEnabled && !galacticAdminMode && (
|
||||
<div className="mb-4 bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start space-x-3">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-amber-800">Geo-Location collection is disabled</div>
|
||||
<div className="text-sm text-amber-700 mt-0.5">
|
||||
New sessions will not collect location data. Enable geo-location in{" "}
|
||||
<a href="/settings" className="underline hover:text-amber-900">Configuration</a>{" "}
|
||||
to start collecting geographic data for future enrollments.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coverage Info */}
|
||||
{geoMetrics && (
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
{geoMetrics.locationsWithData} of {geoMetrics.totalSessions} sessions have location data
|
||||
{geoMetrics.locationsWithData < geoMetrics.totalSessions && (
|
||||
<span className="ml-1 text-gray-400">(older sessions may lack geo data)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Cards */}
|
||||
{geoMetrics && stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Locations Tracked</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{geoMetrics.locations.length}</div>
|
||||
<div className="text-xs text-gray-400">{geoMetrics.locationsWithData} sessions with geo</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Outliers Detected</div>
|
||||
<div className={`text-2xl font-bold ${stats.outliers > 0 ? "text-red-600" : "text-green-600"}`}>
|
||||
{stats.outliers}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">>2 std deviations from mean</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Fastest Location</div>
|
||||
<div className="text-lg font-bold text-green-600 truncate" title={stats.fastest?.locationKey}>
|
||||
{stats.fastest?.locationKey || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{stats.fastest ? `${Math.round(stats.fastest.avgDurationMinutes)} min avg` : "No data"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm font-medium text-gray-500">Slowest Location</div>
|
||||
<div className="text-lg font-bold text-red-600 truncate" title={stats.slowest?.locationKey}>
|
||||
{stats.slowest?.locationKey || "—"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{stats.slowest ? `${Math.round(stats.slowest.avgDurationMinutes)} min avg` : "No data"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map */}
|
||||
{geoMetrics && geoMetrics.locations.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow mb-6">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Performance Map</h2>
|
||||
</div>
|
||||
<div className="p-4" style={{ height: "450px" }}>
|
||||
<GeoMap
|
||||
locations={geoMetrics.locations}
|
||||
globalAvgDuration={geoMetrics.globalAverages.avgDurationMinutes}
|
||||
selectedLocation={selectedLocation}
|
||||
onLocationSelect={setSelectedLocation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Averages Banner */}
|
||||
{geoMetrics && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="text-sm font-medium text-blue-800 mb-2">Global Averages (Benchmark)</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-blue-600 font-medium">Avg Duration:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.avgDurationMinutes} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-600 font-medium">Median:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.medianDurationMinutes} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-600 font-medium">Avg Min/App:</span>{" "}
|
||||
<span className="text-blue-900">{geoMetrics.globalAverages.avgMinutesPerApp} min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-blue-600 font-medium">Avg Throughput:</span>{" "}
|
||||
<span className="text-blue-900">{formatThroughput(geoMetrics.globalAverages.avgThroughputBytesPerSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heatmap Table */}
|
||||
{geoMetrics && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Location Performance</h2>
|
||||
</div>
|
||||
{sortedLocations.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
No geographic data available for the selected time range.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("sessionCount")}
|
||||
>
|
||||
Sessions <SortIcon col="sessionCount" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Success
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDurationMinutes")}
|
||||
>
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
P95
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("appLoadScore")}
|
||||
>
|
||||
App-Load-Score <SortIcon col="appLoadScore" />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgThroughputBytesPerSec")}
|
||||
>
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
vs Global
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedLocations.map((loc) => (
|
||||
<tr
|
||||
key={loc.locationKey}
|
||||
id={`loc-${loc.locationKey.replace(/[^a-zA-Z0-9]/g, "-")}`}
|
||||
className={`hover:bg-gray-50 transition-colors ${
|
||||
loc.isOutlier ? "border-l-4 border-l-red-400" : ""
|
||||
} ${selectedLocation === loc.locationKey ? "bg-blue-50" : ""}`}
|
||||
onClick={() => setSelectedLocation(loc.locationKey)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{loc.locationKey}</span>
|
||||
{loc.isOutlier && (
|
||||
<span
|
||||
className={`ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
loc.outlierDirection === "slow"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-green-100 text-green-800"
|
||||
}`}
|
||||
>
|
||||
{loc.outlierDirection === "slow" ? "Slow" : "Fast"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{loc.avgAppCount > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
~{loc.avgAppCount} apps/session · {formatBytes(loc.totalDownloadBytes)} total
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{loc.sessionCount}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
loc.successRate >= 90
|
||||
? "bg-green-100 text-green-800"
|
||||
: loc.successRate >= 70
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{loc.successRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${durationColor(
|
||||
loc.avgDurationMinutes,
|
||||
geoMetrics.globalAverages.avgDurationMinutes
|
||||
)}`}
|
||||
>
|
||||
{Math.round(loc.avgDurationMinutes)} min
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{loc.p95DurationMinutes > 0 ? `${Math.round(loc.p95DurationMinutes)} min` : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className={`font-medium ${scoreColor(loc.appLoadScore)}`}>
|
||||
{loc.appLoadScore > 0 ? Math.round(loc.appLoadScore) : "—"}
|
||||
</span>
|
||||
{loc.minutesPerApp > 0 && (
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
({loc.minutesPerApp.toFixed(1)} min/app)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{formatThroughput(loc.avgThroughputBytesPerSec)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{loc.durationVsGlobalPct !== 0 ? (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
loc.durationVsGlobalPct > 0
|
||||
? "bg-red-50 text-red-700"
|
||||
: "bg-green-50 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{loc.durationVsGlobalPct > 0 ? "+" : ""}
|
||||
{loc.durationVsGlobalPct.toFixed(0)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No data state */}
|
||||
{!geoMetrics && !loading && (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Failed to load geographic metrics. Please try again later.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
@@ -409,6 +409,13 @@ export default function Navbar() {
|
||||
<span className="text-sm text-gray-700">Usage Metrics</span>
|
||||
</Link>
|
||||
|
||||
<Link href="/geographic-performance" className="flex items-center gap-2 py-2 px-2.5 rounded-md hover:bg-gray-50 transition-colors" onClick={() => setShowSettings(false)}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-700">Geographic Performance</span>
|
||||
</Link>
|
||||
|
||||
{/* CONFIGURATION */}
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400 mt-3 mb-1.5">Configuration</p>
|
||||
|
||||
|
||||
+50
-1
@@ -11,10 +11,13 @@
|
||||
"@azure/msal-browser": "^3.28.0",
|
||||
"@azure/msal-react": "^2.1.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"fflate": "^0.8.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.1.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
@@ -1022,6 +1025,17 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -1063,6 +1077,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1077,6 +1097,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
|
||||
@@ -4210,6 +4239,12 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -5084,6 +5119,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
"@azure/msal-browser": "^3.28.0",
|
||||
"@azure/msal-react": "^2.1.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"fflate": "^0.8.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.1.6",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user