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,
})}`,
},