fix fleet health

This commit is contained in:
Oliver Kieselbach
2026-04-03 21:12:56 +02:00
parent 0976a6e45e
commit 19bd6f8a68
9 changed files with 107 additions and 36 deletions
@@ -1 +1 @@
825
827
@@ -33,11 +33,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<string?> FindSessionTenantIdAsync(string sessionId)
=> _storage.FindSessionTenantIdAsync(sessionId);
public Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null)
=> _storage.GetSessionsAsync(tenantId, maxResults, cursor);
public Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null)
=> _storage.GetSessionsAsync(tenantId, maxResults, cursor, days);
public Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null)
=> _storage.GetAllSessionsAsync(maxResults, cursor);
public Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null)
=> _storage.GetAllSessionsAsync(maxResults, cursor, days);
public Task<bool> DeleteSessionAsync(string tenantId, string sessionId)
=> _storage.DeleteSessionAsync(tenantId, sessionId);
@@ -34,6 +34,10 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var cursor = query["cursor"];
var tenantIdFilter = query["tenantId"];
var daysParam = query["days"];
int? days = null;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
SessionPage page;
@@ -46,13 +50,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
return badRequest;
}
_logger.LogInformation("Fetching sessions for tenant {TenantId} (User: {UserEmail}, cursor: {Cursor})", tenantIdFilter, userEmail, cursor ?? "none");
page = await _sessionRepo.GetSessionsAsync(tenantIdFilter, maxResults: 100, cursor: cursor);
_logger.LogInformation("Fetching sessions for tenant {TenantId} (User: {UserEmail}, cursor: {Cursor}, days: {Days})", tenantIdFilter, userEmail, cursor ?? "none", days?.ToString() ?? "all");
page = await _sessionRepo.GetSessionsAsync(tenantIdFilter, maxResults: 100, cursor: cursor, days: days);
}
else
{
_logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor})", userEmail, cursor ?? "none");
page = await _sessionRepo.GetAllSessionsAsync(maxResults: 100, cursor: cursor);
_logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor}, days: {Days})", userEmail, cursor ?? "none", days?.ToString() ?? "all");
page = await _sessionRepo.GetAllSessionsAsync(maxResults: 100, cursor: cursor, days: days);
}
return await req.OkAsync(new
@@ -30,10 +30,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
var tenantId = TenantHelper.GetTenantId(req);
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
var cursor = query["cursor"];
var daysParam = query["days"];
int? days = null;
if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
days = parsedDays;
_logger.LogInformation("Fetching sessions for tenant {TenantId} (cursor: {Cursor})", tenantId, cursor ?? "none");
_logger.LogInformation("Fetching sessions for tenant {TenantId} (cursor: {Cursor}, days: {Days})", tenantId, cursor ?? "none", days?.ToString() ?? "all");
var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor);
var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor, days: days);
return await req.OkAsync(new
{
@@ -583,7 +583,7 @@ namespace AutopilotMonitor.Functions.Services
/// 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<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null)
public async Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -598,24 +598,45 @@ namespace AutopilotMonitor.Functions.Services
filter += $" and RowKey gt '{cursor}'";
}
// Fetch maxResults + 1 to determine hasMore
var fetchCount = maxResults + 1;
// When days is specified, add RowKey upper bound to limit scan to date range.
// Inverted-tick ordering: newer sessions have smaller RowKey values.
// Sessions older than cutoff have RowKey >= cutoffRowKeyPrefix.
if (days.HasValue)
{
var cutoffDate = DateTime.UtcNow.AddDays(-days.Value);
var cutoffRowKeyPrefix = $"{(DateTime.MaxValue.Ticks - cutoffDate.Ticks):D19}";
filter += $" and RowKey lt '{cutoffRowKeyPrefix}'";
}
// When days is set, return all matching sessions (no pagination); otherwise paginate.
var safetyCap = days.HasValue ? 10000 : maxResults + 1;
var query = indexTableClient.QueryAsync<TableEntity>(
filter: filter,
maxPerPage: Math.Min(fetchCount, 1000)
maxPerPage: Math.Min(safetyCap, 1000)
);
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapIndexEntityToSessionSummary(entity));
if (sessions.Count >= fetchCount) break;
if (sessions.Count >= safetyCap) break;
}
// 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);
return await GetSessionsFromPrimaryTableAsync(tenantId, maxResults, days);
}
if (days.HasValue)
{
// Date-bounded query: all matching sessions returned, no pagination
return new SessionPage
{
Sessions = sessions,
HasMore = false,
Cursor = null
};
}
var hasMore = sessions.Count > maxResults;
@@ -648,20 +669,29 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated).
/// </summary>
private async Task<SessionPage> GetSessionsFromPrimaryTableAsync(string tenantId, int maxResults)
private async Task<SessionPage> GetSessionsFromPrimaryTableAsync(string tenantId, int maxResults, int? days = null)
{
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 safetyCap = days.HasValue ? 10000 : maxResults + 1;
var query = tableClient.QueryAsync<TableEntity>(filter: filter, maxPerPage: Math.Min(safetyCap, 1000));
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
if (sessions.Count >= maxResults + 1) break;
if (sessions.Count >= safetyCap) break;
}
sessions = sessions.OrderByDescending(s => s.StartedAt).ToList();
if (days.HasValue)
{
var cutoffDate = DateTime.UtcNow.AddDays(-days.Value);
sessions = sessions.Where(s => s.StartedAt >= cutoffDate).ToList();
return new SessionPage { Sessions = sessions, HasMore = false };
}
var hasMore = sessions.Count > maxResults;
if (hasMore)
sessions.RemoveAt(sessions.Count - 1);
@@ -674,7 +704,7 @@ namespace AutopilotMonitor.Functions.Services
/// with cursor-based pagination. Uses per-tenant fan-out to leverage the
/// inverted-tick RowKey ordering within each partition, then merge-sorts.
/// </summary>
public async Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null)
public async Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null)
{
try
{
@@ -690,7 +720,7 @@ namespace AutopilotMonitor.Functions.Services
}
if (tenantIds.Count == 0 && string.IsNullOrEmpty(cursor))
return await GetAllSessionsFromPrimaryTableAsync(maxResults);
return await GetAllSessionsFromPrimaryTableAsync(maxResults, days);
// Step 2: Per-tenant fan-out using RowKey ordering (inverted ticks → newest first).
// Parse cursor into invertedTicks prefix for cross-tenant RowKey filtering.
@@ -706,7 +736,16 @@ namespace AutopilotMonitor.Functions.Services
}
}
var fetchPerTenant = maxResults + 1;
// When days is set, fetch all matching sessions per tenant (with safety cap); otherwise paginate.
var fetchPerTenant = days.HasValue ? 5000 : maxResults + 1;
// Compute RowKey upper bound for date filtering
string? cutoffRowKeyPrefix = null;
if (days.HasValue)
{
var cutoffDate = DateTime.UtcNow.AddDays(-days.Value);
cutoffRowKeyPrefix = $"{(DateTime.MaxValue.Ticks - cutoffDate.Ticks):D19}";
}
// Parallel fan-out: query all tenants concurrently
var tasks = tenantIds.Select(async tenantId =>
@@ -714,6 +753,8 @@ namespace AutopilotMonitor.Functions.Services
var filter = $"PartitionKey eq '{tenantId}'";
if (cursorRowKeyPrefix != null)
filter += $" and RowKey ge '{cursorRowKeyPrefix}'";
if (cutoffRowKeyPrefix != null)
filter += $" and RowKey lt '{cutoffRowKeyPrefix}'";
var tenantSessions = new List<SessionSummary>();
await foreach (var entity in indexTableClient.QueryAsync<TableEntity>(
@@ -739,6 +780,17 @@ namespace AutopilotMonitor.Functions.Services
allSessions = allSessions.Skip(cursorIdx + 1).ToList();
}
if (days.HasValue)
{
// Date-bounded query: all matching sessions returned, no pagination
return new SessionPage
{
Sessions = allSessions,
HasMore = false,
Cursor = null
};
}
var hasMore = allSessions.Count > maxResults;
allSessions = allSessions.Take(maxResults).ToList();
@@ -766,19 +818,28 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// Fallback: queries the Sessions table directly for global admin (pre-migration).
/// </summary>
private async Task<SessionPage> GetAllSessionsFromPrimaryTableAsync(int maxResults)
private async Task<SessionPage> GetAllSessionsFromPrimaryTableAsync(int maxResults, int? days = null)
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: Math.Min(maxResults + 1, 1000));
var safetyCap = days.HasValue ? 10000 : maxResults + 1;
var query = tableClient.QueryAsync<TableEntity>(maxPerPage: Math.Min(safetyCap, 1000));
var sessions = new List<SessionSummary>();
await foreach (var entity in query)
{
sessions.Add(MapToSessionSummary(entity));
if (sessions.Count >= maxResults + 1) break;
if (sessions.Count >= safetyCap) break;
}
sessions = sessions.OrderByDescending(s => s.StartedAt).ToList();
if (days.HasValue)
{
var cutoffDate = DateTime.UtcNow.AddDays(-days.Value);
sessions = sessions.Where(s => s.StartedAt >= cutoffDate).ToList();
return new SessionPage { Sessions = sessions, HasMore = false };
}
var hasMore = sessions.Count > maxResults;
if (hasMore)
sessions.RemoveAt(sessions.Count - 1);
@@ -15,8 +15,8 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> StoreSessionAsync(SessionRegistration registration);
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
Task<string?> FindSessionTenantIdAsync(string sessionId);
Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null);
Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null);
Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null);
Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null);
Task<bool> DeleteSessionAsync(string tenantId, string sessionId);
// --- Session Updates ---
@@ -65,6 +65,7 @@ export default function FleetHealthPage() {
isTimeRangeMount.current = false;
return;
}
fetchSessions(timeRange);
fetchAppMetrics(timeRange);
}, [timeRange]);
@@ -121,11 +122,12 @@ export default function FleetHealthPage() {
};
}, [on, off]);
const fetchSessions = async () => {
const fetchSessions = async (range: "7d" | "30d" | "90d" = timeRange) => {
try {
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const endpoint = globalAdminMode
? api.globalSessions.list()
: api.sessions.list(tenantId);
? api.globalSessions.list(undefined, days)
: api.sessions.list(tenantId, days);
const response = await authenticatedFetch(endpoint, getAccessToken);
if (response.ok) {
const data = await response.json();
+4 -4
View File
@@ -21,8 +21,8 @@ export const api = {
// ── Sessions ──────────────────────────────────────────────────────────────
sessions: {
list: (tenantId?: string) =>
`${API_BASE_URL}/api/sessions${qs({ tenantId })}`,
list: (tenantId?: string, days?: number) =>
`${API_BASE_URL}/api/sessions${qs({ tenantId, days: days?.toString() })}`,
get: (sessionId: string, tenantId?: string) =>
`${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`,
events: (sessionId: string, tenantId?: string) =>
@@ -45,8 +45,8 @@ export const api = {
// ── Global Sessions (global admin) ────────────────────────────────────────
globalSessions: {
list: (tenantId?: string) =>
`${API_BASE_URL}/api/global/sessions${qs({ tenantId })}`,
list: (tenantId?: string, days?: number) =>
`${API_BASE_URL}/api/global/sessions${qs({ tenantId, days: days?.toString() })}`,
},
// ── Config ────────────────────────────────────────────────────────────────
File diff suppressed because one or more lines are too long