diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogDeletionExclusionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogDeletionExclusionTests.cs new file mode 100644 index 00000000..53473da8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogDeletionExclusionTests.cs @@ -0,0 +1,65 @@ +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Server-side "exclude deletions" audit view. Per-session deletion bookkeeping +/// (deletion_started/deletion_completed) is written with the triggering +/// operator's UPN — not System.Maintenance — so a cleanup sweep floods the +/// audit table with rows the performer exclusion does not catch. The "All +/// (excl. deletions)" view must drop these in the OData query so a page is +/// back-filled with real entries instead of coming back empty. +/// +public class AuditLogDeletionExclusionTests +{ + private static readonly Guid Tenant = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + [Fact] + public void Tenant_filter_includes_deletion_exclusion_only_when_requested() + { + var without = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: false); + var with = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true); + + Assert.DoesNotContain("deletion_started", without); + Assert.DoesNotContain("deletion_completed", without); + + Assert.Contains("Action ne 'deletion_started'", with); + Assert.Contains("Action ne 'deletion_completed'", with); + } + + [Fact] + public void Global_fanout_filter_includes_deletion_exclusion_only_when_requested() + { + var without = TableStorageService.BuildAuditLogFilterWithRowKeyBound( + Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: false); + var with = TableStorageService.BuildAuditLogFilterWithRowKeyBound( + Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true); + + Assert.DoesNotContain("deletion_started", without); + Assert.Contains("Action ne 'deletion_started'", with); + Assert.Contains("Action ne 'deletion_completed'", with); + } + + [Fact] + public void Exclusion_clause_is_identical_across_tenant_and_global_paths() + { + // Both code paths must drop the same actions; a drift would let deletion + // noise leak back into one of the two views. + var tenant = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true); + var global = TableStorageService.BuildAuditLogFilterWithRowKeyBound( + Tenant.ToString(), null, null, lastRowKey: null, excludeDeletions: true); + + var clause = TableStorageService.DeletionExclusionClause(); + Assert.Contains(clause, tenant); + Assert.Contains(clause, global); + } + + [Fact] + public void Exclusion_does_not_disturb_the_performer_suppression() + { + // The pre-existing System.Maintenance suppression must survive alongside + // the new clause — both noise sources stay filtered. + var with = TableStorageService.BuildAuditLogFilter(Tenant.ToString(), null, null, excludeDeletions: true); + Assert.Contains("PerformedBy ne 'System.Maintenance'", with); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs index eab10242..b8ee1a2e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingSessionEnumeratorTests.cs @@ -106,8 +106,8 @@ public class OffboardingSessionEnumeratorTests public Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null) => throw new NotImplementedException(); public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException(); public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) => throw new NotImplementedException(); - public Task> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) => throw new NotImplementedException(); - public Task> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) => throw new NotImplementedException(); + public Task> GetAuditLogsPageAsync(string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException(); + public Task> GetAllAuditLogsPageAsync(DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions = false) => throw new NotImplementedException(); public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => throw new NotImplementedException(); public Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) => throw new NotImplementedException(); public Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) => throw new NotImplementedException(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs index 1027674e..10e0aef0 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -37,12 +37,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage => _storage.GetAllAuditLogsAsync(dateFrom, dateTo); public Task> GetAuditLogsPageAsync( - string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) - => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation); + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false) + => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation, excludeDeletions); public Task> GetAllAuditLogsPageAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) - => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation); + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false) + => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions); public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index b6baad8a..33c5a5da 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -42,10 +42,17 @@ namespace AutopilotMonitor.Functions.Functions.Admin return bad; } + // "Exclude deletions" view: drop per-session deletion bookkeeping + // server-side so a cleanup-heavy window still fills a page with + // real entries. Folded into the fingerprint scope so a token + // minted for one view can't be replayed against the other. + var excludeDeletions = string.Equals(query["excludeDeletions"], "true", StringComparison.OrdinalIgnoreCase); + var fingerprintScope = excludeDeletions ? "audit:tenant:nodel" : "audit:tenant"; + _logger.LogInformation( - "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}) for user {User}", + "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}, excludeDeletions={ExcludeDeletions}) for user {User}", tenantId, parsed.DateFrom, parsed.DateTo, - parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, + parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, excludeDeletions, userIdentifier); if (parsed.PageSize == null) @@ -59,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin { if (!DateWindowPagination.TryAcceptContinuation( parsed.Continuation, - scope: "audit:tenant", + scope: fingerprintScope, callerTenantId: tenantId, dateFrom: parsed.DateFrom, dateTo: parsed.DateTo, @@ -79,13 +86,13 @@ namespace AutopilotMonitor.Functions.Functions.Admin } var page = await _maintenanceRepo.GetAuditLogsPageAsync( - tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) { var fp = DateWindowPagination.Fingerprint( - scope: "audit:tenant", + scope: fingerprintScope, callerTenantId: tenantId, dateFrom: parsed.DateFrom, dateTo: parsed.DateTo); @@ -95,7 +102,10 @@ namespace AutopilotMonitor.Functions.Functions.Admin pageSize: parsed.PageSize.Value, wireContinuation: wireToken, dateFrom: parsed.DateFrom, - dateTo: parsed.DateTo); + dateTo: parsed.DateTo, + extras: excludeDeletions + ? new[] { new KeyValuePair("excludeDeletions", "true") } + : null); } return await req.OkAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index 2f5d9936..0d95442c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -49,17 +49,24 @@ namespace AutopilotMonitor.Functions.Functions.Admin // the partition-key lookup stays cheap. Fingerprint scope is widened with the // filter value so cross-token reuse is rejected. var filterTenantId = query["tenantId"]; - var fingerprintScope = string.IsNullOrEmpty(filterTenantId) + // "Exclude deletions" view — same semantics as the tenant endpoint. The + // flag is folded into the fingerprint scope (so a token can't cross views) + // and echoed on nextLink for self-contained follow-up requests. + var excludeDeletions = string.Equals(query["excludeDeletions"], "true", StringComparison.OrdinalIgnoreCase); + var baseScope = string.IsNullOrEmpty(filterTenantId) ? "audit:global" : $"audit:global:tenant:{filterTenantId}"; - var nextLinkExtras = string.IsNullOrEmpty(filterTenantId) - ? null - : new[] { new KeyValuePair("tenantId", filterTenantId) }; + var fingerprintScope = excludeDeletions ? $"{baseScope}:nodel" : baseScope; + var nextLinkExtras = new List>(); + if (!string.IsNullOrEmpty(filterTenantId)) + nextLinkExtras.Add(new KeyValuePair("tenantId", filterTenantId)); + if (excludeDeletions) + nextLinkExtras.Add(new KeyValuePair("excludeDeletions", "true")); _logger.LogInformation( - "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}) for {User}", + "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}, excludeDeletions={ExcludeDeletions}) for {User}", parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", - filterTenantId ?? "(none)", userEmail); + filterTenantId ?? "(none)", excludeDeletions, userEmail); if (parsed.PageSize == null) { @@ -95,9 +102,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin var page = string.IsNullOrEmpty(filterTenantId) ? await _maintenanceRepo.GetAllAuditLogsPageAsync( - parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken) + parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions) : await _maintenanceRepo.GetAuditLogsPageAsync( - filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken, excludeDeletions); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 7c110175..20337c03 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -128,21 +128,31 @@ namespace AutopilotMonitor.Functions.Services } public Task> GetAuditLogsPageAsync( - string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); - return FetchAuditLogPageInternalAsync(BuildAuditLogFilter(tenantId, dateFrom, dateTo), pageSize, continuation); + return FetchAuditLogPageInternalAsync( + BuildAuditLogFilter(tenantId, dateFrom, dateTo, excludeDeletions), pageSize, continuation); } public Task> GetAllAuditLogsPageAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false) { // Cross-tenant: per-tenant fan-out + merge by RowKey. Without this // fan-out Azure pages by (PK asc, RK asc) cross-partition, surfacing // tenants alphabetically rather than newest-first globally. - return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation); + return FetchAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation, excludeDeletions); } + // Upper bound on Azure round-trips per page when back-filling past + // excluded rows. With server-side filtering each round-trip advances + // the scan by up to ~1000 rows, so this tolerates a window of ~tens of + // thousands of consecutive deletion entries before a page can come back + // short (still non-empty progress — the caller just clicks Next). + private const int AuditLogMaxFillRoundTrips = 20; + private async Task> FetchAuditLogPageInternalAsync( string? filter, int pageSize, string? continuation) { @@ -150,19 +160,42 @@ namespace AutopilotMonitor.Functions.Services try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( - client: tableClient, - filter: filter, - pageSize: pageSize, - continuation: continuation); // RowKey scheme is `!{revtick}_{guid}` so Azure-native (PK asc, RK asc) // already yields newest-first within the partition. No re-sort needed. // Legacy entries (bare GUID RowKey) sort *after* all new entries because // '!' (0x21) precedes hex digits — they appear at the tail of the result // in undefined order until they age out of the date window. - var logs = new List(entities.Count); - foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity)); + // + // Back-fill loop: a server-side exclusion (e.g. "exclude + // deletions") can make a single Azure page return far fewer than + // pageSize matches — during a cleanup sweep the first scanned + // rows may all be deletion bookkeeping. We follow the opaque + // continuation until we have at least pageSize matches or the + // partition is exhausted, then return the last consumed page's + // token. Accumulating whole Azure pages (rather than trimming to + // pageSize) keeps that token gap-free, so we may overshoot by up + // to one Azure page — harmless for the caller. Without an + // exclusion the first page already fills, so the loop exits + // immediately and behaviour is unchanged. + var logs = new List(pageSize); + string? nextRawToken = continuation; + var roundTrips = 0; + do + { + var (entities, token) = await AzureTablesPaginator.FetchPageAsync( + client: tableClient, + filter: filter, + pageSize: pageSize, + continuation: nextRawToken); + foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity)); + nextRawToken = token; + roundTrips++; + } + while (logs.Count < pageSize + && !string.IsNullOrEmpty(nextRawToken) + && roundTrips < AuditLogMaxFillRoundTrips); + return new RawPage(logs, nextRawToken); } catch (Exception ex) @@ -173,7 +206,7 @@ namespace AutopilotMonitor.Functions.Services } private async Task> FetchAllAuditLogsPageAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, bool excludeDeletions) { if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); try @@ -219,7 +252,7 @@ namespace AutopilotMonitor.Functions.Services var fetchTasks = activeTenantIds.Select(async tid => { continuations.TryGetValue(tid, out var prior); - var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey); + var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey, excludeDeletions); var fetched = new List<(string RowKey, AuditLogEntry Item)>(); await foreach (var e in tableClient.QueryAsync(filter: tenantFilter, maxPerPage: pageSize)) { @@ -259,14 +292,30 @@ namespace AutopilotMonitor.Functions.Services // and remain reachable via raw table queries. private const string AuditLogSuppressedPerformer = "System.Maintenance"; - private static string BuildAuditLogFilterWithRowKeyBound( - string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey) + // Per-session deletion bookkeeping. A single cleanup run emits one + // `deletion_started` + one `deletion_completed` audit row per session, + // so a retention sweep can flood the table with thousands of these. + // They carry the triggering operator's UPN (not System.Maintenance), so + // the performer exclusion above does not catch them. The "exclude + // deletions" audit view drops them server-side so they never consume a + // page slot. Keep in sync with the actions written by + // SessionDeletionProducer.cs. + private const string AuditLogDeletionStartedAction = "deletion_started"; + private const string AuditLogDeletionCompletedAction = "deletion_completed"; + + internal static string DeletionExclusionClause() + => $"Action ne '{AuditLogDeletionStartedAction}' and Action ne '{AuditLogDeletionCompletedAction}'"; + + internal static string BuildAuditLogFilterWithRowKeyBound( + string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey, bool excludeDeletions) { var clauses = new List { $"PartitionKey eq '{tenantId}'", $"PerformedBy ne '{AuditLogSuppressedPerformer}'", }; + if (excludeDeletions) + clauses.Add(DeletionExclusionClause()); if (!string.IsNullOrEmpty(lastRowKey)) clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'"); if (dateFrom.HasValue) @@ -292,12 +341,16 @@ namespace AutopilotMonitor.Functions.Services // sortable; the user-defined "Timestamp" property is set by LogAuditEntry // for parity with the model. We filter on the system Timestamp since it // is always indexed. - private static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo) + internal static string? BuildAuditLogFilter(string? tenantId, DateTime? dateFrom, DateTime? dateTo, bool excludeDeletions = false) { var clauses = new List { $"PerformedBy ne '{AuditLogSuppressedPerformer}'", }; + if (excludeDeletions) + { + clauses.Add(DeletionExclusionClause()); + } if (!string.IsNullOrEmpty(tenantId)) { clauses.Add($"PartitionKey eq '{tenantId}'"); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs index f64dfe92..647daac3 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -33,13 +33,20 @@ namespace AutopilotMonitor.Shared.DataAccess /// in the given window. The returned carries the /// underlying store's opaque continuation token; null when this /// page was the last. Items in each page are sorted newest-first. + /// When is set, per-session deletion + /// bookkeeping (deletion_started/deletion_completed) is + /// filtered out server-side and the page is back-filled to up to + /// real entries, so a cleanup-heavy window + /// never yields an empty page. /// Task> GetAuditLogsPageAsync( - string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false); /// Cross-tenant variant of (Global Admin only). Task> GetAllAuditLogsPageAsync( - DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, + bool excludeDeletions = false); // --- Data Retention Queries --- Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate); diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx index 9e2eeb7e..857c36c4 100644 --- a/src/Web/autopilot-monitor-web/app/audit/page.tsx +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -87,6 +87,12 @@ export default function AuditPage() { const { globalAdminMode } = useAdminMode(); + // The DEFAULT view ("All (excl. deletions)") is resolved server-side: the + // backend drops per-session deletion bookkeeping and back-fills the page, so + // a cleanup-heavy window still returns a full page of real entries instead of + // an all-deletions page that the client would strip down to nothing. + const excludeDeletions = actionFilter === 'DEFAULT'; + const fetchPage = useCallback(async ( nextContinuation: string | null, isInitial: boolean, @@ -99,6 +105,7 @@ export default function AuditPage() { dateTo: dateToIso, pageSize: PAGE_SIZE, continuation: nextContinuation ?? undefined, + excludeDeletions, }; const endpoint = globalAdminMode ? api.audit.globalLogs(opts) : api.audit.logs(opts); const response = await authenticatedFetch(endpoint, getAccessToken); @@ -124,7 +131,7 @@ export default function AuditPage() { setLoading(false); setRefreshing(false); } - }, [addNotification, dateFromIso, dateToIso, getAccessToken, globalAdminMode]); + }, [addNotification, dateFromIso, dateToIso, getAccessToken, globalAdminMode, excludeDeletions]); // Initial / window-change fetch resets pagination state. // fetchPage is intentionally excluded from deps: its identity churns whenever @@ -142,7 +149,7 @@ export default function AuditPage() { setPageNumber(1); fetchPage(null, true); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tenantId, globalAdminMode, dateFromIso, dateToIso]); + }, [tenantId, globalAdminMode, dateFromIso, dateToIso, excludeDeletions]); const handleRefresh = () => { fetchPage(continuation, false); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 1c34c44d..f6da3855 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -403,19 +403,21 @@ export const api = { // ── Audit ───────────────────────────────────────────────────────────────── audit: { - logs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) => + logs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean }) => `${API_BASE_URL}/api/audit/logs${qs({ dateFrom: opts?.dateFrom, dateTo: opts?.dateTo, pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, + excludeDeletions: opts?.excludeDeletions ? 'true' : undefined, })}`, - globalLogs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) => + globalLogs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string; excludeDeletions?: boolean }) => `${API_BASE_URL}/api/global/audit/logs${qs({ dateFrom: opts?.dateFrom, dateTo: opts?.dateTo, pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, + excludeDeletions: opts?.excludeDeletions ? 'true' : undefined, })}`, },