mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
+12
@@ -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<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc)
|
||||
=> _storage.DeleteUserActivityOlderThanAsync(cutoffUtc);
|
||||
|
||||
public Task RecordUserPresenceAsync(string tenantId, string upn, string userRole)
|
||||
=> _storage.RecordUserPresenceAsync(tenantId, upn, userRole);
|
||||
|
||||
public Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window)
|
||||
=> _storage.GetActivePresenceAsync(window);
|
||||
|
||||
public Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc)
|
||||
=> _storage.DeleteUserPresenceOlderThanAsync(cutoffUtc);
|
||||
|
||||
public Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
=> _storage.GetMetricsSummaryAsync(tenantId, days);
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>UserPresenceMiddleware</c>).
|
||||
/// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware.
|
||||
/// </summary>
|
||||
public class GetActiveUsersFunction
|
||||
{
|
||||
private const int DefaultWindowMinutes = 5;
|
||||
private const int MinWindowMinutes = 1;
|
||||
private const int MaxWindowMinutes = 60;
|
||||
|
||||
private readonly ILogger<GetActiveUsersFunction> _logger;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
|
||||
public GetActiveUsersFunction(ILogger<GetActiveUsersFunction> logger, IMetricsRepository metricsRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_metricsRepo = metricsRepo;
|
||||
}
|
||||
|
||||
[Function("GetActiveUsers")]
|
||||
public async Task<HttpResponseData> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="ThrottleWindow"/>,
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<string, DateTime> _lastWrite = new();
|
||||
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly ILogger<UserPresenceMiddleware> _logger;
|
||||
|
||||
public UserPresenceMiddleware(IMetricsRepository metricsRepo, ILogger<UserPresenceMiddleware> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true at most once per <see cref="ThrottleWindow"/> per user (per process), and stamps
|
||||
/// the write time when it does. Keeps the hot path from upserting on every single request.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ builder.UseMiddleware<GlobalExceptionMiddleware>();
|
||||
builder.UseMiddleware<AuthenticationMiddleware>();
|
||||
builder.UseMiddleware<PolicyEnforcementMiddleware>();
|
||||
builder.UseMiddleware<UserRateLimitMiddleware>();
|
||||
// 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<UserPresenceMiddleware>();
|
||||
|
||||
// Configure JSON serialization to use camelCase
|
||||
builder.Services.Configure<JsonSerializerOptions>(options =>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -527,6 +527,110 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LIVE PRESENCE METHODS =====
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<List<UserPresenceEntry>> 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<TableEntity>(
|
||||
filter: filter,
|
||||
select: new[] { "PartitionKey", "Upn", "UserRole", "LastSeen" });
|
||||
|
||||
var results = new List<UserPresenceEntry>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public async Task<int> 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<TableEntity>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user activity metrics for a specific tenant
|
||||
/// </summary>
|
||||
@@ -685,6 +789,38 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<int> 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<TableEntity>(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 =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,6 +32,16 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<UserActivityMetrics> GetUserActivityMetricsAsync(string tenantId);
|
||||
Task<UserActivityMetrics> GetAllUserActivityMetricsAsync();
|
||||
Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date);
|
||||
/// <summary>Retention cleanup: deletes UserActivity login rows whose LoginAt is older than the cutoff. Returns the number deleted.</summary>
|
||||
Task<int> DeleteUserActivityOlderThanAsync(DateTime cutoffUtc);
|
||||
|
||||
// --- Live Presence (one row per user, upserted) ---
|
||||
/// <summary>Upserts the caller's presence row (PK=tenantId, RK=SHA-256(lowercase UPN) hex) with LastSeen=now.</summary>
|
||||
Task RecordUserPresenceAsync(string tenantId, string upn, string userRole);
|
||||
/// <summary>Returns all users whose LastSeen falls within the given window, newest first (cross-tenant).</summary>
|
||||
Task<List<UserPresenceEntry>> GetActivePresenceAsync(TimeSpan window);
|
||||
/// <summary>Retention cleanup: deletes presence rows whose LastSeen is older than the cutoff (drops one-off testers). Returns the number deleted.</summary>
|
||||
Task<int> DeleteUserPresenceOlderThanAsync(DateTime cutoffUtc);
|
||||
|
||||
// --- Metrics Summary (Agent API) ---
|
||||
Task<List<object>> GetMetricsSummaryAsync(string? tenantId, int days = 30);
|
||||
@@ -47,6 +57,11 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
|
||||
public class UserActivityMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public int TotalUniqueUsers { get; set; }
|
||||
public int DailyLogins { get; set; }
|
||||
public int ActiveUsersLast7Days { get; set; }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Live presence record for a single web user. One row per user in the
|
||||
/// <c>UserPresence</c> 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 <see cref="LastSeen"/> within a sliding window — the row itself is
|
||||
/// never deleted by the read path, so the table is self-maintaining (size = distinct users).
|
||||
/// </summary>
|
||||
public class UserPresenceEntry
|
||||
{
|
||||
/// <summary>The user's Azure AD tenant ID (PartitionKey).</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The user's UPN / email.</summary>
|
||||
public string Upn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Resolved role at last request (e.g. "GlobalAdmin", "Admin", "Operator", "Viewer", "Authenticated").</summary>
|
||||
public string UserRole { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp of the user's most recent authenticated request.</summary>
|
||||
public DateTime LastSeen { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ export function SectionPlatformUsage() {
|
||||
{metrics.users.total > 0 || metrics.users.dailyLogins > 0 || metrics.users.active7Days > 0 || metrics.users.active30Days > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Total Users</div>
|
||||
<div className="text-sm text-gray-500 mb-1">Unique Users (90d)</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{metrics.users.total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
@@ -385,7 +385,7 @@ export function SectionPlatformUsage() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="space-y-4 mb-4 opacity-40">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Total Users</span>
|
||||
<span className="text-sm text-gray-500">Unique Users (90d)</span>
|
||||
<span className="text-xl font-bold text-gray-400">--</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -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<PresenceResponse | null>(null);
|
||||
const [windowMinutes, setWindowMinutes] = useState(5);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(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 (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<header className="bg-white dark:bg-gray-800 shadow dark:shadow-gray-700">
|
||||
<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>
|
||||
<h1 className="text-2xl font-normal text-gray-900 dark:text-white">Active Users</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Cross-tenant — web users who made a request within the selected window
|
||||
{lastUpdated && (
|
||||
<span className="ml-2 text-gray-400">• updated {lastUpdated.toLocaleTimeString()}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Window</label>
|
||||
<select
|
||||
value={windowMinutes}
|
||||
onChange={(e) => setWindowMinutes(Number(e.target.value))}
|
||||
className="px-3 py-2 border border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-200 text-sm"
|
||||
>
|
||||
{WINDOW_OPTIONS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
Last {m} min
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => fetchPresence(true)}
|
||||
disabled={refreshing}
|
||||
className="px-4 py-2 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<svg className={`h-5 w-5 ${refreshing ? "animate-spin" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>{refreshing ? "Refreshing..." : "Refresh"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8 space-y-6">
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live counter */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 flex items-center space-x-4">
|
||||
<span className="relative flex h-3 w-3">
|
||||
{activeCount > 0 && (
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
)}
|
||||
<span className={`relative inline-flex rounded-full h-3 w-3 ${activeCount > 0 ? "bg-green-500" : "bg-gray-300"}`}></span>
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-white">
|
||||
{loading && !data ? "—" : activeCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
active now (last {data?.windowMinutes ?? windowMinutes} min)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User list */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Role</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Tenant</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{loading && !data ? "Loading…" : "No users active in this window."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={`${u.tenantId}-${u.upn}`} className="hover:bg-gray-50 dark:hover:bg-gray-700/40 transition-colors">
|
||||
<td className="px-6 py-3 text-sm font-medium text-gray-900 dark:text-white">{u.upn}</td>
|
||||
<td className="px-6 py-3 text-sm">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${roleBadgeClass(u.userRole)}`}>
|
||||
{u.userRole || "Authenticated"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-500 dark:text-gray-400 font-mono text-xs">{u.tenantId}</td>
|
||||
<td className="px-6 py-3 text-sm text-gray-600 dark:text-gray-300">{formatAgo(u.secondsAgo)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
“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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -323,7 +323,7 @@ export default function UsageMetricsPage() {
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-sm text-gray-500 mb-1">Total Users</div>
|
||||
<div className="text-sm text-gray-500 mb-1">Unique Users (90d)</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{metrics.users.total.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
@@ -347,7 +347,7 @@ export default function UsageMetricsPage() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 opacity-40">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500 mb-1">Total Users</div>
|
||||
<div className="text-sm text-gray-500 mb-1">Unique Users (90d)</div>
|
||||
<div className="text-3xl font-bold text-gray-400">--</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user