added Geographic Performance Heatmap

This commit is contained in:
Oliver Kieselbach
2026-03-05 08:40:36 +01:00
parent e2986b2972
commit 93037cd83d
12 changed files with 1205 additions and 5 deletions
@@ -1 +1 @@
219
223
@@ -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='&copy; <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">&#8597;</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">&mdash; 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"
>
&larr; 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">&gt;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 &middot; {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
View File
@@ -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",
+4 -1
View File
@@ -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