From 7c1f14c66b8d9e1fc1ca2cd58d463ebb0b86d3a5 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 23 Jun 2026 19:44:30 +0200 Subject: [PATCH] feat(backend+web): GA "Active Users" live presence dashboard + UserActivity retention Global-Admin cross-tenant view of web users active "now" (made an authenticated request within a window, default 5min), backed by a new self-maintaining UserPresence table. Not App Insights (sampling) / not SignalR. Backend: - UserPresence table (one row/user, PK=tenantId, RK=SHA-256(lowercase UPN)); upserted fire-and-forget by UserPresenceMiddleware (after PolicyEnforcement, 60s per-process throttle, UPN-gated so agent cert calls are skipped). - GET /api/global/presence (GlobalReadOrAdmin) via GetActiveUsersFunction; read path propagates storage errors (5xx) instead of masking them as "0 active". - 1-day presence retention + 90-day UserActivity retention in maintenance; PlatformStats.TotalUsers clamped monotonic so the public "Users Seen" figure cannot regress after a prune. Web: - /admin/presence page (live counter + table), nav entry under GA > Metrics. - "Total Users" relabeled "Unique Users (90d)" to match the windowed metric. Backend+web only, no ConfigVersion bump, inert until redeploy. Co-Authored-By: Claude Opus 4.8 --- .../UserPresenceTests.cs | 71 ++++++ .../TableStorage/TableMetricsRepository.cs | 12 + .../Functions/Admin/GetActiveUsersFunction.cs | 68 ++++++ .../Middleware/UserPresenceMiddleware.cs | 72 ++++++ .../AutopilotMonitor.Functions/Program.cs | 3 + .../Security/EndpointAccessPolicyCatalog.cs | 1 + .../MaintenanceService.Aggregation.cs | 43 +++- .../Offboarding/TenantOffboardingHandler.cs | 1 + .../Services/TableStorageService.Metrics.cs | 136 +++++++++++ .../AutopilotMonitor.Shared/Constants.cs | 5 + .../DataAccess/IMetricsRepository.cs | 15 ++ .../Models/UserPresenceEntry.cs | 26 +++ .../metrics/sections/SectionPlatformUsage.tsx | 4 +- .../app/admin/presence/page.tsx | 217 ++++++++++++++++++ .../app/usage-metrics/page.tsx | 4 +- src/Web/autopilot-monitor-web/lib/api.ts | 3 + .../lib/globalNavConfig.tsx | 1 + 17 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/UserPresenceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/UserPresenceMiddleware.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/UserPresenceEntry.cs create mode 100644 src/Web/autopilot-monitor-web/app/admin/presence/page.tsx diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/UserPresenceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/UserPresenceTests.cs new file mode 100644 index 00000000..f05f3ac2 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/UserPresenceTests.cs @@ -0,0 +1,71 @@ +using AutopilotMonitor.Functions.Middleware; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Covers the pure logic behind the live-presence feature: UPN→RowKey hashing and the +/// per-process write throttle. The Azure Table read/write paths themselves are exercised via the +/// repository at integration time; here we lock the parts that have branches worth regressing. +/// +public class UserPresenceTests +{ + [Theory] + [InlineData("User@Contoso.com", "user@contoso.com")] // casing must not change the key + [InlineData("plain.user@example.org", "PLAIN.USER@EXAMPLE.ORG")] + public void PresenceRowKey_IsCaseInsensitive(string a, string b) + { + Assert.Equal(TableStorageService.PresenceRowKey(a), TableStorageService.PresenceRowKey(b)); + } + + [Fact] + public void PresenceRowKey_IsDeterministicHexHash() + { + var key = TableStorageService.PresenceRowKey("alice@example.com"); + Assert.Equal(64, key.Length); // SHA-256 hex + Assert.Matches("^[0-9a-f]{64}$", key); // valid RowKey chars + Assert.Equal(key, TableStorageService.PresenceRowKey("alice@example.com")); // stable + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void PresenceRowKey_NeverReturnsEmpty(string? input) + { + Assert.False(string.IsNullOrEmpty(TableStorageService.PresenceRowKey(input!))); + } + + [Fact] + public void PresenceRowKey_DistinctUpns_DoNotCollide() + { + // Regression: char-replacement mapped both of these to "a_b@x", letting one user overwrite + // the other. A hash keeps them distinct. + Assert.NotEqual( + TableStorageService.PresenceRowKey("a/b@x"), + TableStorageService.PresenceRowKey("a_b@x")); + } + + [Fact] + public void ShouldWrite_FirstCallWins_ImmediateRepeatThrottled() + { + // Unique identity so this test is isolated from the shared static throttle map. + const string tenant = "11111111-1111-1111-1111-111111111111"; + var upn = $"throttle-{System.Guid.NewGuid():N}@example.com"; + + Assert.True(UserPresenceMiddleware.ShouldWrite(tenant, upn)); // first → write + Assert.False(UserPresenceMiddleware.ShouldWrite(tenant, upn)); // within window → throttled + } + + [Fact] + public void ShouldWrite_IsCaseInsensitiveOnUpn() + { + const string tenant = "22222222-2222-2222-2222-222222222222"; + var unique = System.Guid.NewGuid().ToString("N"); + var upnLower = $"case-{unique}@example.com"; + var upnUpper = $"CASE-{unique}@EXAMPLE.COM"; + + Assert.True(UserPresenceMiddleware.ShouldWrite(tenant, upnLower)); + // Same user in different casing must hit the same throttle bucket. + Assert.False(UserPresenceMiddleware.ShouldWrite(tenant, upnUpper)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs index a3592a3b..addf8cf2 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -59,6 +59,18 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date) => _storage.GetUserActivityForDateAsync(tenantId, date); + public Task DeleteUserActivityOlderThanAsync(DateTime cutoffUtc) + => _storage.DeleteUserActivityOlderThanAsync(cutoffUtc); + + public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole) + => _storage.RecordUserPresenceAsync(tenantId, upn, userRole); + + public Task> GetActivePresenceAsync(TimeSpan window) + => _storage.GetActivePresenceAsync(window); + + public Task DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc) + => _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc); + public Task> GetMetricsSummaryAsync(string? tenantId, int days = 30) => _storage.GetMetricsSummaryAsync(tenantId, days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs new file mode 100644 index 00000000..08801a08 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs @@ -0,0 +1,68 @@ +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin +{ + /// + /// Global Admin (or Global Reader) endpoint: lists web users currently active across all tenants, + /// i.e. those who made an authenticated request within the requested window (default 5 min). + /// Backed by the self-maintaining UserPresence table (see UserPresenceMiddleware). + /// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware. + /// + public class GetActiveUsersFunction + { + private const int DefaultWindowMinutes = 5; + private const int MinWindowMinutes = 1; + private const int MaxWindowMinutes = 60; + + private readonly ILogger _logger; + private readonly IMetricsRepository _metricsRepo; + + public GetActiveUsersFunction(ILogger logger, IMetricsRepository metricsRepo) + { + _logger = logger; + _metricsRepo = metricsRepo; + } + + [Function("GetActiveUsers")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/presence")] HttpRequestData req) + { + try + { + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var windowMinutes = DefaultWindowMinutes; + if (int.TryParse(query["windowMinutes"], out var parsed)) + windowMinutes = Math.Clamp(parsed, MinWindowMinutes, MaxWindowMinutes); + + var now = DateTime.UtcNow; + var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes)); + + var users = active.Select(u => new + { + tenantId = u.TenantId, + upn = u.Upn, + userRole = u.UserRole, + lastSeen = u.LastSeen, + secondsAgo = (int)Math.Max(0, (now - u.LastSeen).TotalSeconds) + }).ToList(); + + return await req.OkAsync(new + { + success = true, + windowMinutes, + activeCount = users.Count, + users + }); + } + catch (Exception ex) + { + return await req.InternalServerErrorAsync(_logger, ex, "Get active users"); + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/UserPresenceMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/UserPresenceMiddleware.cs new file mode 100644 index 00000000..930cbb15 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/UserPresenceMiddleware.cs @@ -0,0 +1,72 @@ +using System.Collections.Concurrent; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Middleware; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Middleware; + +/// +/// Records lightweight "last seen" presence for authenticated web users so a Global Admin can see +/// who is actively using the portal right now. Runs after PolicyEnforcementMiddleware, so +/// RequestContext (UPN, role, tenant) is already resolved. Agent/device routes carry no UPN and are +/// skipped automatically. +/// +/// Writes are throttled per process to at most one upsert per user per , +/// so even under heavy navigation the presence write rate stays negligible. Best-effort: a failed +/// presence write never affects the request it rode in on (fail-open). +/// +public class UserPresenceMiddleware : IFunctionsWorkerMiddleware +{ + private static readonly TimeSpan ThrottleWindow = TimeSpan.FromSeconds(60); + + // Per-process throttle: key = "tenantId|upn" (lowercased) → last upsert time (UTC). + private static readonly ConcurrentDictionary _lastWrite = new(); + + private readonly IMetricsRepository _metricsRepo; + private readonly ILogger _logger; + + public UserPresenceMiddleware(IMetricsRepository metricsRepo, ILogger logger) + { + _metricsRepo = metricsRepo; + _logger = logger; + } + + public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) + { + var ctx = context.GetRequestContext(); + if (!string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId)) + { + try + { + if (ShouldWrite(ctx.TenantId, ctx.UserPrincipalName)) + { + var role = string.IsNullOrEmpty(ctx.UserRole) ? "Authenticated" : ctx.UserRole; + await _metricsRepo.RecordUserPresenceAsync(ctx.TenantId, ctx.UserPrincipalName, role); + } + } + catch (Exception ex) + { + // Presence is best-effort observability — never block the request. + _logger.LogDebug(ex, "[UserPresence] Failed to record presence for {User}", ctx.UserPrincipalName); + } + } + + await next(context); + } + + /// + /// Returns true at most once per per user (per process), and stamps + /// the write time when it does. Keeps the hot path from upserting on every single request. + /// + internal static bool ShouldWrite(string tenantId, string upn) + { + var key = $"{tenantId}|{upn.ToLowerInvariant()}"; + var now = DateTime.UtcNow; + if (_lastWrite.TryGetValue(key, out var last) && now - last < ThrottleWindow) + return false; + _lastWrite[key] = now; + return true; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index a3143df3..ca558e6c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -41,6 +41,9 @@ builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); +// After policy enforcement (RequestContext resolved): record best-effort "last seen" presence for +// authenticated web users so a Global Admin can see who is active right now. Throttled + fail-open. +builder.UseMiddleware(); // Configure JSON serialization to use camelCase builder.Services.Configure(options => diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 9f1f896f..37a08ca6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -324,6 +324,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "global/sessions", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/stats/sessions", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/audit/logs", EndpointPolicy.GlobalReadOrAdmin), + new("GET", "global/presence", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/metrics/platform", EndpointPolicy.GlobalReadOrAdmin), new("GET", "global/metrics/app", EndpointPolicy.GlobalReadOrAdmin), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index f8404e2b..e0f72b2d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -287,6 +287,40 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogWarning(ex, "Failed to cleanup old rule stats"); } + + // User activity retention: delete login rows older than 90 days. The UserActivity table is + // append-only (one row per login) and is otherwise only wiped on tenant offboarding, so + // without this it grows unbounded and the full-table activity-metric scans get slower. + try + { + var userActivityCutoff = DateTime.UtcNow.AddDays(-90); + var deletedUserActivity = await _metricsRepo.DeleteUserActivityOlderThanAsync(userActivityCutoff); + + if (deletedUserActivity > 0) + _logger.LogInformation("User activity cleanup: deleted {Count} login rows older than 90 days", deletedUserActivity); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup old user activity"); + } + + // Presence retention: delete stale presence rows older than 1 day. Presence is purely a + // "currently active" view (read only for windows ≤60 min); historical activity is covered by + // the UserActivity table, so older presence rows carry zero value. A 1-day window minimizes + // data (a one-off tester's UPN doesn't linger) and keeps the LastSeen scan in + // GetActivePresenceAsync (a cross-partition scan polled every 30s by the GA page) tiny. + try + { + var presenceCutoff = DateTime.UtcNow.AddDays(-1); + var deletedPresence = await _metricsRepo.DeleteUserPresenceOlderThanAsync(presenceCutoff); + + if (deletedPresence > 0) + _logger.LogInformation("Presence cleanup: deleted {Count} stale rows older than 1 day", deletedPresence); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup stale presence rows"); + } } /// @@ -328,10 +362,15 @@ namespace AutopilotMonitor.Functions.Services var existingStats = await _metricsRepo.GetPlatformStatsAsync(); + // "Users Seen" is a cumulative, public-facing high-water-mark. UserActivity is pruned to + // 90 days, so the recomputed `totalUsers` only reflects recent loginers — clamp it to the + // previously-persisted value so the cumulative figure can never regress after a cleanup. + var cumulativeUsers = Math.Max(totalUsers, existingStats?.TotalUsers ?? 0); + var stats = new PlatformStats { TotalEnrollments = totalEnrollments, - TotalUsers = totalUsers, + TotalUsers = cumulativeUsers, TotalTenants = tenantIds.Count, TotalSignedUpTenants = allConfigs.Count, UniqueDeviceModels = uniqueModels.Count, @@ -347,7 +386,7 @@ namespace AutopilotMonitor.Functions.Services sw.Stop(); _logger.LogInformation($"Platform stats recomputed in {sw.ElapsedMilliseconds}ms: " + - $"{totalEnrollments} enrollments, {totalUsers} users, {tenantIds.Count} tenants, {uniqueModels.Count} models"); + $"{totalEnrollments} enrollments, {cumulativeUsers} users (cumulative), {tenantIds.Count} tenants, {uniqueModels.Count} models"); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index e4c480d9..3cfdc3c1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -57,6 +57,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding Constants.TableNames.AuditLogs, Constants.TableNames.UsageMetrics, Constants.TableNames.UserActivity, + Constants.TableNames.UserPresence, Constants.TableNames.AppInstallSummaries, Constants.TableNames.TenantAdmins, Constants.TableNames.BootstrapSessions, // main rows; CodeLookup discriminator below diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs index ccf6fe51..15f7309d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Metrics.cs @@ -527,6 +527,110 @@ namespace AutopilotMonitor.Functions.Services } } + // ===== LIVE PRESENCE METHODS ===== + + /// + /// Derives a collision-free, case-insensitive Azure Table RowKey from a UPN: the SHA-256 hash + /// of the lowercased UPN as hex. A hash (rather than char-replacement) guarantees distinct UPNs + /// never collide — replacing disallowed chars with '_' would map e.g. "a/b@x" and "a_b@x" to the + /// same key, letting one user overwrite the other. The original UPN is kept in the Upn column. + /// + internal static string PresenceRowKey(string upn) + { + var normalized = (upn ?? string.Empty).ToLowerInvariant(); + var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + /// + /// Upserts a single presence row (PK=tenantId, RK=hash(UPN)) stamped with LastSeen=now. + /// One row per user — overwritten on every call, so the table never grows past the distinct-user count. + /// + public async Task RecordUserPresenceAsync(string tenantId, string upn, string userRole) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence); + var entity = new TableEntity(tenantId, PresenceRowKey(upn)) + { + ["Upn"] = upn ?? string.Empty, + ["UserRole"] = userRole ?? string.Empty, + ["LastSeen"] = DateTime.UtcNow + }; + + await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + } + catch (Exception ex) + { + // Presence is best-effort observability — never fail the request it rode in on. + _logger.LogDebug(ex, "Failed to record presence for {Upn} in tenant {TenantId}", upn, tenantId); + } + } + + /// + /// Returns all users whose LastSeen is within the given window (cross-tenant), newest first. + /// Intentionally NOT wrapped in a swallow-and-return-empty try/catch: a storage failure must + /// surface to the caller so the operator-facing endpoint returns 5xx, rather than masquerade as + /// "0 users active". The query projects only the columns it needs to keep the response lean. + /// + public async Task> GetActivePresenceAsync(TimeSpan window) + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence); + var cutoff = DateTime.UtcNow - window; + var filter = $"LastSeen ge datetime'{cutoff:yyyy-MM-ddTHH:mm:ss}Z'"; + // PartitionKey is included explicitly: it carries TenantId (read below) and the SDK's + // projection only guarantees the columns named in select. + var query = tableClient.QueryAsync( + filter: filter, + select: new[] { "PartitionKey", "Upn", "UserRole", "LastSeen" }); + + var results = new List(); + await foreach (var entity in query) + { + results.Add(new UserPresenceEntry + { + TenantId = entity.PartitionKey ?? string.Empty, + Upn = entity.GetString("Upn") ?? string.Empty, + UserRole = entity.GetString("UserRole") ?? string.Empty, + LastSeen = entity.GetDateTime("LastSeen") ?? DateTime.MinValue + }); + } + + results.Sort((a, b) => b.LastSeen.CompareTo(a.LastSeen)); + return results; + } + + /// + /// Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff. Keeps the + /// table to genuinely-recent users — one-off testers don't linger indefinitely (data minimization). + /// + public async Task DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserPresence); + var filter = $"LastSeen lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'"; + var query = tableClient.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + await foreach (var entity in query) + { + await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + + if (deleted > 0) + _logger.LogInformation("Deleted {Count} stale presence rows older than {Cutoff:yyyy-MM-dd}", deleted, cutoffUtc); + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete stale presence rows"); + return 0; + } + } + /// /// Gets user activity metrics for a specific tenant /// @@ -685,6 +789,38 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. + /// The table is append-only (one row per login) and is otherwise only wiped on tenant offboarding, + /// so without this it grows unbounded and the full-table metrics scans get progressively slower. + /// + public async Task DeleteUserActivityOlderThanAsync(DateTime cutoffUtc) + { + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.UserActivity); + var filter = $"LoginAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'"; + var query = tableClient.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + await foreach (var entity in query) + { + await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + deleted++; + } + + if (deleted > 0) + _logger.LogInformation("Deleted {Count} user activity rows older than {Cutoff:yyyy-MM-dd}", deleted, cutoffUtc); + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete old user activity rows"); + return 0; + } + } + // ===== RULE STATS METHODS ===== /// diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 345d9131..56200f83 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -707,6 +707,10 @@ namespace AutopilotMonitor.Shared public const string AuditLogs = "AuditLogs"; public const string UsageMetrics = "UsageMetrics"; public const string UserActivity = "UserActivity"; + // Live presence: ONE row per user (PK=TenantId, RowKey=SHA-256(lowercase UPN) hex), overwritten + // on each authenticated web request. Self-maintaining (size = distinct users); "active now" + // is a read-time LastSeen window filter, not row deletion. See UserPresenceMiddleware. + public const string UserPresence = "UserPresence"; // Rules engine tables public const string RuleResults = "RuleResults"; @@ -871,6 +875,7 @@ namespace AutopilotMonitor.Shared AuditLogs, UsageMetrics, UserActivity, + UserPresence, RuleResults, GatherRules, AnalyzeRules, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs index 2b326d6c..051e9ac8 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -32,6 +32,16 @@ namespace AutopilotMonitor.Shared.DataAccess Task GetUserActivityMetricsAsync(string tenantId); Task GetAllUserActivityMetricsAsync(); Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date); + /// Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted. + Task DeleteUserActivityOlderThanAsync(DateTime cutoffUtc); + + // --- Live Presence (one row per user, upserted) --- + /// Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now. + Task RecordUserPresenceAsync(string tenantId, string upn, string userRole); + /// Returns all users whose LastSeen falls within the given window, newest first (cross-tenant). + Task> GetActivePresenceAsync(TimeSpan window); + /// Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted. + Task DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc); // --- Metrics Summary (Agent API) --- Task> GetMetricsSummaryAsync(string? tenantId, int days = 30); @@ -47,6 +57,11 @@ namespace AutopilotMonitor.Shared.DataAccess public class UserActivityMetrics { + /// + /// Distinct users with a login row in the UserActivity table. NOTE: that table is pruned to the + /// retention window (90 days), so this is "unique users within retention", NOT an all-time total. + /// The cumulative all-time figure is the monotonic high-water-mark in PlatformStats.TotalUsers. + /// public int TotalUniqueUsers { get; set; } public int DailyLogins { get; set; } public int ActiveUsersLast7Days { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/UserPresenceEntry.cs b/src/Shared/AutopilotMonitor.Shared/Models/UserPresenceEntry.cs new file mode 100644 index 00000000..586f16a3 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/UserPresenceEntry.cs @@ -0,0 +1,26 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// Live presence record for a single web user. One row per user in the + /// UserPresence table (PartitionKey = tenantId, RowKey = SHA-256(lowercase UPN) hex), + /// overwritten (upsert) on each authenticated web request. "Active now" is derived at read + /// time by filtering on within a sliding window — the row itself is + /// never deleted by the read path, so the table is self-maintaining (size = distinct users). + /// + public class UserPresenceEntry + { + /// The user's Azure AD tenant ID (PartitionKey). + public string TenantId { get; set; } = string.Empty; + + /// The user's UPN / email. + public string Upn { get; set; } = string.Empty; + + /// Resolved role at last request (e.g. "GlobalAdmin", "Admin", "Operator", "Viewer", "Authenticated"). + public string UserRole { get; set; } = string.Empty; + + /// UTC timestamp of the user's most recent authenticated request. + public DateTime LastSeen { get; set; } + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx index 78ff7b1a..c980a652 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionPlatformUsage.tsx @@ -362,7 +362,7 @@ export function SectionPlatformUsage() { {metrics.users.total > 0 || metrics.users.dailyLogins > 0 || metrics.users.active7Days > 0 || metrics.users.active30Days > 0 ? (
-
Total Users
+
Unique Users (90d)
{metrics.users.total.toLocaleString()}
@@ -385,7 +385,7 @@ export function SectionPlatformUsage() {
- Total Users + Unique Users (90d) --
diff --git a/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx new file mode 100644 index 00000000..07524744 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/presence/page.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { useAuth } from "../../../contexts/AuthContext"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; + +const REFRESH_INTERVAL_MS = 30_000; +const WINDOW_OPTIONS = [5, 15, 30, 60]; + +interface ActiveUser { + tenantId: string; + upn: string; + userRole: string; + lastSeen: string; + secondsAgo: number; +} + +interface PresenceResponse { + success: boolean; + windowMinutes: number; + activeCount: number; + users: ActiveUser[]; +} + +function formatAgo(secondsAgo: number): string { + if (secondsAgo < 60) return "just now"; + const minutes = Math.floor(secondsAgo / 60); + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m ago`; +} + +function roleBadgeClass(role: string): string { + switch (role) { + case "GlobalAdmin": + return "bg-purple-100 text-purple-700"; + case "GlobalReader": + return "bg-indigo-100 text-indigo-700"; + case "Admin": + return "bg-blue-100 text-blue-700"; + case "Operator": + return "bg-teal-100 text-teal-700"; + case "Viewer": + return "bg-gray-100 text-gray-600"; + default: + return "bg-gray-100 text-gray-500"; + } +} + +export default function PresencePage() { + const { getAccessToken } = useAuth(); + const [data, setData] = useState(null); + const [windowMinutes, setWindowMinutes] = useState(5); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + // Keep the latest window value available to the interval callback without re-arming the timer. + const windowRef = useRef(windowMinutes); + windowRef.current = windowMinutes; + + const fetchPresence = useCallback( + async (showRefreshing = false) => { + try { + if (showRefreshing) setRefreshing(true); + else setLoading(true); + setError(null); + + const response = await authenticatedFetch(api.metrics.activeUsers(windowRef.current), getAccessToken); + if (!response.ok) { + throw new Error(`Failed to fetch active users: ${response.statusText}`); + } + const json: PresenceResponse = await response.json(); + setData(json); + setLastUpdated(new Date()); + } catch (err) { + if (err instanceof TokenExpiredError) { + console.error("Session expired:", err.message); + } else { + console.error("Error fetching active users:", err); + } + setError(err instanceof Error ? err.message : "Failed to fetch active users"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [getAccessToken], + ); + + // Initial load + whenever the window changes. + useEffect(() => { + fetchPresence(); + }, [fetchPresence, windowMinutes]); + + // Auto-refresh on a fixed interval (reads the current window via ref). + useEffect(() => { + const id = setInterval(() => fetchPresence(true), REFRESH_INTERVAL_MS); + return () => clearInterval(id); + }, [fetchPresence]); + + const users = data?.users ?? []; + const activeCount = data?.activeCount ?? 0; + + return ( +
+
+
+
+
+

Active Users

+

+ Cross-tenant — web users who made a request within the selected window + {lastUpdated && ( + • updated {lastUpdated.toLocaleTimeString()} + )} +

+
+
+ + + +
+
+
+
+ +
+ {error && ( +
+ {error} +
+ )} + + {/* Live counter */} +
+ + {activeCount > 0 && ( + + )} + 0 ? "bg-green-500" : "bg-gray-300"}`}> + +
+
+ {loading && !data ? "—" : activeCount.toLocaleString()} +
+
+ active now (last {data?.windowMinutes ?? windowMinutes} min) +
+
+
+ + {/* User list */} +
+ + + + + + + + + + + {users.length === 0 ? ( + + + + ) : ( + users.map((u) => ( + + + + + + + )) + )} + +
UserRoleTenantLast seen
+ {loading && !data ? "Loading…" : "No users active in this window."} +
{u.upn} + + {u.userRole || "Authenticated"} + + {u.tenantId}{formatAgo(u.secondsAgo)}
+
+ +

+ “Active” means the user made an authenticated request within the window. An open but idle tab + drops off after the window elapses. Updates automatically every {REFRESH_INTERVAL_MS / 1000}s. +

+
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx index 0c5c0573..bdef17db 100644 --- a/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx +++ b/src/Web/autopilot-monitor-web/app/usage-metrics/page.tsx @@ -323,7 +323,7 @@ export default function UsageMetricsPage() { <>
-
Total Users
+
Unique Users (90d)
{metrics.users.total.toLocaleString()}
@@ -347,7 +347,7 @@ export default function UsageMetricsPage() {
-
Total Users
+
Unique Users (90d)
--
diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index caea8deb..f66a9f22 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -204,6 +204,9 @@ export const api = { `${API_BASE_URL}/api/metrics/usage${qs({ tenantId })}`, globalUsage: (tenantId?: string) => `${API_BASE_URL}/api/global/metrics/usage${qs({ tenantId })}`, + // Cross-tenant live presence: web users active within the last windowMinutes (GA / Global Reader). + activeUsers: (windowMinutes?: number) => + `${API_BASE_URL}/api/global/presence${qs({ windowMinutes: windowMinutes != null ? String(windowMinutes) : undefined })}`, app: (tenantId: string, days: number) => `${API_BASE_URL}/api/metrics/app${qs({ tenantId, days: String(days) })}`, globalApp: (days: number, tenantId?: string) => diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index 6005b9ed..afd6e221 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -235,6 +235,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [ items: [ { id: "ga-platform-metrics", label: "Platform Metrics", href: "/admin/metrics/platform-metrics" }, { id: "ga-usage", label: "Platform Usage", href: "/admin/metrics/usage" }, + { id: "ga-active-users", label: "Active Users", href: "/admin/presence" }, { id: "ga-mcp-usage", label: "MCP Usage", href: "/admin/metrics/mcp-usage" }, ], },