mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Merge branch 'claude/fix-session-list-limit-xQYnL'
This commit is contained in:
+1
-1
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var groupBy = query["groupBy"] ?? "city";
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
var sessions = await _storageService.GetAllSessionsAsync(maxResults: 10000, since: cutoff);
|
||||
var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1));
|
||||
var allSummaries = await _storageService.GetAllAppInstallSummariesAsync();
|
||||
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
|
||||
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var geoEnabled = tenantConfig?.EnableGeoLocation ?? true;
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-days);
|
||||
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 10000, since: cutoff);
|
||||
var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId);
|
||||
var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId);
|
||||
var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList();
|
||||
|
||||
|
||||
@@ -46,14 +46,14 @@ public class ProgressPortalFunction
|
||||
|
||||
_logger.LogInformation("ProgressGetSessions: Fetching sessions for tenant {TenantId}", tenantId);
|
||||
|
||||
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 100);
|
||||
var page = await _storageService.GetSessionsAsync(tenantId, maxResults: 100);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = sessions.Count,
|
||||
sessions = sessions
|
||||
count = page.Sessions.Count,
|
||||
sessions = page.Sessions
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -30,18 +30,21 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
// Authentication + GalacticAdminOnly authorization enforced by PolicyEnforcementMiddleware
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var cursor = query["cursor"];
|
||||
|
||||
_logger.LogInformation($"Fetching all sessions across all tenants (User: {userEmail})");
|
||||
_logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor})", userEmail, cursor ?? "none");
|
||||
|
||||
// Get all sessions from storage (no tenant filter)
|
||||
var sessions = await _storageService.GetAllSessionsAsync(maxResults: 100);
|
||||
var page = await _storageService.GetAllSessionsAsync(maxResults: 100, cursor: cursor);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = sessions.Count,
|
||||
sessions = sessions
|
||||
count = page.Sessions.Count,
|
||||
hasMore = page.HasMore,
|
||||
cursor = page.Cursor,
|
||||
sessions = page.Sessions
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -56,6 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
count = 0,
|
||||
hasMore = false,
|
||||
sessions = Array.Empty<object>()
|
||||
});
|
||||
|
||||
|
||||
@@ -28,18 +28,21 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var cursor = query["cursor"];
|
||||
|
||||
_logger.LogInformation($"Fetching sessions for tenant {tenantId}");
|
||||
_logger.LogInformation("Fetching sessions for tenant {TenantId} (cursor: {Cursor})", tenantId, cursor ?? "none");
|
||||
|
||||
// Get sessions from storage
|
||||
var sessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 100);
|
||||
var page = await _storageService.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = sessions.Count,
|
||||
sessions = sessions
|
||||
count = page.Sessions.Count,
|
||||
hasMore = page.HasMore,
|
||||
cursor = page.Cursor,
|
||||
sessions = page.Sessions
|
||||
});
|
||||
|
||||
return response;
|
||||
@@ -54,6 +57,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
success = false,
|
||||
message = "Internal server error",
|
||||
count = 0,
|
||||
hasMore = false,
|
||||
sessions = Array.Empty<object>()
|
||||
});
|
||||
|
||||
|
||||
@@ -273,7 +273,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
foreach (var tid in tenantIds)
|
||||
{
|
||||
var sessions = await _storageService.GetSessionsAsync(tid, maxResults: 10000);
|
||||
var sessionsPage = await _storageService.GetSessionsAsync(tid, maxResults: 10000);
|
||||
var sessions = sessionsPage.Sessions;
|
||||
totalEnrollments += sessions.Count;
|
||||
successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded);
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
await CleanupOldDataAsync();
|
||||
await RecomputePlatformStatsAsync();
|
||||
|
||||
// Safety net: backfill any sessions missing from SessionsIndex
|
||||
await _storageService.BackfillSessionIndexAsync();
|
||||
|
||||
maintenanceStart.Stop();
|
||||
_logger.LogInformation($"Daily maintenance completed in {maintenanceStart.ElapsedMilliseconds}ms");
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<PlatformAgentMetricsResponse> ComputePlatformMetricsInternalAsync()
|
||||
{
|
||||
// 1. Fetch the 100 most recent sessions across all tenants
|
||||
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 100);
|
||||
var page = await _storageService.GetAllSessionsAsync(maxResults: 100);
|
||||
var allSessions = page.Sessions;
|
||||
|
||||
if (allSessions.Count == 0)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// <summary>
|
||||
/// Hosted service that initializes all Azure Table Storage tables at application startup.
|
||||
/// This ensures all tables exist before any requests are processed.
|
||||
/// Also performs a one-time backfill of the SessionsIndex table if it is empty.
|
||||
/// </summary>
|
||||
public class TableInitializerService : IHostedService
|
||||
{
|
||||
@@ -35,6 +36,21 @@ public class TableInitializerService : IHostedService
|
||||
// Don't throw - allow the application to start even if table creation fails
|
||||
// Individual operations will fail gracefully with appropriate error messages
|
||||
}
|
||||
|
||||
// One-time backfill: if SessionsIndex is empty, copy all sessions from Sessions table
|
||||
try
|
||||
{
|
||||
if (await _tableStorageService.IsSessionIndexEmptyAsync())
|
||||
{
|
||||
_logger.LogInformation("SessionsIndex table is empty — starting startup backfill");
|
||||
var count = await _tableStorageService.BackfillSessionIndexAsync();
|
||||
_logger.LogInformation("Startup backfill completed: {Count} sessions indexed", count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Startup session index backfill failed — maintenance backfill will catch up");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -414,5 +414,81 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SESSION INDEX BACKFILL =====
|
||||
|
||||
/// <summary>
|
||||
/// Backfills the SessionsIndex table from the Sessions table.
|
||||
/// Finds sessions that don't have an IndexRowKey property and creates the corresponding
|
||||
/// index entry. Idempotent — safe to run repeatedly.
|
||||
/// Returns the number of sessions backfilled.
|
||||
/// </summary>
|
||||
public async Task<int> BackfillSessionIndexAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var sessionsTable = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var query = sessionsTable.QueryAsync<TableEntity>();
|
||||
|
||||
int backfilledCount = 0;
|
||||
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
var existingIndexRowKey = entity.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(existingIndexRowKey))
|
||||
continue; // Already indexed
|
||||
|
||||
var startedAt = entity.GetDateTimeOffset("StartedAt")?.UtcDateTime ?? DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await UpsertSessionIndexAsync(entity, startedAt);
|
||||
backfilledCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to backfill session index for {TenantId}/{SessionId}",
|
||||
entity.PartitionKey, entity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (backfilledCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Session index backfill completed: {Count} sessions indexed", backfilledCount);
|
||||
}
|
||||
|
||||
return backfilledCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Session index backfill failed");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the SessionsIndex table is empty.
|
||||
/// Used by startup backfill to determine if a full migration is needed.
|
||||
/// </summary>
|
||||
public async Task<bool> IsSessionIndexEmptyAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexTable = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var query = indexTable.QueryAsync<TableEntity>(maxPerPage: 1, select: new[] { "PartitionKey" });
|
||||
|
||||
await foreach (var _ in query)
|
||||
{
|
||||
return false; // At least one entity exists
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check if session index is empty");
|
||||
return true; // Assume empty on error → trigger backfill
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,229 @@ namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
public partial class TableStorageService
|
||||
{
|
||||
// ===== SESSION INDEX HELPERS =====
|
||||
|
||||
/// <summary>
|
||||
/// Computes the inverted-tick RowKey for the SessionsIndex table.
|
||||
/// Newest sessions have the smallest RowKey, so Azure Table Storage returns them first.
|
||||
/// Format: "{invertedTicks:D19}_{sessionId}" to guarantee uniqueness.
|
||||
/// </summary>
|
||||
private static string ComputeIndexRowKey(DateTime startedAt, string sessionId)
|
||||
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the SessionId from an index RowKey ("{invertedTicks}_{sessionId}").
|
||||
/// </summary>
|
||||
private static string ExtractSessionIdFromIndexRowKey(string indexRowKey)
|
||||
{
|
||||
var underscoreIndex = indexRowKey.IndexOf('_');
|
||||
return underscoreIndex >= 0 ? indexRowKey.Substring(underscoreIndex + 1) : indexRowKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts a session entry in the SessionsIndex table and stores the IndexRowKey
|
||||
/// back in the Sessions entity. Copies all SessionSummary-relevant fields so that
|
||||
/// listing queries only need to hit the index table.
|
||||
/// </summary>
|
||||
private async Task UpsertSessionIndexAsync(TableEntity sessionEntity, DateTime startedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = sessionEntity.PartitionKey;
|
||||
var sessionId = sessionEntity.RowKey;
|
||||
var indexRowKey = ComputeIndexRowKey(startedAt, sessionId);
|
||||
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
|
||||
// Check if there's an existing index entry with a different RowKey (StartedAt changed)
|
||||
var existingIndexRowKey = sessionEntity.GetString("IndexRowKey");
|
||||
if (!string.IsNullOrEmpty(existingIndexRowKey) && existingIndexRowKey != indexRowKey)
|
||||
{
|
||||
// StartedAt shifted — delete old index entry
|
||||
try
|
||||
{
|
||||
await indexTableClient.DeleteEntityAsync(tenantId, existingIndexRowKey);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Old index entry already gone — fine
|
||||
}
|
||||
}
|
||||
|
||||
// Build index entity with all SessionSummary fields
|
||||
var indexEntity = new TableEntity(tenantId, indexRowKey)
|
||||
{
|
||||
["SessionId"] = sessionId,
|
||||
["SerialNumber"] = sessionEntity.GetString("SerialNumber") ?? string.Empty,
|
||||
["DeviceName"] = sessionEntity.GetString("DeviceName") ?? string.Empty,
|
||||
["Manufacturer"] = sessionEntity.GetString("Manufacturer") ?? string.Empty,
|
||||
["Model"] = sessionEntity.GetString("Model") ?? string.Empty,
|
||||
["StartedAt"] = EnsureUtc(startedAt),
|
||||
["Status"] = sessionEntity.GetString("Status") ?? "InProgress",
|
||||
["CurrentPhase"] = sessionEntity.GetInt32("CurrentPhase") ?? 0,
|
||||
["CurrentPhaseDetail"] = sessionEntity.GetString("CurrentPhaseDetail") ?? string.Empty,
|
||||
["EventCount"] = sessionEntity.GetInt32("EventCount") ?? 0,
|
||||
["EnrollmentType"] = sessionEntity.GetString("EnrollmentType") ?? "v1",
|
||||
["IsPreProvisioned"] = sessionEntity.GetBoolean("IsPreProvisioned") ?? false,
|
||||
["IsHybridJoin"] = sessionEntity.GetBoolean("IsHybridJoin") ?? false,
|
||||
["IsUserDriven"] = sessionEntity.GetBoolean("IsUserDriven") ?? false,
|
||||
["AgentVersion"] = sessionEntity.GetString("AgentVersion") ?? string.Empty,
|
||||
["OsName"] = sessionEntity.GetString("OsName") ?? string.Empty,
|
||||
["OsBuild"] = sessionEntity.GetString("OsBuild") ?? string.Empty,
|
||||
["OsDisplayVersion"] = sessionEntity.GetString("OsDisplayVersion") ?? string.Empty,
|
||||
["OsEdition"] = sessionEntity.GetString("OsEdition") ?? string.Empty,
|
||||
["OsLanguage"] = sessionEntity.GetString("OsLanguage") ?? string.Empty,
|
||||
["GeoCountry"] = sessionEntity.GetString("GeoCountry") ?? string.Empty,
|
||||
["GeoRegion"] = sessionEntity.GetString("GeoRegion") ?? string.Empty,
|
||||
["GeoCity"] = sessionEntity.GetString("GeoCity") ?? string.Empty,
|
||||
["GeoLoc"] = sessionEntity.GetString("GeoLoc") ?? string.Empty
|
||||
};
|
||||
|
||||
// Copy nullable fields
|
||||
var completedAt = sessionEntity.GetDateTimeOffset("CompletedAt")?.UtcDateTime;
|
||||
if (completedAt.HasValue)
|
||||
indexEntity["CompletedAt"] = EnsureUtc(completedAt.Value);
|
||||
|
||||
var failureReason = sessionEntity.GetString("FailureReason");
|
||||
if (!string.IsNullOrEmpty(failureReason))
|
||||
indexEntity["FailureReason"] = failureReason;
|
||||
|
||||
var durationSeconds = sessionEntity.GetInt32("DurationSeconds");
|
||||
if (durationSeconds.HasValue)
|
||||
indexEntity["DurationSeconds"] = durationSeconds.Value;
|
||||
|
||||
var diagnosticsBlobName = sessionEntity.GetString("DiagnosticsBlobName");
|
||||
if (!string.IsNullOrEmpty(diagnosticsBlobName))
|
||||
indexEntity["DiagnosticsBlobName"] = diagnosticsBlobName;
|
||||
|
||||
var lastEventAt = sessionEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
if (lastEventAt.HasValue)
|
||||
indexEntity["LastEventAt"] = EnsureUtc(lastEventAt.Value);
|
||||
|
||||
var resumedAt = sessionEntity.GetDateTimeOffset("ResumedAt")?.UtcDateTime;
|
||||
if (resumedAt.HasValue)
|
||||
indexEntity["ResumedAt"] = EnsureUtc(resumedAt.Value);
|
||||
|
||||
await indexTableClient.UpsertEntityAsync(indexEntity);
|
||||
|
||||
// Store IndexRowKey back in the Sessions entity so Merge-mode updates can find it
|
||||
var sessionsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var indexRefUpdate = new TableEntity(tenantId, sessionId)
|
||||
{
|
||||
["IndexRowKey"] = indexRowKey
|
||||
};
|
||||
await sessionsTableClient.UpdateEntityAsync(indexRefUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to upsert session index for {SessionId}", sessionEntity.RowKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges specific changed fields into the SessionsIndex entry.
|
||||
/// Used by Merge-mode update methods to keep the index in sync without full entity rewrite.
|
||||
/// </summary>
|
||||
private async Task MergeSessionIndexAsync(string tenantId, string indexRowKey, TableEntity fieldsToMerge)
|
||||
{
|
||||
if (string.IsNullOrEmpty(indexRowKey))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var indexUpdate = new TableEntity(tenantId, indexRowKey);
|
||||
|
||||
foreach (var kvp in fieldsToMerge)
|
||||
{
|
||||
if (kvp.Key == "odata.etag" || kvp.Key == "PartitionKey" || kvp.Key == "RowKey" || kvp.Key == "Timestamp")
|
||||
continue;
|
||||
indexUpdate[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
await indexTableClient.UpdateEntityAsync(indexUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to merge session index for {TenantId}/{IndexRowKey}", tenantId, indexRowKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a session entry from the SessionsIndex table.
|
||||
/// </summary>
|
||||
private async Task DeleteSessionIndexAsync(string tenantId, string? indexRowKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(indexRowKey))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
await indexTableClient.DeleteEntityAsync(tenantId, indexRowKey);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already gone — fine
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete session index for {TenantId}/{IndexRowKey}", tenantId, indexRowKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an index entity (from SessionsIndex table) to SessionSummary.
|
||||
/// The key difference from MapToSessionSummary: SessionId comes from a stored property
|
||||
/// instead of RowKey (which contains the inverted-tick key in the index).
|
||||
/// </summary>
|
||||
private SessionSummary MapIndexEntityToSessionSummary(TableEntity entity)
|
||||
{
|
||||
var startedAt = SafeGetDateTime(entity, "StartedAt") ?? DateTime.UtcNow;
|
||||
var completedAt = SafeGetDateTime(entity, "CompletedAt");
|
||||
|
||||
var statusString = entity.GetString("Status") ?? "InProgress";
|
||||
if (!Enum.TryParse<SessionStatus>(statusString, ignoreCase: true, out var status))
|
||||
{
|
||||
status = SessionStatus.Unknown;
|
||||
}
|
||||
|
||||
return new SessionSummary
|
||||
{
|
||||
SessionId = entity.GetString("SessionId") ?? ExtractSessionIdFromIndexRowKey(entity.RowKey),
|
||||
TenantId = entity.PartitionKey,
|
||||
SerialNumber = entity.GetString("SerialNumber") ?? string.Empty,
|
||||
DeviceName = entity.GetString("DeviceName") ?? string.Empty,
|
||||
Manufacturer = entity.GetString("Manufacturer") ?? string.Empty,
|
||||
Model = entity.GetString("Model") ?? string.Empty,
|
||||
StartedAt = startedAt,
|
||||
CompletedAt = completedAt,
|
||||
CurrentPhase = SafeGetInt32(entity, "CurrentPhase") ?? 0,
|
||||
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
|
||||
Status = status,
|
||||
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
|
||||
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
|
||||
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
|
||||
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
|
||||
DiagnosticsBlobName = entity.GetString("DiagnosticsBlobName"),
|
||||
LastEventAt = SafeGetDateTime(entity, "LastEventAt"),
|
||||
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
|
||||
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
|
||||
ResumedAt = SafeGetDateTime(entity, "ResumedAt"),
|
||||
OsName = entity.GetString("OsName") ?? string.Empty,
|
||||
OsBuild = entity.GetString("OsBuild") ?? string.Empty,
|
||||
OsDisplayVersion = entity.GetString("OsDisplayVersion") ?? string.Empty,
|
||||
OsEdition = entity.GetString("OsEdition") ?? string.Empty,
|
||||
OsLanguage = entity.GetString("OsLanguage") ?? string.Empty,
|
||||
IsUserDriven = entity.GetBoolean("IsUserDriven") ?? false,
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// ===== SESSION MANAGEMENT METHODS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -176,6 +399,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
entity["GeoLoc"] = geoLoc;
|
||||
|
||||
await tableClient.UpsertEntityAsync(entity);
|
||||
|
||||
// Dual-write: upsert into SessionsIndex for time-sorted listing
|
||||
await UpsertSessionIndexAsync(entity, startedAt);
|
||||
|
||||
_logger.LogInformation($"Stored session {registration.SessionId}");
|
||||
return true;
|
||||
}
|
||||
@@ -323,84 +550,204 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sessions for a tenant
|
||||
/// Gets sessions for a tenant, ordered newest-first, with cursor-based pagination.
|
||||
/// Queries the SessionsIndex table where RowKey = inverted-tick + sessionId,
|
||||
/// so Azure Table Storage returns results in descending time order natively.
|
||||
/// Falls back to the Sessions table if SessionsIndex is empty (pre-migration).
|
||||
/// </summary>
|
||||
public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int maxResults = 100, DateTime? since = null)
|
||||
public async Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var sessions = new List<SessionSummary>();
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
|
||||
// Query sessions by tenant (PartitionKey), optionally filtered by StartedAt
|
||||
// maxPerPage capped at 1000 (Azure Table Storage limit), pagination is automatic
|
||||
// Build filter: PartitionKey scope + optional cursor for "Load More"
|
||||
var filter = $"PartitionKey eq '{tenantId}'";
|
||||
if (since.HasValue)
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
filter += $" and StartedAt ge datetime'{since.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}'";
|
||||
filter += $" and RowKey gt '{cursor}'";
|
||||
}
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
// Fetch maxResults + 1 to determine hasMore
|
||||
var fetchCount = maxResults + 1;
|
||||
var query = indexTableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: Math.Min(maxResults, 1000)
|
||||
maxPerPage: Math.Min(fetchCount, 1000)
|
||||
);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
if (sessions.Count >= maxResults) break;
|
||||
sessions.Add(MapIndexEntityToSessionSummary(entity));
|
||||
if (sessions.Count >= fetchCount) break;
|
||||
}
|
||||
|
||||
// Sort by StartedAt descending (most recent first)
|
||||
return sessions.OrderByDescending(s => s.StartedAt).ToList();
|
||||
// If SessionsIndex is empty and no cursor, fall back to Sessions table (pre-migration)
|
||||
if (sessions.Count == 0 && string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return await GetSessionsFromPrimaryTableAsync(tenantId, maxResults);
|
||||
}
|
||||
|
||||
var hasMore = sessions.Count > maxResults;
|
||||
if (hasMore)
|
||||
sessions.RemoveAt(sessions.Count - 1);
|
||||
|
||||
// Cursor = RowKey of the last returned item (opaque to frontend)
|
||||
string? nextCursor = null;
|
||||
if (hasMore && sessions.Count > 0)
|
||||
{
|
||||
var lastSession = sessions[sessions.Count - 1];
|
||||
nextCursor = ComputeIndexRowKey(lastSession.StartedAt, lastSession.SessionId);
|
||||
}
|
||||
|
||||
return new SessionPage
|
||||
{
|
||||
Sessions = sessions,
|
||||
HasMore = hasMore,
|
||||
Cursor = nextCursor
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError("Failed to get sessions for tenant {TenantId}: {ExType}: {ExMessage}\n{StackTrace}",
|
||||
tenantId, ex.GetType().Name, ex.Message, ex.StackTrace);
|
||||
return new List<SessionSummary>();
|
||||
return new SessionPage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sessions across all tenants (for galactic admin mode)
|
||||
/// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated).
|
||||
/// </summary>
|
||||
public async Task<List<SessionSummary>> GetAllSessionsAsync(int maxResults = 100, DateTime? since = null)
|
||||
private async Task<SessionPage> GetSessionsFromPrimaryTableAsync(string tenantId, int maxResults)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var filter = $"PartitionKey eq '{tenantId}'";
|
||||
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: Math.Min(maxResults + 1, 1000));
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
if (sessions.Count >= maxResults + 1) break;
|
||||
}
|
||||
|
||||
sessions = sessions.OrderByDescending(s => s.StartedAt).ToList();
|
||||
var hasMore = sessions.Count > maxResults;
|
||||
if (hasMore)
|
||||
sessions.RemoveAt(sessions.Count - 1);
|
||||
|
||||
return new SessionPage { Sessions = sessions, HasMore = hasMore };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all sessions across all tenants (galactic admin mode), ordered newest-first,
|
||||
/// with cursor-based pagination. Queries SessionsIndex cross-partition.
|
||||
/// Cross-partition queries require in-memory sort since Azure Table Storage only
|
||||
/// guarantees RowKey order within a single partition.
|
||||
/// </summary>
|
||||
public async Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var sessions = new List<SessionSummary>();
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
|
||||
// Query all sessions, optionally filtered by StartedAt to limit dataset size
|
||||
// maxPerPage is capped at 1000 (Azure Table Storage limit); pagination is automatic
|
||||
string? filter = since.HasValue
|
||||
? $"StartedAt ge datetime'{since.Value:yyyy-MM-ddTHH:mm:ss.fffffffZ}'"
|
||||
: null;
|
||||
|
||||
var query = tableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: Math.Min(maxResults, 1000)
|
||||
);
|
||||
|
||||
await foreach (var entity in query)
|
||||
// For galactic queries, use StartedAt filter derived from cursor for efficiency
|
||||
string? filter = null;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
if (sessions.Count >= maxResults) break;
|
||||
// Cursor format: "{invertedTicks}_{sessionId}" — extract StartedAt from inverted ticks
|
||||
var underscoreIdx = cursor.IndexOf('_');
|
||||
if (underscoreIdx > 0 && long.TryParse(cursor.Substring(0, underscoreIdx), out var invertedTicks))
|
||||
{
|
||||
var cursorStartedAt = new DateTime(DateTime.MaxValue.Ticks - invertedTicks, DateTimeKind.Utc);
|
||||
// Fetch sessions started at or before the cursor time (with small buffer)
|
||||
filter = $"StartedAt le datetime'{cursorStartedAt.AddSeconds(1):yyyy-MM-ddTHH:mm:ssZ}'";
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by StartedAt descending (most recent first)
|
||||
return sessions.OrderByDescending(s => s.StartedAt).ToList();
|
||||
// Over-fetch for cross-partition: we need enough to sort and paginate
|
||||
var fetchCount = maxResults + 100;
|
||||
var query = indexTableClient.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: Math.Min(fetchCount, 1000)
|
||||
);
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
sessions.Add(MapIndexEntityToSessionSummary(entity));
|
||||
if (sessions.Count >= fetchCount) break;
|
||||
}
|
||||
|
||||
// If SessionsIndex is empty and no cursor, fall back to Sessions table
|
||||
if (sessions.Count == 0 && string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
return await GetAllSessionsFromPrimaryTableAsync(maxResults);
|
||||
}
|
||||
|
||||
// Sort by StartedAt descending (cross-partition results are not pre-sorted)
|
||||
sessions = sessions.OrderByDescending(s => s.StartedAt).ToList();
|
||||
|
||||
// Apply cursor: skip sessions we've already returned
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
var cursorSessionId = ExtractSessionIdFromIndexRowKey(cursor);
|
||||
var cursorIdx = sessions.FindIndex(s => s.SessionId == cursorSessionId);
|
||||
if (cursorIdx >= 0)
|
||||
{
|
||||
sessions = sessions.Skip(cursorIdx + 1).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
var hasMore = sessions.Count > maxResults;
|
||||
sessions = sessions.Take(maxResults).ToList();
|
||||
|
||||
string? nextCursor = null;
|
||||
if (hasMore && sessions.Count > 0)
|
||||
{
|
||||
var lastSession = sessions[sessions.Count - 1];
|
||||
nextCursor = ComputeIndexRowKey(lastSession.StartedAt, lastSession.SessionId);
|
||||
}
|
||||
|
||||
return new SessionPage
|
||||
{
|
||||
Sessions = sessions,
|
||||
HasMore = hasMore,
|
||||
Cursor = nextCursor
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get all sessions");
|
||||
return new List<SessionSummary>();
|
||||
return new SessionPage();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback: queries the Sessions table directly for galactic admin (pre-migration).
|
||||
/// </summary>
|
||||
private async Task<SessionPage> GetAllSessionsFromPrimaryTableAsync(int maxResults)
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: Math.Min(maxResults + 1, 1000));
|
||||
|
||||
var sessions = new List<SessionSummary>();
|
||||
await foreach (var entity in query)
|
||||
{
|
||||
sessions.Add(MapToSessionSummary(entity));
|
||||
if (sessions.Count >= maxResults + 1) break;
|
||||
}
|
||||
|
||||
sessions = sessions.OrderByDescending(s => s.StartedAt).ToList();
|
||||
var hasMore = sessions.Count > maxResults;
|
||||
if (hasMore)
|
||||
sessions.RemoveAt(sessions.Count - 1);
|
||||
|
||||
return new SessionPage { Sessions = sessions, HasMore = hasMore };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific session
|
||||
/// </summary>
|
||||
@@ -573,6 +920,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// This drastically reduces ETag conflicts when concurrent requests update different fields.
|
||||
await tableClient.UpdateEntityAsync(update, session.ETag, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge the same fields into SessionsIndex
|
||||
var indexRowKey = session.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
|
||||
_logger.LogInformation($"Updated session {sessionId} status to {status}");
|
||||
return true;
|
||||
}
|
||||
@@ -676,6 +1027,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// Unconditional merge write — ETag.All bypasses concurrency check
|
||||
await forceTableClient.UpdateEntityAsync(forceUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge the same fields into SessionsIndex
|
||||
var forceIndexRowKey = freshSession.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, forceIndexRowKey, forceUpdate);
|
||||
|
||||
_logger.LogInformation($"Force-updated session {sessionId} status to {status} (unconditional merge after ETag exhaustion)");
|
||||
return true;
|
||||
}
|
||||
@@ -767,6 +1123,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge the same fields into SessionsIndex
|
||||
var indexRowKey = entity.Value.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
|
||||
return;
|
||||
}
|
||||
catch (Azure.RequestFailedException ex) when (ex.Status == 412)
|
||||
@@ -844,6 +1205,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, entity.Value.ETag, Azure.Data.Tables.TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge into SessionsIndex
|
||||
var indexRowKey = entity.Value.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
|
||||
_logger.LogInformation($"Stored diagnostics blob name for session {sessionId}: {blobName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -880,6 +1246,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: read IndexRowKey and merge into SessionsIndex
|
||||
try
|
||||
{
|
||||
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId,
|
||||
select: new[] { "IndexRowKey" });
|
||||
var indexRowKey = entity.Value.GetString("IndexRowKey");
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to sync session index for SetPreProvisioned {SessionId}", sessionId);
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Set IsPreProvisioned={isPreProvisioned}, Status={status?.ToString() ?? "(unchanged)"}, IsUserDriven={isUserDriven?.ToString() ?? "(unchanged)"} for session {sessionId} (unconditional merge)");
|
||||
}
|
||||
|
||||
@@ -900,6 +1280,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
|
||||
// Check if geo fields are already populated (avoid redundant writes)
|
||||
string? indexRowKey = null;
|
||||
try
|
||||
{
|
||||
var existing = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId);
|
||||
@@ -909,6 +1290,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogDebug("Session {SessionId} already has geo data, skipping update", sessionId);
|
||||
return;
|
||||
}
|
||||
indexRowKey = existing.Value.GetString("IndexRowKey");
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
@@ -924,6 +1306,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
|
||||
await tableClient.UpdateEntityAsync(update, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
// Dual-write: merge into SessionsIndex
|
||||
await MergeSessionIndexAsync(tenantId, indexRowKey, update);
|
||||
|
||||
_logger.LogDebug("Updated geo for session {SessionId}: {City}, {Region}, {Country}", sessionId, city, region, country);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -940,7 +1326,26 @@ namespace AutopilotMonitor.Functions.Services
|
||||
try
|
||||
{
|
||||
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
var response = await tableClient.DeleteEntityAsync(tenantId, sessionId);
|
||||
|
||||
// Read IndexRowKey before deleting so we can also delete the index entry
|
||||
string? indexRowKey = null;
|
||||
try
|
||||
{
|
||||
var entity = await tableClient.GetEntityAsync<TableEntity>(tenantId, sessionId,
|
||||
select: new[] { "IndexRowKey" });
|
||||
indexRowKey = entity.Value.GetString("IndexRowKey");
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Session already gone — nothing to delete
|
||||
return true;
|
||||
}
|
||||
|
||||
await tableClient.DeleteEntityAsync(tenantId, sessionId);
|
||||
|
||||
// Dual-write: delete from SessionsIndex
|
||||
await DeleteSessionIndexAsync(tenantId, indexRowKey);
|
||||
|
||||
_logger.LogInformation($"Deleted session {sessionId} for tenant {tenantId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Query sessions from the last 90 days only — covers all displayed metrics (today/7d/30d)
|
||||
// with a generous margin. Avoids loading the entire table into memory.
|
||||
// "Total" counters use PlatformStats (cumulative, already tracked separately).
|
||||
var allSessions = await _storageService.GetAllSessionsAsync(maxResults: 1000000, since: DateTime.UtcNow.AddDays(-90));
|
||||
var allSessions = await _storageService.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1));
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var today = now.Date;
|
||||
@@ -223,7 +223,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private async Task<PlatformUsageMetrics> ComputeTenantUsageMetricsInternalAsync(string tenantId)
|
||||
{
|
||||
// Query sessions for specific tenant only — last 90 days
|
||||
var tenantSessions = await _storageService.GetSessionsAsync(tenantId, maxResults: 1000000, since: DateTime.UtcNow.AddDays(-90));
|
||||
var tenantSessions = await _storageService.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1), tenantId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var today = now.Date;
|
||||
|
||||
@@ -195,6 +195,7 @@ namespace AutopilotMonitor.Shared
|
||||
{
|
||||
// Core data tables
|
||||
public const string Sessions = "Sessions";
|
||||
public const string SessionsIndex = "SessionsIndex";
|
||||
public const string Events = "Events";
|
||||
public const string AuditLogs = "AuditLogs";
|
||||
public const string UsageMetrics = "UsageMetrics";
|
||||
@@ -244,6 +245,7 @@ namespace AutopilotMonitor.Shared
|
||||
public static string[] All => new[]
|
||||
{
|
||||
Sessions,
|
||||
SessionsIndex,
|
||||
Events,
|
||||
AuditLogs,
|
||||
UsageMetrics,
|
||||
|
||||
@@ -201,6 +201,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public string GeoLoc { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Paginated result of session listings.
|
||||
/// Used by GetSessionsAsync and GetAllSessionsAsync to support cursor-based "Load More".
|
||||
/// </summary>
|
||||
public class SessionPage
|
||||
{
|
||||
public List<SessionSummary> Sessions { get; set; } = new();
|
||||
public bool HasMore { get; set; }
|
||||
public string Cursor { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of an enrollment session
|
||||
/// </summary>
|
||||
|
||||
@@ -64,6 +64,9 @@ interface SessionTableProps {
|
||||
totalPages: number;
|
||||
onPreviousPage: () => void;
|
||||
onNextPage: () => void;
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
adminMode: boolean;
|
||||
galacticAdminMode: boolean;
|
||||
blockedDevicesSet: Set<string>;
|
||||
@@ -91,6 +94,9 @@ export function SessionTable({
|
||||
totalPages,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
adminMode,
|
||||
galacticAdminMode,
|
||||
blockedDevicesSet,
|
||||
@@ -179,7 +185,7 @@ export function SessionTable({
|
||||
<div className="mt-8 bg-white shadow rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Sessions ({sessions.length})
|
||||
Sessions ({sessions.length}{hasMore ? '+' : ''})
|
||||
{filteredSessions.length !== sessions.length && (
|
||||
<span className="text-sm text-gray-500 ml-2">
|
||||
({filteredSessions.length} filtered)
|
||||
@@ -408,7 +414,7 @@ export function SessionTable({
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Page {currentPage} of {totalPages} ({sortedSessions.length} total sessions)
|
||||
Page {currentPage} of {totalPages} ({sortedSessions.length}{hasMore ? '+' : ''} total sessions)
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -428,6 +434,29 @@ export function SessionTable({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-6 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
'Load 100 more sessions...'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ export default function Home() {
|
||||
const [apiStatus, setApiStatus] = useState<"unchecked" | "checking" | "healthy" | "error">("unchecked");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [serialValidationEnabled, setSerialValidationEnabled] = useState<boolean | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
@@ -70,20 +73,36 @@ export default function Home() {
|
||||
// Track whether we've been connected at least once — used to detect reconnects
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
const fetchSessions = async (loadMoreCursor?: string) => {
|
||||
try {
|
||||
// Use different endpoint based on galactic admin mode
|
||||
const endpoint = galacticAdminMode
|
||||
let endpoint = galacticAdminMode
|
||||
? `${API_BASE_URL}/api/galactic/sessions`
|
||||
: `${API_BASE_URL}/api/sessions?tenantId=${tenantId}`;
|
||||
|
||||
// Append cursor for "Load More" requests
|
||||
if (loadMoreCursor) {
|
||||
endpoint += endpoint.includes('?') ? '&' : '?';
|
||||
endpoint += `cursor=${encodeURIComponent(loadMoreCursor)}`;
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const allSessions = data.sessions || [];
|
||||
setSessions(allSessions);
|
||||
fetchBlockedDevices(allSessions);
|
||||
const newSessions = data.sessions || [];
|
||||
|
||||
if (loadMoreCursor) {
|
||||
// Append to existing sessions (Load More)
|
||||
setSessions(prev => [...prev, ...newSessions]);
|
||||
} else {
|
||||
// Initial load — replace all sessions
|
||||
setSessions(newSessions);
|
||||
fetchBlockedDevices(newSessions);
|
||||
}
|
||||
|
||||
setHasMore(data.hasMore || false);
|
||||
setCursor(data.cursor || null);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to fetch sessions: ${response.statusText}`, 'backend-error');
|
||||
}
|
||||
@@ -96,9 +115,16 @@ export default function Home() {
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (!cursor || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
fetchSessions(cursor);
|
||||
};
|
||||
|
||||
const fetchBlockedDevices = async (currentSessions: Session[]) => {
|
||||
if (!adminMode || !galacticAdminMode) {
|
||||
setBlockedDevicesSet(new Set());
|
||||
@@ -691,6 +717,9 @@ export default function Home() {
|
||||
totalPages={totalPages}
|
||||
onPreviousPage={handlePreviousPage}
|
||||
onNextPage={handleNextPage}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
onLoadMore={loadMore}
|
||||
adminMode={adminMode}
|
||||
galacticAdminMode={galacticAdminMode}
|
||||
blockedDevicesSet={blockedDevicesSet}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+236
-36
@@ -1,44 +1,244 @@
|
||||
# Static Landing Page (No-JS Fallback)
|
||||
# Session Table Redesign — Dual-Table mit SessionsIndex
|
||||
|
||||
## Goal
|
||||
Make the landing page render full static HTML content server-side, so crawlers and users without JavaScript see the complete page (without animations/interactivity).
|
||||
## Problem
|
||||
- `Sessions`-Tabelle: `RowKey = SessionId` (GUID) → zufällige Reihenfolge
|
||||
- `GetSessionsAsync` muss ALLE Sessions laden und in-memory sortieren
|
||||
- Hardcoded `maxResults: 100` — kein "Load More" möglich
|
||||
- Skaliert nicht bei wachsender Session-Anzahl
|
||||
|
||||
## Plan
|
||||
## Lösung: Dual-Table Architektur
|
||||
|
||||
### Architecture Change
|
||||
Convert `page.tsx` from a full `"use client"` component to a **Server Component** that renders all static content as HTML, with small Client Components extracted only for interactive parts.
|
||||
### Tabellen-Layout
|
||||
|
||||
### What becomes static (Server Component):
|
||||
- [ ] Hero section (headline, description, feature bullets)
|
||||
- [ ] "How It Works" timeline section
|
||||
- [ ] Comparison table
|
||||
- [ ] Footer / CTA sections
|
||||
- [ ] All text, images, layout, and CSS animations
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Sessions Table (UNVERÄNDERT) │
|
||||
│ PartitionKey = TenantId │
|
||||
│ RowKey = SessionId │
|
||||
│ + NEU: IndexRowKey Property (für Index-Referenz) │
|
||||
│ │
|
||||
│ → Alle 12 Point-Query-Lookups bleiben O(1) │
|
||||
│ → Alle Updates/Merges bleiben wie bisher │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
|
||||
### What stays as Client Components (extracted):
|
||||
- [ ] `LoginButton.tsx` — Auth login button (needs MSAL context)
|
||||
- [ ] `PlatformStats.tsx` — Live stats counter (fetches JSON, shows defaults as fallback)
|
||||
- [ ] `AuthRedirect.tsx` — Redirect logic for already-authenticated users
|
||||
- [ ] Theme-dependent styling (already handled by ThemeProvider in layout)
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ SessionsIndex Table (NEU) │
|
||||
│ PartitionKey = TenantId │
|
||||
│ RowKey = "{invertedTicks:D19}_{SessionId}" │
|
||||
│ Properties = Alle SessionSummary-Felder │
|
||||
│ │
|
||||
│ → $top=100 liefert sofort die 100 neuesten Sessions │
|
||||
│ → RowKey gt '{cursor}' + $top=100 = Load More │
|
||||
│ → Kein In-Memory-Sort nötig │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Steps
|
||||
1. [ ] Read and understand full `page.tsx` content
|
||||
2. [ ] Identify all `"use client"` dependencies (hooks, browser APIs)
|
||||
3. [ ] Extract interactive parts into small Client Components
|
||||
4. [ ] Remove `"use client"` from `page.tsx` — make it a Server Component
|
||||
5. [ ] Ensure CSS animations still work (they do — CSS is server-rendered)
|
||||
6. [ ] Test that the static HTML contains all visible content
|
||||
7. [ ] Verify no regressions in interactive behavior
|
||||
### RowKey-Berechnung
|
||||
```csharp
|
||||
string ComputeIndexRowKey(DateTime startedAt, string sessionId)
|
||||
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
```
|
||||
Neueste Sessions = kleinster invertierter Tick = werden zuerst zurückgegeben.
|
||||
|
||||
### What users without JS will see:
|
||||
- Full page content (text, images, layout)
|
||||
- CSS animations (keyframes work without JS)
|
||||
- Hover effects (CSS-only)
|
||||
- Static default values for platform stats
|
||||
- Login button visible but non-functional (acceptable)
|
||||
### Performance-Übersicht
|
||||
|
||||
### What they won't see:
|
||||
- Live platform stats (JS fetch)
|
||||
- Working login flow (requires MSAL)
|
||||
- Auth-based redirects
|
||||
- Theme toggle functionality
|
||||
| Operation | Tabelle | Methode | Performance |
|
||||
|-----------|---------|---------|-------------|
|
||||
| **Neueste 100 laden** | SessionsIndex | `$top=100` | O(1) nativ sortiert |
|
||||
| **Load More** | SessionsIndex | `RowKey gt '{cursor}' + $top=100` | O(1) |
|
||||
| **Session Detail** | Sessions | `GetEntityAsync(tenantId, sessionId)` | O(1) Point Query |
|
||||
| **Status Update** | Sessions + Index | Merge auf beide | 2× O(1) |
|
||||
| **Event Count++** | Sessions + Index | Merge auf beide | 2× O(1) |
|
||||
| **Delete** | Sessions + Index | Delete auf beide | 2× O(1) |
|
||||
| **Galactic Listing** | SessionsIndex | Query alle Partitions + in-memory sort | wie bisher |
|
||||
|
||||
**Kein Trade-off.** Listing UND Lookups sind optimal.
|
||||
|
||||
---
|
||||
|
||||
## Migrations-Strategie: Vollautomatisch
|
||||
|
||||
### 1. Lazy Write-Through (ab Deploy)
|
||||
Jeder Schreibvorgang schreibt automatisch in **beide** Tabellen.
|
||||
Neue und aktualisierte Sessions sind sofort im Index.
|
||||
|
||||
### 2. Startup-Backfill (einmalig beim ersten Deploy)
|
||||
`TableInitializerService.StartAsync()` prüft ob `SessionsIndex` leer ist.
|
||||
Wenn ja → alle bestehenden Sessions aus `Sessions` in den Index kopieren.
|
||||
Läuft einmalig automatisch.
|
||||
|
||||
### 3. Maintenance-Backfill (Sicherheitsnetz, alle 2h)
|
||||
`MaintenanceService.RunAllAsync()` prüft ob Sessions ohne `IndexRowKey` existieren.
|
||||
Wenn ja → nachmigrieren. Idempotent, fängt Edge Cases ab.
|
||||
|
||||
**Ergebnis:** Deploy = fertig. Kein manuelles Script, kein Downtime.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Shared Models + Konstanten
|
||||
|
||||
- [ ] **1. `SessionPage` Model** (`ApiModels.cs`)
|
||||
```csharp
|
||||
public class SessionPage {
|
||||
public List<SessionSummary> Sessions { get; set; } = new();
|
||||
public bool HasMore { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **2. Tabellen-Konstante** (`Constants.cs`)
|
||||
```csharp
|
||||
public const string SessionsIndex = "SessionsIndex";
|
||||
```
|
||||
|
||||
### Phase 2: TableStorageService — Index-Infrastruktur
|
||||
|
||||
- [ ] **3. Helper: `ComputeIndexRowKey`**
|
||||
```csharp
|
||||
private static string ComputeIndexRowKey(DateTime startedAt, string sessionId)
|
||||
=> $"{(DateTime.MaxValue.Ticks - startedAt.Ticks):D19}_{sessionId}";
|
||||
```
|
||||
|
||||
- [ ] **4. Helper: `UpsertSessionIndexAsync`**
|
||||
Nimmt ein TableEntity aus der Sessions-Tabelle, berechnet IndexRowKey,
|
||||
schreibt/updated den Eintrag in SessionsIndex.
|
||||
Speichert `IndexRowKey` auch als Property im Sessions-Entity zurück.
|
||||
|
||||
- [ ] **5. Helper: `DeleteSessionIndexAsync`**
|
||||
Liest `IndexRowKey` aus Sessions-Entity, löscht den Index-Eintrag.
|
||||
|
||||
### Phase 3: Schreibende Methoden — Dual-Write
|
||||
|
||||
Jede Methode die in `Sessions` schreibt, schreibt zusätzlich in `SessionsIndex`.
|
||||
Der `IndexRowKey` wird im Sessions-Entity als Property mitgespeichert,
|
||||
sodass kein extra Lookup nötig ist.
|
||||
|
||||
- [ ] **6. `StoreSessionAsync`** (ProcessRegistration)
|
||||
- Nach `UpsertEntityAsync` auf Sessions: `UpsertSessionIndexAsync` aufrufen
|
||||
- `IndexRowKey` Property im Sessions-Entity setzen
|
||||
|
||||
- [ ] **7. `UpdateSessionStatusAsync`**
|
||||
- Entity wird ohnehin per `GetEntityAsync` gelesen → `IndexRowKey` ist verfügbar
|
||||
- Nach Merge auf Sessions: Merge auf SessionsIndex mit gleichem IndexRowKey
|
||||
|
||||
- [ ] **8. `IncrementSessionEventCountAsync`**
|
||||
- Gleich wie #7: Entity wird gelesen, IndexRowKey nutzen, Dual-Merge
|
||||
|
||||
- [ ] **9. `SetSessionPreProvisionedAsync`**
|
||||
- Aktuell: direkter Merge mit `ETag.All` (kein vorheriger Read)
|
||||
- Anpassung: `IndexRowKey` muss bekannt sein → entweder:
|
||||
a) Vorher kurzen Read machen um IndexRowKey zu holen, oder
|
||||
b) Index-Update in `UpdateSessionStatusAsync` mit abhandeln (wird meist zusammen aufgerufen)
|
||||
- Option (a) ist sicherer und konsistenter
|
||||
|
||||
- [ ] **10. `UpdateSessionGeoAsync`**
|
||||
- Liest Entity bereits (Line 905) → IndexRowKey verfügbar
|
||||
- Dual-Merge hinzufügen
|
||||
|
||||
- [ ] **11. `UpdateSessionDiagnosticsBlobAsync`**
|
||||
- Liest Entity bereits (Line 839) → IndexRowKey verfügbar
|
||||
- Dual-Merge hinzufügen
|
||||
|
||||
- [ ] **12. `DeleteSessionAsync`**
|
||||
- VOR dem Delete: Entity lesen um IndexRowKey zu holen
|
||||
- Dann Delete auf Sessions + Delete auf SessionsIndex
|
||||
|
||||
### Phase 4: Listing-Methoden — Index nutzen
|
||||
|
||||
- [ ] **13. `GetSessionsAsync` → Query auf SessionsIndex**
|
||||
```csharp
|
||||
public async Task<SessionPage> GetSessionsAsync(
|
||||
string tenantId, int maxResults = 100, string? cursor = null)
|
||||
```
|
||||
- Query auf `SessionsIndex` statt `Sessions`
|
||||
- Filter: `PartitionKey eq '{tenantId}'` + optional `RowKey gt '{cursor}'`
|
||||
- `maxPerPage: maxResults + 1` → `hasMore = (count > maxResults)`
|
||||
- Return: `SessionPage { Sessions (maxResults), HasMore }`
|
||||
- Kein In-Memory-Sort nötig — RowKey-Reihenfolge = zeitlich sortiert
|
||||
|
||||
- [ ] **14. `GetAllSessionsAsync` → Query auf SessionsIndex**
|
||||
```csharp
|
||||
public async Task<SessionPage> GetAllSessionsAsync(
|
||||
int maxResults = 100, string? cursor = null)
|
||||
```
|
||||
- Query auf SessionsIndex ohne PartitionKey-Filter
|
||||
- Weiterhin In-Memory-Sort nötig (cross-partition)
|
||||
- Cursor-basiert: `StartedAt < cursorStartedAt` Filter
|
||||
|
||||
### Phase 5: API-Funktionen
|
||||
|
||||
- [ ] **15. `GetSessionsFunction`** — `cursor` Query-Parameter
|
||||
- Optional `cursor` aus Query-String parsen
|
||||
- Neue Response: `{ success, count, hasMore, sessions }`
|
||||
|
||||
- [ ] **16. `GetAllSessionsFunction`** — gleich
|
||||
- Optional `cursor` Query-Parameter
|
||||
- Neue Response: `{ success, count, hasMore, sessions }`
|
||||
|
||||
### Phase 6: Tabellen-Initialisierung + Migration
|
||||
|
||||
- [ ] **17. `InitializeTablesAsync`** — SessionsIndex Tabelle anlegen
|
||||
- `SessionsIndex` zur Tabellen-Liste hinzufügen
|
||||
|
||||
- [ ] **18. Startup-Backfill in `TableInitializerService`**
|
||||
- Nach Tabellen-Init: prüfe ob SessionsIndex leer ist
|
||||
- Wenn ja: alle Sessions aus Sessions-Tabelle lesen, Index befüllen
|
||||
- Sessions ohne `IndexRowKey` Property → IndexRowKey berechnen und zurückschreiben
|
||||
|
||||
- [ ] **19. Maintenance-Backfill in `MaintenanceService`**
|
||||
- Neue Methode: `BackfillSessionIndexAsync()`
|
||||
- Suche Sessions ohne `IndexRowKey` Property
|
||||
- Für jede: IndexRowKey berechnen, in Index schreiben, IndexRowKey in Session speichern
|
||||
- In `RunAllAsync()` einbinden
|
||||
- Idempotent
|
||||
|
||||
### Phase 7: Frontend
|
||||
|
||||
- [ ] **20. `dashboard/page.tsx` — Load-More-State + Logik**
|
||||
- Neue States: `hasMore: boolean`, `loadingMore: boolean`, `cursor: string | null`
|
||||
- `fetchSessions(cursor?: string)`:
|
||||
- Ohne cursor: Initial Load → `setSessions(newSessions)`
|
||||
- Mit cursor: Load More → `setSessions(prev => [...prev, ...newSessions])`
|
||||
- URL: `?tenantId=X` oder `?tenantId=X&cursor=...`
|
||||
- `loadMore()`:
|
||||
- Nimmt `startedAt` + `sessionId` der letzten Session
|
||||
- Berechnet cursor (oder benutzt server-seitigen cursor-Wert aus Response)
|
||||
- Stats Cards: basieren weiterhin nur auf geladenen Sessions (akzeptabel)
|
||||
|
||||
- [ ] **21. `SessionTable.tsx` — "Load More" Button**
|
||||
- Neue Props: `hasMore`, `loadingMore`, `onLoadMore`
|
||||
- Button am Ende der Tabelle, nach Pagination Controls
|
||||
- Zeigt "Load 100 more sessions..." wenn `hasMore` true
|
||||
- Loading-Spinner während `loadingMore`
|
||||
- Session Count Header: "Sessions (100)" → "Sessions (100+)" wenn hasMore
|
||||
|
||||
- [ ] **22. Cursor in API-Response**
|
||||
- Backend gibt `cursor` String in Response zurück (= RowKey des letzten Elements)
|
||||
- Frontend benutzt diesen opaken String direkt für den nächsten Request
|
||||
- Vorteil: Frontend muss IndexRowKey-Format nicht kennen
|
||||
|
||||
---
|
||||
|
||||
## Dateien die geändert werden
|
||||
|
||||
### Shared
|
||||
1. `src/Shared/.../Models/ApiModels.cs` — `SessionPage` Model
|
||||
2. `src/Shared/.../Constants.cs` — `SessionsIndex` Tabellen-Name
|
||||
|
||||
### Backend — Services
|
||||
3. `src/Backend/.../Services/TableStorageService.Sessions.cs` — Dual-Write, Index-Helpers, neue Listing-Methoden
|
||||
4. `src/Backend/.../Services/TableStorageService.Maintenance.cs` — Backfill-Methode
|
||||
5. `src/Backend/.../Services/MaintenanceService.cs` — Backfill in RunAllAsync einbinden
|
||||
6. `src/Backend/.../Services/TableInitializerService.cs` — Startup-Backfill
|
||||
|
||||
### Backend — Functions
|
||||
7. `src/Backend/.../Functions/Sessions/GetSessionsFunction.cs` — cursor Param + neue Response
|
||||
8. `src/Backend/.../Functions/Sessions/GetAllSessionsFunction.cs` — cursor Param + neue Response
|
||||
|
||||
### Frontend
|
||||
9. `src/Web/.../app/dashboard/page.tsx` — Load More State + Handler
|
||||
10. `src/Web/.../app/dashboard/components/SessionTable.tsx` — Load More Button UI
|
||||
|
||||
### Tabellen-Init
|
||||
11. `src/Backend/.../Services/TableStorageService.cs` (oder wo InitializeTablesAsync lebt) — SessionsIndex anlegen
|
||||
|
||||
Reference in New Issue
Block a user