Merge branch 'claude/fix-session-list-limit-xQYnL'

This commit is contained in:
Oliver Kieselbach
2026-03-17 14:00:37 +01:00
18 changed files with 881 additions and 100 deletions
@@ -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();
@@ -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
View File
@@ -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