diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 924a9261..01d65e21 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -606 +628 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 837dd962..e5d76887 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1258 +1274 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs new file mode 100644 index 00000000..9b109651 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs @@ -0,0 +1,91 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Audit-log RowKey ordering invariants. Replaces the prior bare-GUID RowKey, +/// which made paged audit reads return random-order pages — Azure Tables sorts +/// by RowKey ASC within a partition, so a random GUID gave random ordering, and +/// the in-page Timestamp re-sort only fixed ordering inside that random slice +/// (page 1 was not necessarily the newest 50 audits). +/// +public class AuditLogRowKeyOrderingTests +{ + [Fact] + public void Newer_audit_rowkey_sorts_before_older_audit_rowkey() + { + // Azure Tables returns rows in (PK asc, RK asc); we want ASC = newest-first. + // Reverse-tick: newer timestamp → smaller revtick → smaller RowKey string. + var now = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc); + var oneHourAgo = now.AddHours(-1); + + var rkNow = TableStorageService.BuildAuditLogRowKey(now, Guid.Empty); + var rkOld = TableStorageService.BuildAuditLogRowKey(oneHourAgo, Guid.Empty); + + Assert.True(string.Compare(rkNow, rkOld, StringComparison.Ordinal) < 0, + $"Expected newer rowkey '{rkNow}' to sort before older rowkey '{rkOld}'"); + } + + [Fact] + public void New_audit_rowkey_sorts_before_legacy_bare_guid_rowkey_starting_with_digit() + { + // The biggest migration trap: legacy GUIDs starting with '0'-'9' would + // otherwise interleave with — or precede — current-era revtick values + // ("2…" or "3…"). The '!' prefix (0x21) sorts before all hex digits so + // every new entry lands ahead of every legacy entry. + var now = DateTime.UtcNow; + var newRk = TableStorageService.BuildAuditLogRowKey(now, Guid.NewGuid()); + + // Legacy: bare GUID, all 16 hex first chars are possible. + var legacyDigitGuid = "0a1b2c3d-4e5f-6789-abcd-ef0123456789"; + var legacyHexGuid = "ffffffff-ffff-ffff-ffff-ffffffffffff"; + var legacyZeroGuid = "00000000-0000-0000-0000-000000000000"; + + Assert.True(string.Compare(newRk, legacyDigitGuid, StringComparison.Ordinal) < 0, + $"New rowkey '{newRk}' should sort before legacy digit-GUID rowkey '{legacyDigitGuid}'"); + Assert.True(string.Compare(newRk, legacyHexGuid, StringComparison.Ordinal) < 0, + $"New rowkey '{newRk}' should sort before legacy hex-GUID rowkey '{legacyHexGuid}'"); + Assert.True(string.Compare(newRk, legacyZeroGuid, StringComparison.Ordinal) < 0, + $"New rowkey '{newRk}' should sort before legacy all-zero-GUID rowkey '{legacyZeroGuid}'"); + } + + [Fact] + public void Same_tick_writes_have_unique_rowkeys() + { + // Reverse-tick alone collides for two writes within the same 100-ns tick — + // the GUID suffix prevents one of them from silently overwriting the other. + var ts = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc); + + var rk1 = TableStorageService.BuildAuditLogRowKey(ts, Guid.NewGuid()); + var rk2 = TableStorageService.BuildAuditLogRowKey(ts, Guid.NewGuid()); + + Assert.NotEqual(rk1, rk2); + // Both share the same revtick prefix — only the suffix differs. + var prefix1 = rk1.Substring(0, 20); // "!" + 19 digits + var prefix2 = rk2.Substring(0, 20); + Assert.Equal(prefix1, prefix2); + } + + [Fact] + public void RowKey_starts_with_marker_prefix() + { + // Sanity check on the wire format — the prefix is what guarantees + // separation from legacy rows; if it ever drifts the migration window + // promise breaks silently. + var rk = TableStorageService.BuildAuditLogRowKey(DateTime.UtcNow, Guid.NewGuid()); + Assert.StartsWith("!", rk); + } + + [Fact] + public void Audit_global_tenant_id_is_a_well_formed_guid() + { + // The synthetic global-tenant partition is added to the audit fan-out + // alongside real tenants from TenantConfiguration. It must parse as a + // GUID since BuildAuditLogFilterWithRowKeyBound interpolates it into an + // OData PartitionKey filter — a malformed value would fail the table + // query rather than silently skipping the partition. + Assert.True(Guid.TryParse(Constants.AuditGlobalTenantId, out _), + $"Constants.AuditGlobalTenantId='{Constants.AuditGlobalTenantId}' must be a valid GUID"); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs new file mode 100644 index 00000000..abfae68d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Round-trip + tamper + replay tests for the pagination continuation token +/// codec (PR-1 of mcp-pagination-rollout). +/// +public class ContinuationTokenTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string AzureToken = "1!76!ZjQwYjBmYWMtMDcyNS00MzZjLWI2NTAtZmRl"; + + private static IEnumerable> FilterArgsA() => new[] + { + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("eventType", "enrollment_complete"), + new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"), + new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"), + }; + + private static IEnumerable> FilterArgsB() => new[] + { + new KeyValuePair("tenantId", TenantB), + new KeyValuePair("eventType", "enrollment_complete"), + new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"), + new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"), + }; + + [Fact] + public void Encode_then_TryDecode_returns_original_azure_token() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp); + + Assert.True(ContinuationToken.TryDecode(encoded, TenantA, fp, out var decoded, out var reason)); + Assert.Null(reason); + Assert.Equal(AzureToken, decoded); + } + + [Fact] + public void TryDecode_rejects_token_for_a_different_tenant() + { + // Token issued for tenant A, replayed by tenant B — must fail before any + // data is read regardless of fingerprint plausibility. + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp); + + var fpForB = ContinuationToken.ComputeFingerprint(FilterArgsB()); + Assert.False(ContinuationToken.TryDecode(encoded, TenantB, fpForB, out var decoded, out var reason)); + Assert.Equal("cross_tenant", reason); + Assert.Equal(string.Empty, decoded); + } + + [Fact] + public void TryDecode_rejects_token_when_filter_fingerprint_changes() + { + // Token issued for filter X, caller now sends filter Y → reject; token + // would otherwise reseek into a different table layout. + var fpOriginal = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fpOriginal); + + var fpDifferent = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("eventType", "enrollment_failed"), // changed + new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"), + new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"), + }); + + Assert.False(ContinuationToken.TryDecode(encoded, TenantA, fpDifferent, out _, out var reason)); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryDecode_rejects_token_with_tampered_payload() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp); + + // Flip a byte in the middle of the base64url payload — decoder either + // fails parsing or rejects on tenant/fingerprint check; either way no + // entries are exposed. + var tampered = encoded.Substring(0, encoded.Length / 2) + "X" + encoded.Substring(encoded.Length / 2 + 1); + + Assert.False(ContinuationToken.TryDecode(tampered, TenantA, fp, out _, out var reason)); + Assert.NotNull(reason); + } + + [Fact] + public void TryDecode_rejects_expired_token() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var issued = DateTimeOffset.UtcNow - TimeSpan.FromHours(48); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp, issued); + + Assert.False(ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason)); + Assert.Equal("expired", reason); + } + + [Fact] + public void TryDecode_accepts_token_within_default_ttl() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var issued = DateTimeOffset.UtcNow - TimeSpan.FromHours(23); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp, issued); + + Assert.True(ContinuationToken.TryDecode(encoded, TenantA, fp, out var decoded, out _)); + Assert.Equal(AzureToken, decoded); + } + + [Fact] + public void TryDecode_rejects_garbage_input() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + Assert.False(ContinuationToken.TryDecode("not a token", TenantA, fp, out _, out var reason)); + Assert.NotNull(reason); + } + + [Fact] + public void TryDecode_rejects_empty_input() + { + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + Assert.False(ContinuationToken.TryDecode("", TenantA, fp, out _, out var reason)); + Assert.Equal("empty", reason); + } + + [Fact] + public void ComputeFingerprint_is_stable_regardless_of_arg_order() + { + var fp1 = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"), + new KeyValuePair("eventType", "x"), + }); + var fp2 = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("eventType", "x"), + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"), + }); + Assert.Equal(fp1, fp2); + } + + [Fact] + public void ComputeFingerprint_treats_keys_case_insensitively() + { + var fp1 = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("TenantId", TenantA), + new KeyValuePair("EventType", "x"), + }); + var fp2 = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantid", TenantA), + new KeyValuePair("eventtype", "x"), + }); + Assert.Equal(fp1, fp2); + } + + [Fact] + public void ComputeFingerprint_drops_null_and_empty_values() + { + // Optional filter args (null/empty) must not perturb the fingerprint — + // otherwise endpoints that pass them through unconditionally would + // produce non-resumable tokens. + var withOptionals = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("eventType", "x"), + new KeyValuePair("dateFrom", null), + new KeyValuePair("dateTo", ""), + }); + var withoutOptionals = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantA), + new KeyValuePair("eventType", "x"), + }); + Assert.Equal(withOptionals, withoutOptionals); + } + + [Fact] + public void ComputeFingerprint_changes_when_value_differs() + { + var a = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantA), + }); + var b = ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", TenantB), + }); + Assert.NotEqual(a, b); + } + + [Fact] + public void Encode_throws_on_empty_tenant_or_fingerprint() + { + Assert.Throws(() => ContinuationToken.Encode(AzureToken, "", "fp")); + Assert.Throws(() => ContinuationToken.Encode(AzureToken, TenantA, "")); + Assert.Throws(() => ContinuationToken.Encode(null!, TenantA, "fp")); + } + + // ────────── HMAC signature integrity ──────────────────────────────────── + + [Fact] + public void TryDecode_rejects_iat_bypass_attempt() + { + // The whole point of HMAC: a client that hand-crafts a token with a + // fresh `iat` to bypass the 24h expiry must be rejected. The fingerprint + // and tenantId checks alone don't catch this — only the signature does. + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp, + issuedAt: DateTimeOffset.UtcNow - TimeSpan.FromHours(48)); + + // Decode the token's payload, push iat to "now", re-encode without + // re-signing — exactly what an attacker would try. + var jsonBytes = Base64UrlDecode(encoded); + using var doc = System.Text.Json.JsonDocument.Parse(jsonBytes); + var rebuilt = new System.Collections.Generic.Dictionary(); + foreach (var prop in doc.RootElement.EnumerateObject()) + { + rebuilt[prop.Name] = prop.Name == "iat" + ? (object)DateTimeOffset.UtcNow.ToUnixTimeSeconds() + : prop.Value.ValueKind == System.Text.Json.JsonValueKind.String + ? prop.Value.GetString()! + : (object)prop.Value.GetInt64(); + } + var tamperedJson = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(rebuilt); + var tampered = Base64UrlEncode(tamperedJson); + + Assert.False(ContinuationToken.TryDecode(tampered, TenantA, fp, out _, out var reason)); + Assert.Equal("bad_signature", reason); + } + + [Fact] + public void TryDecode_rejects_payload_with_missing_signature() + { + // Crafted token without the `sig` field — the codec must refuse to + // proceed before any other field is even examined. + var unsigned = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new + { + t = AzureToken, + tid = TenantA, + fp = "deadbeef", + iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + }); + var raw = Base64UrlEncode(unsigned); + + Assert.False(ContinuationToken.TryDecode(raw, TenantA, "deadbeef", out _, out var reason)); + Assert.Equal("missing_signature", reason); + } + + [Fact] + public void TryDecode_rejects_token_signed_with_a_different_key() + { + // Simulate key rotation: token issued under key A, validated under key B. + // (We toggle the test key around the encode/decode pair.) + var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1); + var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7); + + var fp = ContinuationToken.ComputeFingerprint(FilterArgsA()); + + ContinuationToken.SetSigningKeyForTesting(keyA); + var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp); + + ContinuationToken.SetSigningKeyForTesting(keyB); + var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason); + + // Restore the test default before leaving so subsequent tests aren't affected. + TestSetup.Initialize(); + + Assert.False(ok); + Assert.Equal("bad_signature", reason); + } + + // Test-only mirrors of the Base64Url helpers in ContinuationToken — they're + // private there, but we need the same encoding to construct adversarial tokens. + private static string Base64UrlEncode(byte[] bytes) + { + var s = Convert.ToBase64String(bytes); + return s.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + private static byte[] Base64UrlDecode(string s) + { + var padded = s.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + return Convert.FromBase64String(padded); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs new file mode 100644 index 00000000..81f2ec10 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 3 (mcp-pagination-rollout) — date-windowed forensics endpoints +/// (audit logs + ops events). Verifies the shared +/// helper and its cross-tenant / +/// cross-window guarantees. +/// +public class DateWindowPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + // ────────── ParseQuery ────────────────────────────────────────────────── + + [Fact] + public void ParseQuery_with_no_window_defaults_to_30d_ending_at_now() + { + var anchor = new DateTimeOffset(2026, 5, 5, 14, 0, 0, TimeSpan.Zero); + var parsed = DateWindowPagination.ParseQuery(new NameValueCollection(), anchor); + + Assert.Null(parsed.Error); + Assert.NotNull(parsed.DateFrom); + Assert.NotNull(parsed.DateTo); + Assert.Equal(anchor.UtcDateTime, parsed.DateTo); + Assert.Equal(anchor.UtcDateTime - TimeSpan.FromDays(30), parsed.DateFrom); + } + + [Fact] + public void ParseQuery_honours_explicit_dateFrom_and_dateTo_exactly() + { + var qs = new NameValueCollection + { + { "dateFrom", "2026-04-01T00:00:00Z" }, + { "dateTo", "2026-05-01T00:00:00Z" }, + }; + var parsed = DateWindowPagination.ParseQuery(qs); + + Assert.Null(parsed.Error); + Assert.Equal(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateFrom); + Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateTo); + } + + [Fact] + public void ParseQuery_with_only_dateFrom_leaves_dateTo_open() + { + var qs = new NameValueCollection { { "dateFrom", "2026-04-01T00:00:00Z" } }; + var parsed = DateWindowPagination.ParseQuery(qs); + + Assert.Null(parsed.Error); + Assert.Equal(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateFrom); + Assert.Null(parsed.DateTo); + } + + [Fact] + public void ParseQuery_rejects_dateFrom_after_dateTo() + { + var qs = new NameValueCollection + { + { "dateFrom", "2026-06-01T00:00:00Z" }, + { "dateTo", "2026-04-01T00:00:00Z" }, + }; + var parsed = DateWindowPagination.ParseQuery(qs); + + Assert.NotNull(parsed.Error); + } + + [Fact] + public void ParseQuery_rejects_garbage_dateFrom() + { + var qs = new NameValueCollection { { "dateFrom", "not-a-date" } }; + var parsed = DateWindowPagination.ParseQuery(qs); + Assert.NotNull(parsed.Error); + } + + [Theory] + [InlineData("0")] + [InlineData("1001")] + [InlineData("banana")] + public void ParseQuery_rejects_invalid_pageSize(string raw) + { + var qs = new NameValueCollection { { "pageSize", raw } }; + var parsed = DateWindowPagination.ParseQuery(qs); + Assert.NotNull(parsed.Error); + } + + [Fact] + public void ParseQuery_drops_continuation_when_pageSize_absent() + { + var qs = new NameValueCollection { { "continuation", "STALE" } }; + var parsed = DateWindowPagination.ParseQuery(qs); + + Assert.Null(parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Fact] + public void ParseQuery_with_pageSize_activates_pagination() + { + var qs = new NameValueCollection + { + { "pageSize", "50" }, + { "continuation", "ENCODED" }, + }; + var parsed = DateWindowPagination.ParseQuery(qs); + + Assert.Equal(50, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + } + + // ────────── Fingerprint + token round trip ────────────────────────────── + + [Fact] + public void TryAcceptContinuation_round_trips_when_window_and_tenant_match() + { + var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); + var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var fp = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = DateWindowPagination.TryAcceptContinuation( + encoded, "audit:tenant", TenantA, dateFrom, dateTo, extras: null, + out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_for_a_different_tenant() + { + var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); + var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var fp = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = DateWindowPagination.TryAcceptContinuation( + encoded, "audit:tenant", TenantB, dateFrom, dateTo, extras: null, + out var _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_window_changes() + { + var fpA = DateWindowPagination.Fingerprint( + "audit:tenant", TenantA, + new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc)); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA); + + var ok = DateWindowPagination.TryAcceptContinuation( + encoded, "audit:tenant", TenantA, + // Caller now sends a different window — token must be rejected, + // otherwise the underlying Pk/Rk cursor would seek into rows that + // no longer match the active filter. + new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 5, 2, 0, 0, 0, DateTimeKind.Utc), + extras: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_scope_changes() + { + // A token issued for the tenant-scoped audit endpoint must not be + // accepted by the global-scoped endpoint (or vice versa) — the scope + // is part of the fingerprint precisely so the two are non-fungible. + var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); + var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var fpTenant = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant); + + var ok = DateWindowPagination.TryAcceptContinuation( + encoded, "audit:global", TenantA, dateFrom, dateTo, extras: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_for_ops_events_includes_category_in_fingerprint() + { + var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc); + var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var withConsent = new[] { new KeyValuePair("category", "Consent") }; + var fp = DateWindowPagination.Fingerprint("ops-events", TenantA, dateFrom, dateTo, withConsent); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + // Same scope/tenant/window but different category → must reject. + var withSecurity = new[] { new KeyValuePair("category", "Security") }; + var ok = DateWindowPagination.TryAcceptContinuation( + encoded, "ops-events", TenantA, dateFrom, dateTo, withSecurity, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_includes_pageSize_continuation_dateFrom_dateTo_and_extras() + { + var dateFrom = new DateTime(2026, 4, 1, 12, 30, 0, DateTimeKind.Utc); + var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + var extras = new[] { new KeyValuePair("category", "Consent") }; + + var link = DateWindowPagination.BuildNextLink( + "/api/global/ops-events", 50, "TOKEN+/=", dateFrom, dateTo, extras); + + Assert.StartsWith("/api/global/ops-events?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=TOKEN%2B%2F%3D", link); + Assert.Contains("dateFrom=2026-04-01T12%3A30%3A00", link); + Assert.Contains("dateTo=2026-05-01T00%3A00%3A00", link); + Assert.Contains("category=Consent", link); + } + + [Fact] + public void BuildNextLink_omits_optional_bounds_and_empty_extras() + { + var link = DateWindowPagination.BuildNextLink( + "/api/audit/logs", 50, "abc", dateFrom: null, dateTo: null, + extras: new[] { new KeyValuePair("category", null) }); + + Assert.StartsWith("/api/audit/logs?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=abc", link); + Assert.DoesNotContain("dateFrom=", link); + Assert.DoesNotContain("dateTo=", link); + Assert.DoesNotContain("category=", link); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs new file mode 100644 index 00000000..ef17404a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs @@ -0,0 +1,278 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.DataAccess; +using static AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Cross-partition merge used by both the global all-category ops-events page +/// and the global cross-tenant audit-logs page. Without this fan-out, Azure +/// pages cross-partition queries by (PK asc, RK asc) — so page 1 would be +/// entirely the alphabetically-first partition, masking newer items from +/// later-alphabet partitions. The merge picks top-pageSize by timestamp across +/// all per-partition fetches. +/// +public class PerPartitionFanOutMergeTests +{ + private static OpsEventEntry Entry(string partition, DateTime ts, string id) + => new() { Category = partition, Id = id, Timestamp = ts, EventType = "synthetic" }; + + private static (string RowKey, OpsEventEntry Item) Row(DateTime ts, string id) + { + // Mimics the storage-layer revtick rowkey: smaller string = newer timestamp. + var rk = $"{(DateTime.MaxValue.Ticks - ts.Ticks):D19}_{id}"; + return (rk, Entry(partition: "", ts, id)); + } + + private static PartitionFetchResult Fetched( + string partition, params (string RowKey, OpsEventEntry Item)[] rows) + { + var aligned = rows + .Select(r => (r.RowKey, Item: new OpsEventEntry + { + Id = r.Item.Id, + Category = partition, + EventType = r.Item.EventType, + Timestamp = r.Item.Timestamp, + })) + .ToArray(); + return new PartitionFetchResult(partition, aligned); + } + + // ────────── Merge picks newest-across-partitions, not within-partition ── + + [Fact] + public void First_page_returns_newest_across_partitions_not_alphabetically_first() + { + // Without the merge, Azure's (PK asc, RK asc) ordering would surface + // "Agent" partition items first (alphabetically), even though "Security" + // has a newer event. The merge must pick by timestamp, not partition. + var t0 = DateTime.UtcNow; + var fetched = new[] + { + Fetched("Agent", Row(t0.AddMinutes(-5), "agent-1")), + Fetched("Security", Row(t0, "sec-1")), + Fetched("Tenant", Row(t0.AddMinutes(-10), "tenant-1")), + }; + + var (items, _) = MergeAndAdvance( + fetched, + new Dictionary(), + pageSize: 3, + timestampSelector: e => e.Timestamp); + + Assert.Equal(3, items.Count); + Assert.Equal("sec-1", items[0].Id); // newest first + Assert.Equal("agent-1", items[1].Id); + Assert.Equal("tenant-1", items[2].Id); + } + + [Fact] + public void Empty_fetch_marks_partition_exhausted_so_next_page_skips_it() + { + var t0 = DateTime.UtcNow; + var fetched = new[] + { + Fetched("Agent"), // empty fetch + Fetched("Security", Row(t0, "sec-1")), + }; + + var (_, conts) = MergeAndAdvance( + fetched, + new Dictionary(), + pageSize: 5, + timestampSelector: e => e.Timestamp); + + Assert.True(conts["Agent"].Exhausted); + Assert.False(conts["Security"].Exhausted); + } + + // ────────── Continuation advance: avoid losing leftovers when a partition loses the cut ── + + [Fact] + public void Continuation_keeps_position_for_partitions_whose_items_lost_the_merge_cut() + { + // Page 1: pageSize=3. Agent fetched 5 items, Security fetched 5 items, but + // Security's are all newer. Merge takes 3 from Security; Agent's 5 items + // all lost the cut. Agent's continuation MUST stay at its previous + // position so the items get re-fetched and compete on page 2. + var t0 = DateTime.UtcNow; + var fetched = new[] + { + Fetched("Agent", + Row(t0.AddMinutes(-100), "a1"), + Row(t0.AddMinutes(-101), "a2"), + Row(t0.AddMinutes(-102), "a3"), + Row(t0.AddMinutes(-103), "a4"), + Row(t0.AddMinutes(-104), "a5")), + Fetched("Security", + Row(t0, "s1"), + Row(t0.AddMinutes(-1), "s2"), + Row(t0.AddMinutes(-2), "s3"), + Row(t0.AddMinutes(-3), "s4"), + Row(t0.AddMinutes(-4), "s5")), + }; + + var (items, conts) = MergeAndAdvance( + fetched, + new Dictionary(), + pageSize: 3, + timestampSelector: e => e.Timestamp); + + // Security wins all three slots. + Assert.Equal(new[] { "s1", "s2", "s3" }, items.Select(i => i.Id).ToArray()); + + // Agent had no items returned and a non-empty fetch → keep prior continuation + // (null = restart). The partition is NOT marked exhausted, so the next page + // will re-fetch its items and they'll be re-considered at a lower floor. + Assert.False(conts["Agent"].Exhausted); + Assert.Null(conts["Agent"].LastRowKey); + + Assert.False(conts["Security"].Exhausted); + Assert.NotNull(conts["Security"].LastRowKey); + } + + [Fact] + public void Continuation_advances_to_oldest_returned_rowkey_per_partition() + { + // When merge takes some-but-not-all from a partition, advance only past the + // items we actually returned. The leftover (older) items in that fetch + // will be re-fetched and re-considered on the next page. + var t0 = DateTime.UtcNow; + var (rkNew, _) = Row(t0, "x-new"); + var (rkMid, _) = Row(t0.AddMinutes(-5), "x-mid"); + var (rkOld, _) = Row(t0.AddMinutes(-10), "x-old"); + var fetched = new[] + { + Fetched("Agent", + (rkNew, Entry("Agent", t0, "x-new")), + (rkMid, Entry("Agent", t0.AddMinutes(-5), "x-mid")), + (rkOld, Entry("Agent", t0.AddMinutes(-10), "x-old"))), + // A second partition whose only event is between Agent's mid and old + // items, ensuring the merge ends after Agent's mid item. + Fetched("Security", + Row(t0.AddMinutes(-7), "s-only")), + }; + + var (items, conts) = MergeAndAdvance( + fetched, + new Dictionary(), + pageSize: 3, + timestampSelector: e => e.Timestamp); + + // Top 3 by timestamp desc: x-new, x-mid, s-only. x-old is dropped. + Assert.Equal(new[] { "x-new", "x-mid", "s-only" }, items.Select(i => i.Id).ToArray()); + + // Agent's continuation advances to rkMid (the oldest Agent rowkey we returned). + // x-old will be re-fetched and re-considered next page (filter: RowKey gt rkMid). + Assert.Equal(rkMid, conts["Agent"].LastRowKey); + Assert.False(conts["Agent"].Exhausted); + } + + // ────────── Codec round-trip ──────────────────────────────────────────── + + [Fact] + public void Encode_then_decode_round_trips_continuation_map() + { + var original = new Dictionary + { + ["Agent"] = new PartitionContinuation("0123456789012345678_aaa", false), + ["Security"] = new PartitionContinuation(null, true), + ["Consent"] = new PartitionContinuation("0987654321098765432_bbb", false), + }; + + var encoded = EncodeMultiContinuation(original); + var decoded = DecodeMultiContinuation(encoded); + + Assert.Equal(3, decoded.Count); + Assert.Equal("0123456789012345678_aaa", decoded["Agent"].LastRowKey); + Assert.False(decoded["Agent"].Exhausted); + Assert.True(decoded["Security"].Exhausted); + Assert.Equal("0987654321098765432_bbb", decoded["Consent"].LastRowKey); + } + + [Fact] + public void Decode_returns_empty_for_malformed_or_empty_continuation() + { + Assert.Empty(DecodeMultiContinuation(null)); + Assert.Empty(DecodeMultiContinuation("")); + Assert.Empty(DecodeMultiContinuation("not-base64-!@#")); + Assert.Empty(DecodeMultiContinuation("dGhpcyBpcyBub3QganNvbg==")); // valid base64, not JSON + } + + // ────────── End-to-end: drain across pages preserves all items, in order ── + + [Fact] + public void Multi_page_drain_returns_all_items_globally_newest_first_no_duplicates() + { + var t0 = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc); + + var allItems = new List<(string Part, OpsEventEntry Item, string Rk)>(); + var partitions = new[] { "Agent", "Security", "Tenant" }; + var minute = 0; + foreach (var part in partitions) + { + for (int i = 0; i < 4; i++) + { + minute++; + var ts = t0.AddMinutes(-minute); + var (rk, _) = Row(ts, $"{part}-{i}"); + allItems.Add((part, new OpsEventEntry + { + Category = part, + Id = $"{part}-{i}", + Timestamp = ts, + EventType = "synthetic", + }, rk)); + } + } + + IReadOnlyList> SimulateFetch( + Dictionary continuations, int pageSize) + { + var fetched = new List>(); + foreach (var part in partitions) + { + if (continuations.TryGetValue(part, out var c) && c.Exhausted) continue; + continuations.TryGetValue(part, out var prior); + var lastRk = prior?.LastRowKey; + + var rows = allItems + .Where(t => t.Part == part) + .Where(t => lastRk == null || string.Compare(t.Rk, lastRk, StringComparison.Ordinal) > 0) + .OrderBy(t => t.Rk, StringComparer.Ordinal) + .Take(pageSize) + .Select(t => (t.Rk, t.Item)) + .ToList(); + fetched.Add(new PartitionFetchResult(part, rows)); + } + return fetched; + } + + const int pageSize = 5; + var seen = new List(); + var conts = new Dictionary(); + var loopGuard = 0; + + while (true) + { + var fetched = SimulateFetch(conts, pageSize); + var (page, nextConts) = MergeAndAdvance(fetched, conts, pageSize, e => e.Timestamp); + seen.AddRange(page); + conts = nextConts; + + bool anyActive = conts.Any(kv => !kv.Value.Exhausted); + if (!anyActive) break; + if (page.Count == 0) break; + if (++loopGuard > 50) Assert.Fail("Drain loop did not terminate"); + } + + Assert.Equal(12, seen.Count); + Assert.Equal(12, seen.Select(e => e.Id).Distinct().Count()); + for (int i = 1; i < seen.Count; i++) + { + Assert.True(seen[i - 1].Timestamp >= seen[i].Timestamp, + $"Out-of-order at index {i}: {seen[i - 1].Id}({seen[i - 1].Timestamp}) before {seen[i].Id}({seen[i].Timestamp})"); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs index d1953c86..34f01721 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs @@ -16,8 +16,8 @@ public class PlatformMetricsServiceWindowTests { var sessionRepo = new Mock(); sessionRepo - .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false }); + .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); sessionRepo .Setup(r => r.GetSessionEventsAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List()); @@ -37,9 +37,9 @@ public class PlatformMetricsServiceWindowTests int? capturedDays = null; repo - .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, d) => capturedDays = d) - .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false }); + .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny())) + .Callback((_, d) => capturedDays = d) + .ReturnsAsync(new List()); var result = await service.ComputePlatformMetricsAsync(days); @@ -54,9 +54,9 @@ public class PlatformMetricsServiceWindowTests int? capturedDays = null; repo - .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, d) => capturedDays = d) - .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false }); + .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny())) + .Callback((_, d) => capturedDays = d) + .ReturnsAsync(new List()); var result = await service.ComputePlatformMetricsAsync(0); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs new file mode 100644 index 00000000..4f93ddbe --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs @@ -0,0 +1,393 @@ +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 6 (mcp-pagination-rollout) — pagination wiring on +/// GET /api/raw/events + GET /api/global/raw/events. Replaces the +/// legacy ?limit= wire shape and the hard-coded limit:20 +/// EventTypeIndex lookup that silently capped recall on large tenants. +/// Includes the single-session path, which paginates session events so +/// sessions with more matching events than pageSize stay reachable +/// across multiple nextLink follow-ups. +/// +public class QueryRawEventsPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string FilterA = "00000000-1111-2222-3333-444444444444"; + private const string SessionA = "11111111-2222-3333-4444-555555555555"; + private const string SessionB = "99999999-8888-7777-6666-555555555555"; + + // ────────── ParsePagination ───────────────────────────────────────────── + + [Fact] + public void ParsePagination_with_no_params_returns_default_pageSize() + { + var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection()); + + Assert.Null(parsed.Error); + Assert.Equal(QueryRawEventsPagination.DefaultPageSize, parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Fact] + public void ParsePagination_honours_pageSize_and_continuation() + { + var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection + { + { "pageSize", "500" }, + { "continuation", "ENCODED" }, + }); + + Assert.Equal(500, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("1001")] + [InlineData("banana")] + public void ParsePagination_rejects_invalid_pageSize(string raw) + { + var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection { { "pageSize", raw } }); + Assert.NotNull(parsed.Error); + } + + // ────────── Cross-session token round-trip + cross-binding rejections ─── + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_filters() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", + callerTenantId: TenantA, + filterTenantId: null, + sessionId: null, + eventType: "app_install_failed", + source: "Teams", + severity: "Error", + startedAfter: null, + startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", + callerTenantId: TenantA, + filterTenantId: null, + sessionId: null, + eventType: "app_install_failed", + source: "Teams", + severity: "Error", + startedAfter: null, + startedBefore: null, + out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_for_a_different_caller() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantB, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_eventType_changes() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "error_detected", source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_severity_changes() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: "Error", + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: "Critical", + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_source_changes() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: "Teams", severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: "Office", severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_date_window_changes() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: "2026-04-01T00:00:00Z", startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: "2026-05-01T00:00:00Z", startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_scope_changes() + { + var fpTenant = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:global", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_global_filterTenantId_changes() + { + var fpA = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:global", callerTenantId: TenantA, + filterTenantId: FilterA, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:global", callerTenantId: TenantA, + filterTenantId: "99999999-9999-9999-9999-999999999999", + sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── Single-session path: round-trip + cross-binding ───────────── + + [Fact] + public void TryAcceptContinuation_round_trips_on_single_session_path() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: null, + startedAfter: null, startedBefore: null, + out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_replayed_for_a_different_session() + { + // Cursor issued for sessionA must NOT be accepted when caller switches to + // sessionB — otherwise events from sessionB could be served against the + // /api/raw/events?sessionId= API contract. + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionB, + eventType: null, source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_single_session_token_replayed_into_cross_session_path() + { + // Token bound to sessionId = SessionA must not silently work when the + // caller drops sessionId and queries cross-session by eventType only. + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_single_session_token_when_severity_filter_changes() + { + var fp = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: "Error", + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = QueryRawEventsPagination.TryAcceptContinuation( + raw: encoded, + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: SessionA, + eventType: null, source: null, severity: "Critical", + startedAfter: null, startedBefore: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys() + { + var query = new NameValueCollection + { + { "eventType", "app_install_failed" }, + { "severity", "Error" }, + { "source", "Teams" }, + { "startedAfter", "2026-04-01T00:00:00Z" }, + { "limit", "100" }, // legacy → must be dropped + { "pageSize", "999" }, // owned by new contract — overwritten + { "continuation", "STALE" }, // owned by new contract — overwritten + }; + + var link = QueryRawEventsPagination.BuildNextLink( + "/api/raw/events", 200, "FRESH+/=", query); + + Assert.StartsWith("/api/raw/events?", link); + Assert.Contains("pageSize=200", link); + Assert.Contains("continuation=FRESH%2B%2F%3D", link); + Assert.Contains("eventType=app_install_failed", link); + Assert.Contains("severity=Error", link); + Assert.Contains("source=Teams", link); + Assert.Contains("startedAfter=2026-04-01T00%3A00%3A00Z", link); + Assert.DoesNotContain("limit=", link); + Assert.DoesNotContain("STALE", link); + } + + [Fact] + public void BuildNextLink_preserves_sessionId_on_single_session_path() + { + // Single-session path must echo sessionId so the bookmark survives + // round-trips and the AI/UI doesn't lose context across pages. + var query = new NameValueCollection + { + { "sessionId", SessionA }, + { "tenantId", TenantA }, + }; + + var link = QueryRawEventsPagination.BuildNextLink( + "/api/raw/events", 200, "TOKEN", query); + + Assert.Contains($"sessionId={SessionA}", link); + Assert.Contains($"tenantId={TenantA}", link); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs new file mode 100644 index 00000000..bff03f22 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs @@ -0,0 +1,183 @@ +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pagination wiring on GET /api/search/sessions-by-event + +/// GET /api/global/search/sessions-by-event — closes the gap left after +/// PR-6 where the repo's paged variant existed but the endpoint kept its +/// legacy ?limit= max-100 contract. +/// +public class SearchSessionsByEventPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string FilterA = "00000000-1111-2222-3333-444444444444"; + + // ────────── ParsePagination ───────────────────────────────────────────── + + [Fact] + public void ParsePagination_with_no_params_returns_default_pageSize() + { + var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection()); + + Assert.Null(parsed.Error); + Assert.Equal(SearchSessionsByEventPagination.DefaultPageSize, parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Fact] + public void ParsePagination_honours_pageSize_and_continuation() + { + var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection + { + { "pageSize", "200" }, + { "continuation", "ENCODED" }, + }); + + Assert.Equal(200, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("1001")] + [InlineData("banana")] + public void ParsePagination_rejects_invalid_pageSize(string raw) + { + var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection { { "pageSize", raw } }); + Assert.NotNull(parsed.Error); + } + + // ────────── Token round-trip + cross-binding rejections ───────────────── + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_eventType() + { + var fp = SearchSessionsByEventPagination.Fingerprint( + "search-by-event:tenant", TenantA, filterTenantId: null, eventType: "app_install_failed"); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:tenant", TenantA, filterTenantId: null, + eventType: "app_install_failed", out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_for_different_caller() + { + var fp = SearchSessionsByEventPagination.Fingerprint( + "search-by-event:tenant", TenantA, null, "app_install_failed"); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:tenant", TenantB, null, "app_install_failed", + out _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_eventType_changes() + { + var fp = SearchSessionsByEventPagination.Fingerprint( + "search-by-event:tenant", TenantA, null, "app_install_failed"); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:tenant", TenantA, null, "error_detected", + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_scope_changes() + { + // Tenant-scoped token replayed into the global endpoint — tokens must + // be non-fungible across the two endpoints even with the same caller + + // eventType (the underlying queries differ). + var fpTenant = SearchSessionsByEventPagination.Fingerprint( + "search-by-event:tenant", TenantA, null, "app_install_failed"); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:global", TenantA, null, "app_install_failed", + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_global_filterTenantId_changes() + { + var fpA = SearchSessionsByEventPagination.Fingerprint( + "search-by-event:global", TenantA, FilterA, "app_install_failed"); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:global", TenantA, + filterTenantId: "99999999-9999-9999-9999-999999999999", + eventType: "app_install_failed", + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_across_search_by_event_and_raw_events_scopes() + { + // A token from the cousin endpoint /api/raw/events (raw-events:tenant) + // must not be accepted here, even with identical eventType + caller. + var fpRaw = QueryRawEventsPagination.Fingerprint( + scope: "raw-events:tenant", callerTenantId: TenantA, + filterTenantId: null, sessionId: null, + eventType: "app_install_failed", source: null, severity: null, + startedAfter: null, startedBefore: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpRaw); + + var ok = SearchSessionsByEventPagination.TryAcceptContinuation( + encoded, "search-by-event:tenant", TenantA, null, "app_install_failed", + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys() + { + var query = new NameValueCollection + { + { "eventType", "app_install_failed" }, + { "tenantId", FilterA }, + { "limit", "100" }, // legacy → must be dropped + { "pageSize", "999" }, // overwritten by the new contract + { "continuation", "STALE" }, // overwritten by the new contract + }; + + var link = SearchSessionsByEventPagination.BuildNextLink( + "/api/global/search/sessions-by-event", 50, "FRESH+/=", query); + + Assert.StartsWith("/api/global/search/sessions-by-event?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=FRESH%2B%2F%3D", link); + Assert.Contains("eventType=app_install_failed", link); + Assert.Contains($"tenantId={FilterA}", link); + Assert.DoesNotContain("limit=", link); + Assert.DoesNotContain("STALE", link); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs new file mode 100644 index 00000000..fd00015a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs @@ -0,0 +1,224 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 5 (mcp-pagination-rollout) — pagination wiring on +/// GET /api/search/sessions + GET /api/global/search/sessions. +/// Replaces the legacy ?limit= wire shape with the rollout plan's +/// ?pageSize= + ?continuation= + nextLink contract. +/// +public class SearchSessionsPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + private static SessionSearchFilter SimpleFilter() => new() { Status = "Failed", Manufacturer = "Microsoft" }; + + private static SessionSearchFilter HardwareFilter() => new() + { + Status = "Failed", + DeviceProperties = new Dictionary + { + ["tpm_status.specVersion"] = "2.0", + ["hardware_spec.ramTotalGB"] = ">=8", + }, + }; + + // ────────── ParsePagination ───────────────────────────────────────────── + + [Fact] + public void ParsePagination_with_no_params_returns_default_pageSize() + { + var parsed = SearchSessionsPagination.ParsePagination(new NameValueCollection()); + + Assert.Null(parsed.Error); + Assert.Equal(SearchSessionsPagination.DefaultPageSize, parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Fact] + public void ParsePagination_honours_pageSize_and_continuation() + { + var parsed = SearchSessionsPagination.ParsePagination(new NameValueCollection + { + { "pageSize", "200" }, + { "continuation", "ENCODED" }, + }); + + Assert.Equal(200, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + } + + [Theory] + [InlineData("0")] + [InlineData("1001")] + [InlineData("banana")] + public void ParsePagination_rejects_invalid_pageSize(string raw) + { + var parsed = SearchSessionsPagination.ParsePagination( + new NameValueCollection { { "pageSize", raw } }); + Assert.NotNull(parsed.Error); + } + + // ────────── Token round-trip + cross-binding rejections ───────────────── + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_filter() + { + var filter = SimpleFilter(); + var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, filterTenantId: null, filter); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:tenant", TenantA, filterTenantId: null, filter, + out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_for_different_caller() + { + var filter = SimpleFilter(); + var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, filterTenantId: null, filter); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:tenant", TenantB, filterTenantId: null, filter, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_status_changes() + { + var filterFailed = new SessionSearchFilter { Status = "Failed" }; + var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filterFailed); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var filterSucceeded = new SessionSearchFilter { Status = "Succeeded" }; + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:tenant", TenantA, null, filterSucceeded, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_across_scan_and_snapshot_paths() + { + // Same other filters, but adding/removing deviceProperties switches the + // backend code path (scan ↔ device-snapshot). The fingerprint encodes + // "path" so the cursor can't be replayed across paths. + var simple = SimpleFilter(); + var hardware = HardwareFilter(); + var fpScan = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, simple); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpScan); + + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:tenant", TenantA, null, hardware, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_device_property_changes() + { + var filterTpm20 = new SessionSearchFilter + { + DeviceProperties = new Dictionary { ["tpm_status.specVersion"] = "2.0" }, + }; + var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filterTpm20); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var filterTpm12 = new SessionSearchFilter + { + DeviceProperties = new Dictionary { ["tpm_status.specVersion"] = "1.2" }, + }; + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:tenant", TenantA, null, filterTpm12, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_scope_changes() + { + var filter = SimpleFilter(); + var fpTenant = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filter); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant); + + var ok = SearchSessionsPagination.TryAcceptContinuation( + encoded, "search:global", TenantA, null, filter, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys() + { + var query = new NameValueCollection + { + { "status", "Failed" }, + { "manufacturer", "Microsoft" }, + { "limit", "100" }, // legacy — must be dropped + { "pageSize", "200" }, // owned by new contract — overwritten + { "continuation", "STALE" }, // owned by new contract — overwritten + { "prop.tpm_status.specVersion", "2.0" }, + }; + + var link = SearchSessionsPagination.BuildNextLink( + "/api/search/sessions", 50, "FRESH+/=", query); + + Assert.StartsWith("/api/search/sessions?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=FRESH%2B%2F%3D", link); + Assert.Contains("status=Failed", link); + Assert.Contains("manufacturer=Microsoft", link); + Assert.Contains("prop.tpm_status.specVersion=2.0", link); + Assert.DoesNotContain("limit=", link); + Assert.DoesNotContain("STALE", link); + } + + [Fact] + public void Fingerprint_is_stable_regardless_of_deviceProperties_iteration_order() + { + var f1 = new SessionSearchFilter + { + DeviceProperties = new Dictionary + { + ["a"] = "1", + ["b"] = "2", + }, + }; + var f2 = new SessionSearchFilter + { + DeviceProperties = new Dictionary + { + ["b"] = "2", + ["a"] = "1", + }, + }; + Assert.Equal( + SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f1), + SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f2)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs new file mode 100644 index 00000000..1244c49e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs @@ -0,0 +1,236 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 2 (mcp-pagination-rollout) — pagination wiring on +/// GET /api/sessions/{sessionId}/events. +/// +public class SessionEventsPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string SessionA = "11111111-2222-3333-4444-555555555555"; + private const string SessionB = "99999999-8888-7777-6666-555555555555"; + + // ---------- (a) no pageSize → unpaginated path; query parser surfaces null ---------- + + [Fact] + public void ParseQuery_without_pageSize_keeps_unpaginated_legacy_path() + { + var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection()); + + Assert.Null(parsed.PageSize); + Assert.Null(parsed.Continuation); + Assert.Null(parsed.Error); + } + + [Fact] + public void ParseQuery_drops_continuation_when_pageSize_absent() + { + // continuation alone is meaningless — silently dropped so a stale token in + // a bookmark does not force callers into an error path. + var qs = new NameValueCollection { { "continuation", "abc" } }; + var parsed = SessionEventsPagination.ParseQuery(qs); + + Assert.Null(parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + // ---------- (b) with pageSize → paginated path; helper surfaces values ---------- + + [Fact] + public void ParseQuery_with_pageSize_activates_paginated_path() + { + var qs = new NameValueCollection + { + { "pageSize", "100" }, + { "continuation", "ENCODED" }, + }; + var parsed = SessionEventsPagination.ParseQuery(qs); + + Assert.Equal(100, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + Assert.Null(parsed.Error); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("1001")] + public void ParseQuery_rejects_pageSize_outside_range(string raw) + { + var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection { { "pageSize", raw } }); + Assert.NotNull(parsed.Error); + } + + [Fact] + public void ParseQuery_rejects_non_integer_pageSize() + { + var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection { { "pageSize", "banana" } }); + Assert.NotNull(parsed.Error); + } + + [Fact] + public void BuildNextLink_includes_pageSize_continuation_and_optional_tenantId() + { + var link = SessionEventsPagination.BuildNextLink(SessionA, 200, "TOKEN+/=", TenantA); + + Assert.StartsWith($"/api/sessions/{SessionA}/events?", link); + Assert.Contains("pageSize=200", link); + Assert.Contains("continuation=TOKEN%2B%2F%3D", link); // url-escaped + Assert.Contains($"tenantId={TenantA}", link); + } + + [Fact] + public void BuildNextLink_omits_tenantId_when_caller_does_not_pass_it() + { + var link = SessionEventsPagination.BuildNextLink(SessionA, 200, "abc", null); + + Assert.DoesNotContain("tenantId=", link); + } + + // ---------- (e) GA cross-tenant nextLink → token round-trip ---------- + + [Fact] + public void NextLink_carries_resolved_tenantId_for_GA_cross_tenant_round_trip() + { + // Regression: previously the function called BuildNextLink with the raw + // ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events + // without explicitly passing tenantId. Page 1 then bound the token to the + // resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted + // it — so page 2 validated against the GA's JWT tenant and the token was + // rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId + // verbatim, and the function honours ?tenantId= on follow-up pages for GA. + const string ResolvedTenant = "c3d4e5f6-a7b8-9012-cdef-abcdef123456"; + + var fp = SessionEventsPagination.Fingerprint(ResolvedTenant, SessionA); + var encoded = ContinuationToken.Encode("rawAzureToken", ResolvedTenant, fp); + var link = SessionEventsPagination.BuildNextLink(SessionA, 200, encoded, ResolvedTenant); + + // Wire-form: tenantId is in the URL so the next-call extractor sees it. + Assert.Contains($"tenantId={ResolvedTenant}", link); + + // And the token validates against that same tenant — no cross_tenant reject. + var ok = SessionEventsPagination.TryAcceptContinuation( + encoded, ResolvedTenant, SessionA, out var azure, out var reason); + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzureToken", azure); + } + + // ---------- (d) cross-session continuation rejection ---------- + + [Fact] + public void TryAcceptContinuation_rejects_token_issued_for_a_different_session() + { + var fpA = SessionEventsPagination.Fingerprint(TenantA, SessionA); + var encodedForA = ContinuationToken.Encode("rawAzureToken", TenantA, fpA); + + var ok = SessionEventsPagination.TryAcceptContinuation( + encodedForA, TenantA, SessionB, out var azure, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + Assert.Equal(string.Empty, azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_issued_for_a_different_tenant() + { + var fpA = SessionEventsPagination.Fingerprint(TenantA, SessionA); + var encodedForA = ContinuationToken.Encode("rawAzureToken", TenantA, fpA); + + const string TenantB = "00000000-0000-0000-0000-000000000099"; + var ok = SessionEventsPagination.TryAcceptContinuation( + encodedForA, TenantB, SessionA, out var azure, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + Assert.Equal(string.Empty, azure); + } + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_session_and_tenant() + { + var fp = SessionEventsPagination.Fingerprint(TenantA, SessionA); + var encoded = ContinuationToken.Encode("rawAzureToken", TenantA, fp); + + var ok = SessionEventsPagination.TryAcceptContinuation( + encoded, TenantA, SessionA, out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzureToken", azure); + } + + // ---------- (c) consume-until-absent → repo total reachable across pages ---------- + + [Fact] + public async Task ConsumeUntilAbsent_drains_all_events_from_paged_repository() + { + // Simulates a 5-page session (200 events/page) plus a final partial page — + // the same loop the UI/AI follow until the response no longer carries a + // nextRawToken. Verifies the contract: full data is reachable across N + // calls without truncation. + const int totalEvents = 1083; + const int pageSize = 200; + var pages = SplitIntoPages(totalEvents, pageSize); + + var repo = new Mock(MockBehavior.Strict); + + var callIndex = 0; + repo + .Setup(r => r.GetSessionEventsPageAsync(TenantA, SessionA, pageSize, It.IsAny())) + .ReturnsAsync(() => pages[callIndex++]); + + var collected = new List(); + string? continuation = null; + var loopGuard = 0; + while (true) + { + var page = await repo.Object.GetSessionEventsPageAsync(TenantA, SessionA, pageSize, continuation); + collected.AddRange(page.Items); + if (string.IsNullOrEmpty(page.NextRawToken)) break; + continuation = page.NextRawToken; + if (++loopGuard > 50) Assert.Fail("Pagination loop did not terminate within 50 iterations"); + } + + Assert.Equal(totalEvents, collected.Count); + // Sequence must be unique and complete (1..N) — no events lost or duplicated. + var sequences = new HashSet(System.Linq.Enumerable.Select(collected, e => e.Sequence)); + Assert.Equal(totalEvents, sequences.Count); + Assert.Equal(1L, System.Linq.Enumerable.Min(sequences)); + Assert.Equal((long)totalEvents, System.Linq.Enumerable.Max(sequences)); + } + + private static List> SplitIntoPages(int total, int pageSize) + { + var result = new List>(); + long seq = 1; + var emitted = 0; + while (emitted < total) + { + var take = System.Math.Min(pageSize, total - emitted); + var batch = new List(take); + for (int i = 0; i < take; i++) + { + batch.Add(new EnrollmentEvent + { + SessionId = SessionA, + TenantId = TenantA, + Sequence = seq++, + EventType = "synthetic", + }); + } + emitted += take; + var hasMore = emitted < total; + result.Add(new RawPage(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null)); + } + return result; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs new file mode 100644 index 00000000..9573d34e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs @@ -0,0 +1,266 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; +using Moq; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 5 (mcp-pagination-rollout) — pagination wiring on +/// GET /api/sessions + GET /api/global/sessions. Replaces the +/// legacy ?limit= + ?cursor= wire shape with the rollout plan's +/// ?pageSize= + ?continuation= + nextLink contract. +/// +public class SessionListPaginationTests +{ + private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + + // ────────── ParseQuery ────────────────────────────────────────────────── + + [Fact] + public void ParseQuery_with_no_params_returns_default_pageSize() + { + var parsed = SessionListPagination.ParseQuery(new NameValueCollection(), acceptFilterTenantId: false); + + Assert.Null(parsed.Error); + Assert.Equal(SessionListPagination.DefaultPageSize, parsed.PageSize); + Assert.Null(parsed.Continuation); + Assert.Null(parsed.Days); + Assert.Null(parsed.FilterTenantId); + } + + [Fact] + public void ParseQuery_honours_pageSize_and_continuation() + { + var parsed = SessionListPagination.ParseQuery(new NameValueCollection + { + { "pageSize", "10" }, + { "continuation", "ENCODED" }, + }, acceptFilterTenantId: false); + + Assert.Equal(10, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("1001")] + [InlineData("banana")] + public void ParseQuery_rejects_invalid_pageSize(string raw) + { + var parsed = SessionListPagination.ParseQuery( + new NameValueCollection { { "pageSize", raw } }, + acceptFilterTenantId: false); + Assert.NotNull(parsed.Error); + } + + [Fact] + public void ParseQuery_carries_days_filter() + { + var parsed = SessionListPagination.ParseQuery( + new NameValueCollection { { "days", "30" } }, + acceptFilterTenantId: false); + Assert.Equal(30, parsed.Days); + } + + [Fact] + public void ParseQuery_rejects_invalid_days() + { + var parsed = SessionListPagination.ParseQuery( + new NameValueCollection { { "days", "0" } }, + acceptFilterTenantId: false); + Assert.NotNull(parsed.Error); + } + + [Fact] + public void ParseQuery_ignores_filter_tenantId_when_not_accepted() + { + // The tenant-scoped endpoint must never honour ?tenantId= in the query + // — that's an authorization vector. acceptFilterTenantId=false skips it. + var parsed = SessionListPagination.ParseQuery( + new NameValueCollection { { "tenantId", TenantB } }, + acceptFilterTenantId: false); + Assert.Null(parsed.FilterTenantId); + } + + [Fact] + public void ParseQuery_carries_filter_tenantId_when_accepted() + { + var parsed = SessionListPagination.ParseQuery( + new NameValueCollection { { "tenantId", TenantB } }, + acceptFilterTenantId: true); + Assert.Equal(TenantB, parsed.FilterTenantId); + } + + // ────────── Token round-trip + cross-binding rejections ───────────────── + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_caller() + { + var fp = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: 30); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SessionListPagination.TryAcceptContinuation( + encoded, "sessions:tenant", TenantA, days: 30, filterTenantId: null, + out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_for_a_different_caller() + { + var fp = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp); + + var ok = SessionListPagination.TryAcceptContinuation( + encoded, "sessions:tenant", TenantB, days: null, filterTenantId: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_days_changes() + { + var fp30 = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: 30); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp30); + + var ok = SessionListPagination.TryAcceptContinuation( + encoded, "sessions:tenant", TenantA, days: 90, filterTenantId: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_scope_changes() + { + // Token from /api/sessions (tenant scope) replayed into /api/global/sessions + // (global scope) — must reject. + var fpTenant = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: null); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant); + + var ok = SessionListPagination.TryAcceptContinuation( + encoded, "sessions:global", TenantA, days: null, filterTenantId: null, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_filterTenantId_changes_on_global() + { + // Global endpoint with ?tenantId=A then ?tenantId=B → token from page 1 + // must not seek into a different tenant's data on page 2. + var fpA = SessionListPagination.Fingerprint("sessions:global", TenantA, days: null, filterTenantId: TenantA); + var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA); + + var ok = SessionListPagination.TryAcceptContinuation( + encoded, "sessions:global", TenantA, days: null, filterTenantId: TenantB, + out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_includes_pageSize_continuation_days_filterTenantId() + { + var link = SessionListPagination.BuildNextLink( + "/api/global/sessions", 10, "TOKEN+/=", days: 30, filterTenantId: TenantA); + + Assert.StartsWith("/api/global/sessions?", link); + Assert.Contains("pageSize=10", link); + Assert.Contains("continuation=TOKEN%2B%2F%3D", link); + Assert.Contains("days=30", link); + Assert.Contains($"tenantId={TenantA}", link); + } + + [Fact] + public void BuildNextLink_omits_optional_params_when_unset() + { + var link = SessionListPagination.BuildNextLink( + "/api/sessions", 50, "abc", days: null, filterTenantId: null); + + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=abc", link); + Assert.DoesNotContain("days=", link); + Assert.DoesNotContain("tenantId=", link); + } + + // ────────── Repo-contract: drain MUST keep paginating with days set ───── + + [Fact] + public async Task ConsumeUntilAbsent_drains_all_sessions_with_days_via_continuation_loop() + { + // Regression guard for the storage-layer short-circuit that previously + // returned NextRawToken=null whenever `days` was set, capping callers at + // ~10k sessions silently. After the fix, the days cutoff is applied as a + // RowKey filter and pagination mechanics are independent of the window — + // so a tenant with thousands of in-window sessions remains fully reachable + // across multiple pages, exactly like the no-days case. + const int totalSessions = 1500; + const int pageSize = 200; + const int days = 30; + var pages = SplitSessionPages(totalSessions, pageSize); + + var repo = new Mock(MockBehavior.Strict); + var callIndex = 0; + repo + .Setup(r => r.GetSessionsPageAsync(TenantA, days, pageSize, It.IsAny())) + .ReturnsAsync(() => pages[callIndex++]); + + var collected = new List(); + string? continuation = null; + var loopGuard = 0; + while (true) + { + var page = await repo.Object.GetSessionsPageAsync(TenantA, days, pageSize, continuation); + collected.AddRange(page.Items); + if (string.IsNullOrEmpty(page.NextRawToken)) break; + continuation = page.NextRawToken; + if (++loopGuard > 50) Assert.Fail("Pagination loop did not terminate within 50 iterations"); + } + + Assert.Equal(totalSessions, collected.Count); + // SessionIds must be unique — no session emitted twice across pages. + var ids = new HashSet(System.Linq.Enumerable.Select(collected, s => s.SessionId)); + Assert.Equal(totalSessions, ids.Count); + } + + private static List> SplitSessionPages(int total, int pageSize) + { + var result = new List>(); + var emitted = 0; + while (emitted < total) + { + var take = System.Math.Min(pageSize, total - emitted); + var batch = new List(take); + for (int i = 0; i < take; i++) + { + batch.Add(new SessionSummary + { + TenantId = TenantA, + SessionId = $"sess-{emitted + i:D6}", + StartedAt = System.DateTime.UtcNow.AddMinutes(-(emitted + i)), + }); + } + emitted += take; + var hasMore = emitted < total; + result.Add(new RawPage(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null)); + } + return result; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs new file mode 100644 index 00000000..7092d619 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs @@ -0,0 +1,185 @@ +using System.Collections.Specialized; +using AutopilotMonitor.Functions.Pagination; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// PR 4 (mcp-pagination-rollout) — pagination wiring on +/// GET /api/global/session-reports. The endpoint stays GA-only at the +/// policy layer; tenantId here is a server-side filter, not an +/// authorization scope. The continuation token still binds to the caller's +/// identity + the active filter so a token issued for one filter view can't be +/// silently retargeted to another tenant's reports. +/// +public class SessionReportsPaginationTests +{ + private const string CallerTenant = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + private const string CallerTenantOther = "b2c3d4e5-f6a7-8901-bcde-f12345678901"; + private const string FilterTenantA = "00000000-1111-2222-3333-444444444444"; + private const string FilterTenantB = "55555555-6666-7777-8888-999999999999"; + + // ────────── ParseQuery ────────────────────────────────────────────────── + + [Fact] + public void ParseQuery_with_no_params_returns_unfiltered_unpaginated() + { + var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection()); + + Assert.Null(parsed.Error); + Assert.Null(parsed.FilterTenantId); + Assert.Null(parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Fact] + public void ParseQuery_keeps_tenantId_filter_independent_of_pagination() + { + var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection + { + { "tenantId", FilterTenantA }, + }); + + Assert.Null(parsed.Error); + Assert.Equal(FilterTenantA, parsed.FilterTenantId); + Assert.Null(parsed.PageSize); + } + + [Fact] + public void ParseQuery_with_pageSize_activates_pagination() + { + var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection + { + { "pageSize", "50" }, + { "continuation", "ENCODED" }, + { "tenantId", FilterTenantA }, + }); + + Assert.Equal(50, parsed.PageSize); + Assert.Equal("ENCODED", parsed.Continuation); + Assert.Equal(FilterTenantA, parsed.FilterTenantId); + } + + [Fact] + public void ParseQuery_drops_continuation_when_pageSize_absent() + { + var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection + { + { "continuation", "STALE" }, + }); + + Assert.Null(parsed.PageSize); + Assert.Null(parsed.Continuation); + } + + [Theory] + [InlineData("0")] + [InlineData("-5")] + [InlineData("1001")] + [InlineData("banana")] + public void ParseQuery_rejects_invalid_pageSize(string raw) + { + var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection { { "pageSize", raw } }); + Assert.NotNull(parsed.Error); + } + + // ────────── Token round-trip + cross-binding rejections ───────────────── + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_caller_and_filter() + { + var fp = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA); + var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp); + + var ok = SessionReportsPagination.TryAcceptContinuation( + encoded, CallerTenant, FilterTenantA, out var azure, out var reason); + + Assert.True(ok); + Assert.Null(reason); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_round_trips_for_matching_caller_when_filter_absent() + { + // GA list-all-tenants view — filterTenantId null both times → fingerprint matches. + var fp = SessionReportsPagination.Fingerprint(CallerTenant, filterTenantId: null); + var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp); + + var ok = SessionReportsPagination.TryAcceptContinuation( + encoded, CallerTenant, filterTenantId: null, out var azure, out _); + + Assert.True(ok); + Assert.Equal("rawAzure", azure); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_replayed_by_a_different_caller() + { + // Anti-replay: a token issued for caller A can't be reused by caller B, + // even though both could query session-reports — protects deep paginated + // bookmark links from being replayed by a third party. + var fp = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA); + var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp); + + var ok = SessionReportsPagination.TryAcceptContinuation( + encoded, CallerTenantOther, FilterTenantA, out _, out var reason); + + Assert.False(ok); + Assert.Equal("cross_tenant", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_filter_changes_between_calls() + { + // Caller paginates with tenantId=A then rewrites to tenantId=B — token + // must be rejected so the cursor can't bleed across filters. + var fpA = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA); + var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fpA); + + var ok = SessionReportsPagination.TryAcceptContinuation( + encoded, CallerTenant, FilterTenantB, out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + [Fact] + public void TryAcceptContinuation_rejects_token_when_filter_drops_to_null() + { + // Caller had tenantId=A applied then cleared the filter — different + // filter scope, token must restart. + var fpA = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA); + var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fpA); + + var ok = SessionReportsPagination.TryAcceptContinuation( + encoded, CallerTenant, filterTenantId: null, out _, out var reason); + + Assert.False(ok); + Assert.Equal("filter_mismatch", reason); + } + + // ────────── BuildNextLink ─────────────────────────────────────────────── + + [Fact] + public void BuildNextLink_includes_pageSize_continuation_and_tenantId_when_filtering() + { + var link = SessionReportsPagination.BuildNextLink(50, "TOKEN+/=", FilterTenantA); + + Assert.StartsWith("/api/global/session-reports?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=TOKEN%2B%2F%3D", link); + Assert.Contains($"tenantId={FilterTenantA}", link); + } + + [Fact] + public void BuildNextLink_omits_tenantId_when_no_filter_active() + { + var link = SessionReportsPagination.BuildNextLink(50, "abc", filterTenantId: null); + + Assert.StartsWith("/api/global/session-reports?", link); + Assert.Contains("pageSize=50", link); + Assert.Contains("continuation=abc", link); + Assert.DoesNotContain("tenantId=", link); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs new file mode 100644 index 00000000..b0682c56 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs @@ -0,0 +1,24 @@ +using System.Runtime.CompilerServices; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Test-process-wide setup. Runs once before any test executes (via the +/// [ModuleInitializer] hook) so individual tests don't have to bother +/// with global state — in particular, the ContinuationToken signing key. +/// +internal static class TestSetup +{ + [ModuleInitializer] + public static void Initialize() + { + // Fixed deterministic key for reproducible test runs. Any 32 bytes will + // do — production reads from the PaginationTokenSigningKey env var which + // we deliberately do NOT inspect here, so test runs aren't affected by + // (or able to leak into) production secrets. + var key = new byte[32]; + for (int i = 0; i < key.Length; i++) key[i] = (byte)(i * 7 + 13); + ContinuationToken.SetSigningKeyForTesting(key); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs new file mode 100644 index 00000000..e633cb7f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Generic helpers driving cross-partition merge-pagination for global views + /// where Azure-Tables' native (PK asc, RK asc) ordering would otherwise + /// surface partitions one-at-a-time instead of globally newest-first. + /// Used by ops-events (categories as partitions) and global audit logs + /// (tenants as partitions). + /// + /// + /// + /// Per-partition continuation anchored on RowKey (not Azure's opaque token): + /// LastRowKey = the oldest RowKey we have already returned for this + /// partition. Next fetch filters RowKey gt LastRowKey — natural + /// ordering within the partition (revtick → newest-first) means this skips + /// exactly what we showed and re-considers everything older. + /// + /// + /// Why not Azure's continuation token? Cross-partition merge can drop items + /// that were fetched but didn't survive the top-pageSize cut. If we advanced + /// an Azure cursor past those leftovers, we'd lose them forever. With a + /// RowKey bound we simply don't advance the continuation for partitions whose + /// fetched items lost the merge — they get re-fetched next call and compete + /// again at a lower floor. + /// + /// + internal static class PerPartitionFanOutMerge + { + internal sealed record PartitionContinuation(string? LastRowKey, bool Exhausted); + + internal sealed record PartitionFetchResult( + string Partition, + IReadOnlyList<(string RowKey, T Item)> Items); + + /// + /// Pure merge-and-advance step. Given pre-fetched per-partition items + /// (already newest-first within each list) and the previous per-partition + /// continuation map, returns the top-pageSize merged items plus the next + /// continuation map. + /// + public static (List Items, Dictionary NextContinuations) + MergeAndAdvance( + IReadOnlyList> fetched, + IReadOnlyDictionary priorContinuations, + int pageSize, + Func timestampSelector) + { + var allFetched = fetched + .SelectMany(r => r.Items.Select(t => (part: r.Partition, rk: t.RowKey, item: t.Item))) + .OrderByDescending(t => timestampSelector(t.item)) + .ToList(); + + var merged = allFetched.Take(pageSize).ToList(); + var returnedItems = merged.Select(t => t.item).ToList(); + + var newContinuations = new Dictionary(StringComparer.Ordinal); + foreach (var r in fetched) + { + var returnedFromPart = merged.Where(m => m.part == r.Partition).ToList(); + if (returnedFromPart.Count > 0) + { + // Advance to the oldest RowKey we returned for this partition. + // Revtick scheme: larger RowKey string = older timestamp, so + // OrdinalCompare-max gives us the oldest of the returned set. + var oldestRk = returnedFromPart + .Select(m => m.rk) + .OrderBy(rk => rk, StringComparer.Ordinal) + .Last(); + newContinuations[r.Partition] = new PartitionContinuation(oldestRk, Exhausted: false); + } + else if (r.Items.Count == 0) + { + // No more items past current continuation → partition exhausted. + priorContinuations.TryGetValue(r.Partition, out var prior); + newContinuations[r.Partition] = new PartitionContinuation(prior?.LastRowKey, Exhausted: true); + } + else + { + // Fetched some but they all lost the merge cut to other partitions. + // Keep continuation where it was — they'll be re-fetched and likely + // win on the next page when the floor drops. Wasted bandwidth, but + // correct (no item silently dropped). + priorContinuations.TryGetValue(r.Partition, out var prior); + newContinuations[r.Partition] = prior ?? new PartitionContinuation(null, Exhausted: false); + } + } + + // Carry over already-exhausted entries from the prior call so they + // stay marked across pages (we only fetch from active partitions on + // this round and don't see exhausted ones in `fetched`). + foreach (var kv in priorContinuations) + { + if (kv.Value.Exhausted && !newContinuations.ContainsKey(kv.Key)) + newContinuations[kv.Key] = kv.Value; + } + + return (returnedItems, newContinuations); + } + + public static string EncodeMultiContinuation(IReadOnlyDictionary continuations) + { + var doc = new Dictionary + { + ["v"] = 1, + ["c"] = continuations.ToDictionary( + kv => kv.Key, + kv => (object?)new Dictionary + { + ["rk"] = kv.Value.LastRowKey, + ["x"] = kv.Value.Exhausted, + }), + }; + var json = JsonSerializer.Serialize(doc); + return Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + } + + public static Dictionary DecodeMultiContinuation(string? raw) + { + var result = new Dictionary(StringComparer.Ordinal); + if (string.IsNullOrEmpty(raw)) return result; + + try + { + var bytes = Convert.FromBase64String(raw); + using var doc = JsonDocument.Parse(bytes); + if (!doc.RootElement.TryGetProperty("c", out var entries)) return result; + foreach (var prop in entries.EnumerateObject()) + { + string? rk = prop.Value.TryGetProperty("rk", out var rkEl) && rkEl.ValueKind == JsonValueKind.String + ? rkEl.GetString() + : null; + bool exhausted = prop.Value.TryGetProperty("x", out var xEl) && xEl.ValueKind == JsonValueKind.True; + result[prop.Name] = new PartitionContinuation(rk, exhausted); + } + } + catch + { + // Malformed continuation → treat as empty (start over). The wire- + // layer fingerprint already rejects tampered tokens before reaching here. + } + return result; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs index 433bf2dc..a3c3bd75 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -1,6 +1,7 @@ using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using AutopilotMonitor.Functions.Services; using Azure.Data.Tables; using Microsoft.Extensions.Logging; @@ -26,11 +27,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage string entityId, string performedBy, Dictionary? details = null) => _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details); - public Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) - => _storage.GetAuditLogsAsync(tenantId, maxResults); + public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) + => _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo); - public Task> GetAllAuditLogsAsync(int maxResults = 100) - => _storage.GetAllAuditLogsAsync(maxResults); + public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) + => _storage.GetAllAuditLogsAsync(dateFrom, dateTo); + + public Task> GetAuditLogsPageAsync( + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation); + + public Task> GetAllAuditLogsPageAsync( + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation); public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) => _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs index 42156953..6423d621 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs @@ -1,7 +1,9 @@ +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Azure; using Azure.Data.Tables; using Microsoft.Extensions.Logging; @@ -178,25 +180,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage } } - public async Task> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50) + public async Task> GetSessionReportsAsync(string? tenantId = null) { var results = new List(); - + var filter = BuildSessionReportFilter(tenantId); try { - var count = 0; - await foreach (var entity in _reportsTableClient.QueryAsync( - filter: $"PartitionKey eq 'reports'")) + await foreach (var entity in _reportsTableClient.QueryAsync(filter: filter)) { - var report = MapToSessionReportMetadata(entity); - - // Filter by tenantId if specified - if (tenantId != null && report.TenantId != tenantId) - continue; - - results.Add(report); - count++; - if (count >= maxResults) break; + results.Add(MapToSessionReportMetadata(entity)); } } catch (RequestFailedException ex) when (ex.Status == 404) @@ -207,6 +199,48 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return results; } + public async Task> GetSessionReportsPageAsync( + string? tenantId, int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + try + { + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: _reportsTableClient, + filter: BuildSessionReportFilter(tenantId), + pageSize: pageSize, + continuation: continuation); + + var page = new List(entities.Count); + foreach (var entity in entities) page.Add(MapToSessionReportMetadata(entity)); + // RowKey is inverted-tick of SubmittedAt — entities arrive newest-first + // already; no client-side resort needed. + return new RawPage(page, nextRawToken); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug("SessionReports table does not exist yet, returning empty page"); + return RawPage.Empty; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get session reports page"); + return RawPage.Empty; + } + } + + private static string BuildSessionReportFilter(string? tenantId) + { + // All reports live in the single "reports" partition; tenantId is a + // property the SDK can filter server-side. + var filter = "PartitionKey eq 'reports'"; + if (!string.IsNullOrEmpty(tenantId)) + { + filter += $" and TenantId eq '{tenantId!.Replace("'", "''")}'"; + } + return filter; + } + public async Task GetSessionReportAsync(string reportId) { try diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs index 94c03691..a63116a7 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.DataAccess.TableStorage @@ -46,38 +49,148 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage await _table.UpsertEntityAsync(entity); } - public async Task> GetOpsEventsAsync(int maxResults = 200) + public async Task> GetOpsEventsAsync( + string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null) { var result = new List(); + var filter = BuildFilter(category, dateFrom, dateTo); + var query = string.IsNullOrEmpty(filter) + ? _table.QueryAsync() + : _table.QueryAsync(filter: filter); - // Query all partitions, sort by Timestamp descending client-side - // (Azure Table Storage doesn't support cross-partition ordering) - await foreach (var entity in _table.QueryAsync(maxPerPage: maxResults)) + await foreach (var entity in query) { result.Add(MapToEntry(entity)); - if (result.Count >= maxResults) - break; } - - // Sort newest-first across categories + // Cross-partition order is undefined; sort newest-first to match prior behaviour. result.Sort((a, b) => b.Timestamp.CompareTo(a.Timestamp)); return result; } - public async Task> GetOpsEventsByCategoryAsync(string category, int maxResults = 100) + public async Task> GetOpsEventsPageAsync( + string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) { - var result = new List(); - - await foreach (var entity in _table.QueryAsync(e => e.PartitionKey == category, maxPerPage: maxResults)) + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + try { - result.Add(MapToEntry(entity)); - if (result.Count >= maxResults) - break; - } + // Single-category path: PK-targeted query, RowKey is reverse-tick → + // Azure-native (PK asc, RK asc) already yields newest-first. No re-sort. + if (!string.IsNullOrEmpty(category)) + { + var filter = BuildFilter(category, dateFrom, dateTo); + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: _table, + filter: filter, + pageSize: pageSize, + continuation: continuation); - return result; + var page = new List(entities.Count); + foreach (var entity in entities) page.Add(MapToEntry(entity)); + return new RawPage(page, nextRawToken); + } + + // All-category path: per-partition fan-out + merge-sort. Azure pages + // cross-partition queries by (PK asc, RK asc), so without this fan-out + // the first page would come entirely from the alphabetically-first + // category — defeating "newest first globally". Mirrors the same + // pattern used for cross-tenant session queries. + return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get ops events page"); + return RawPage.Empty; + } } + // Fixed list — keep in sync with OpsEventCategory. New categories are rare + // and require a deploy anyway, so a runtime probe of the table partitions + // would just trade certainty for cost. + internal static readonly string[] AllCategories = new[] + { + OpsEventCategory.Consent, + OpsEventCategory.Maintenance, + OpsEventCategory.Security, + OpsEventCategory.Tenant, + OpsEventCategory.Agent, + OpsEventCategory.Sla, + }; + + private async Task> FanOutAcrossCategoriesAsync( + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + { + var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation); + + var activeCats = AllCategories + .Where(cat => !(continuations.TryGetValue(cat, out var c) && c.Exhausted)) + .ToList(); + if (activeCats.Count == 0) + return new RawPage(new List(), null); + + var fetchTasks = activeCats.Select(async cat => + { + continuations.TryGetValue(cat, out var catContinuation); + var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey); + + var fetched = new List<(string RowKey, OpsEventEntry Item)>(); + await foreach (var e in _table.QueryAsync(filter: filter, maxPerPage: pageSize)) + { + fetched.Add((e.RowKey, MapToEntry(e))); + if (fetched.Count >= pageSize) break; + } + return new PerPartitionFanOutMerge.PartitionFetchResult(cat, fetched); + }).ToList(); + + var results = await Task.WhenAll(fetchTasks); + + var (items, nextContinuations) = PerPartitionFanOutMerge.MergeAndAdvance( + results, continuations, pageSize, e => e.Timestamp); + + bool anyActive = nextContinuations.Any(c => !c.Value.Exhausted); + string? nextRawToken = anyActive + ? PerPartitionFanOutMerge.EncodeMultiContinuation(nextContinuations) + : null; + return new RawPage(items, nextRawToken); + } + + private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey) + { + var clauses = new List + { + $"PartitionKey eq '{category.Replace("'", "''")}'", + }; + if (!string.IsNullOrEmpty(lastRowKey)) + clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'"); + if (dateFrom.HasValue) + clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'"); + if (dateTo.HasValue) + clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'"); + return string.Join(" and ", clauses); + } + + // Filters on the system-managed Timestamp; user-defined "Timestamp" property + // mirrors it on insert via SaveOpsEventAsync. + private static string? BuildFilter(string? category, DateTime? dateFrom, DateTime? dateTo) + { + var clauses = new List(); + if (!string.IsNullOrEmpty(category)) + { + clauses.Add($"PartitionKey eq '{category!.Replace("'", "''")}'"); + } + if (dateFrom.HasValue) + { + clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'"); + } + if (dateTo.HasValue) + { + clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'"); + } + return clauses.Count == 0 ? null : string.Join(" and ", clauses); + } + + private static DateTime ToUtc(DateTime dt) + => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); + public async Task DeleteOpsEventsOlderThanAsync(DateTime cutoff) { var deleted = 0; diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs index 27cbc8e3..e9d81da3 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -1,5 +1,6 @@ using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using AutopilotMonitor.Functions.Services; namespace AutopilotMonitor.Functions.DataAccess.TableStorage @@ -33,11 +34,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task FindSessionTenantIdAsync(string sessionId) => _storage.FindSessionTenantIdAsync(sessionId); - public Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null) - => _storage.GetSessionsAsync(tenantId, maxResults, cursor, days); + public Task> GetSessionsAsync(string tenantId, int? days = null) + => _storage.GetSessionsAsync(tenantId, days); - public Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null) - => _storage.GetAllSessionsAsync(maxResults, cursor, days); + public Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null) + => _storage.GetAllSessionsAsync(tenantIdFilter, days); + + public Task> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation) + => _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation); + + public Task> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation) + => _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation); public Task DeleteSessionAsync(string tenantId, string sessionId) => _storage.DeleteSessionAsync(tenantId, sessionId); @@ -120,12 +127,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage public Task> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200) => _storage.GetSessionEventsByTypeAsync(tenantId, sessionId, eventType, maxResults); + public Task> GetSessionEventsPageAsync(string tenantId, string sessionId, int pageSize, string? continuation) + => _storage.GetSessionEventsPageAsync(tenantId, sessionId, pageSize, continuation); + public Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10) => _storage.QuickSearchSessionsAsync(tenantId, query, limit); public Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter) => _storage.SearchSessionsAsync(tenantId, filter); + public Task> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation) + => _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation); + + public Task> SearchSessionsByEventPageAsync( + string? tenantId, string eventType, string? source, string? severity, string? phase, + int pageSize, string? continuation) + => _storage.SearchSessionsByEventPageAsync(tenantId, eventType, source, severity, phase, pageSize, continuation); + public Task> SearchSessionsByEventAsync( string? tenantId, string eventType, string? source, string? severity, string? phase, int limit = 50) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index c441c4ca..b6baad8a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -30,20 +33,78 @@ namespace AutopilotMonitor.Functions.Functions.Admin string tenantId = TenantHelper.GetTenantId(req); string userIdentifier = TenantHelper.GetUserIdentifier(req); - _logger.LogInformation($"Fetching audit logs for tenant {tenantId} by user {userIdentifier}"); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var parsed = DateWindowPagination.ParseQuery(query); + if (parsed.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; + } - // Get audit logs from storage - var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, maxResults: 100); + _logger.LogInformation( + "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}) for user {User}", + tenantId, parsed.DateFrom, parsed.DateTo, + parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null, + userIdentifier); - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new + if (parsed.PageSize == null) + { + var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, parsed.DateFrom, parsed.DateTo); + return await req.OkAsync(new { success = true, count = logs.Count, logs }); + } + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!DateWindowPagination.TryAcceptContinuation( + parsed.Continuation, + scope: "audit:tenant", + callerTenantId: tenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo, + extras: null, + out azureToken, + out var rejectReason)) + { + _logger.LogWarning("GetAuditLogs: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _maintenanceRepo.GetAuditLogsPageAsync( + tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = DateWindowPagination.Fingerprint( + scope: "audit:tenant", + callerTenantId: tenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, tenantId, fp); + nextLink = DateWindowPagination.BuildNextLink( + basePath: "/api/audit/logs", + pageSize: parsed.PageSize.Value, + wireContinuation: wireToken, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo); + } + + return await req.OkAsync(new { success = true, - count = logs.Count, - logs = logs + count = page.Items.Count, + logs = page.Items, + nextLink, }); - - return response; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index 5eadef58..c72eb8c9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -30,20 +33,78 @@ namespace AutopilotMonitor.Functions.Functions.Admin { // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware var userEmail = TenantHelper.GetUserIdentifier(req); + var callerTenantId = TenantHelper.GetTenantId(req); - _logger.LogInformation($"Fetching all audit logs across all tenants (User: {userEmail})"); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var parsed = DateWindowPagination.ParseQuery(query); + if (parsed.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; + } - var logs = await _maintenanceRepo.GetAllAuditLogsAsync(maxResults: 100); + _logger.LogInformation( + "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}) for {User}", + parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", userEmail); - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new + if (parsed.PageSize == null) + { + var logs = await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo); + return await req.OkAsync(new { success = true, count = logs.Count, logs }); + } + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!DateWindowPagination.TryAcceptContinuation( + parsed.Continuation, + scope: "audit:global", + callerTenantId: callerTenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo, + extras: null, + out azureToken, + out var rejectReason)) + { + _logger.LogWarning("GetGlobalAuditLogs: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _maintenanceRepo.GetAllAuditLogsPageAsync( + parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = DateWindowPagination.Fingerprint( + scope: "audit:global", + callerTenantId: callerTenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = DateWindowPagination.BuildNextLink( + basePath: "/api/global/audit/logs", + pageSize: parsed.PageSize.Value, + wireContinuation: wireToken, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo); + } + + return await req.OkAsync(new { success = true, - count = logs.Count, - logs = logs + count = page.Items.Count, + logs = page.Items, + nextLink, }); - - return response; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs index f591738d..5bafe1d4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -1,6 +1,10 @@ +using System.Collections.Generic; using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -9,7 +13,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin { /// /// Global Admin endpoint: returns operational events from the OpsEvents table. - /// Supports optional category filter via query parameter. + /// Supports optional category + dateFrom/dateTo filters and opt-in pagination. /// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware. /// public class GetOpsEventsFunction @@ -31,31 +35,81 @@ namespace AutopilotMonitor.Functions.Functions.Admin { try { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var callerTenantId = TenantHelper.GetTenantId(req); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); var category = query["category"]; - var maxResultsStr = query["maxResults"]; - var maxResults = 200; - if (int.TryParse(maxResultsStr, out var parsed) && parsed > 0 && parsed <= 1000) - maxResults = parsed; - List events; - if (!string.IsNullOrWhiteSpace(category)) + var parsed = DateWindowPagination.ParseQuery(query); + if (parsed.Error != null) { - events = await _repository.GetOpsEventsByCategoryAsync(category, maxResults); - } - else - { - events = await _repository.GetOpsEventsAsync(maxResults); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; } - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new + var extras = string.IsNullOrEmpty(category) + ? null + : new[] { new KeyValuePair("category", category) }; + + if (parsed.PageSize == null) + { + var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo); + return await req.OkAsync(new { success = true, count = events.Count, events }); + } + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!DateWindowPagination.TryAcceptContinuation( + parsed.Continuation, + scope: "ops-events", + callerTenantId: callerTenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo, + extras: extras, + out azureToken, + out var rejectReason)) + { + _logger.LogWarning("GetOpsEvents: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _repository.GetOpsEventsPageAsync( + category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = DateWindowPagination.Fingerprint( + scope: "ops-events", + callerTenantId: callerTenantId, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo, + extras: extras); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = DateWindowPagination.BuildNextLink( + basePath: "/api/global/ops-events", + pageSize: parsed.PageSize.Value, + wireContinuation: wireToken, + dateFrom: parsed.DateFrom, + dateTo: parsed.DateTo, + extras: extras); + } + + return await req.OkAsync(new { success = true, - count = events.Count, - events + count = page.Items.Count, + events = page.Items, + nextLink, }); - return response; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 13ed4553..16da298d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -72,9 +72,9 @@ namespace AutopilotMonitor.Functions.Functions.Feedback (DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays) return await WriteJson(req, new { eligible = false }); - // 5. Sessions check — at least 1 session exists - var sessionPage = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 1); - if (sessionPage.Sessions.Count == 0) + // 5. Sessions check — at least 1 session exists. + var sessionPage = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null); + if (sessionPage.Items.Count == 0) return await WriteJson(req, new { eligible = false }); // 6. Cooldown check diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs index 0dc91043..931e0b4e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs @@ -43,14 +43,15 @@ public class ProgressPortalFunction _logger.LogInformation("ProgressGetSessions: Fetching sessions for tenant {TenantId}", tenantId); - var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100); + // Progress portal renders only the latest 100 sessions — single page is enough. + var page = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 100, continuation: null); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, - count = page.Sessions.Count, - sessions = page.Sessions + count = page.Items.Count, + sessions = page.Items }); return response; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs index 69060fbf..523c3f5a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs @@ -1,8 +1,11 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -30,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw try { var tenantId = TenantHelper.GetTenantId(req); - return await QueryEvents(req, tenantId); + return await QueryEvents(req, tenantId, scope: "raw-events:tenant", basePath: "/api/raw/events", filterTenantId: null); } catch (UnauthorizedAccessException) { @@ -54,8 +57,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw { try { - var tenantId = req.Query["tenantId"]; - return await QueryEvents(req, string.IsNullOrEmpty(tenantId) ? null : tenantId); + var filterTenantId = req.Query["tenantId"]; + var effectiveTenantId = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId; + return await QueryEvents(req, effectiveTenantId, scope: "raw-events:global", + basePath: "/api/global/raw/events", filterTenantId: effectiveTenantId); } catch (Exception ex) { @@ -63,19 +68,31 @@ namespace AutopilotMonitor.Functions.Functions.Raw } } - private async Task QueryEvents(HttpRequestData req, string? tenantId) + private async Task QueryEvents( + HttpRequestData req, string? tenantId, string scope, string basePath, string? filterTenantId) { - var sessionId = req.Query["sessionId"]; - var eventType = req.Query["eventType"]; - var severity = req.Query["severity"]; - var source = req.Query["source"]; - var startedAfter = req.Query["startedAfter"]; - var startedBefore = req.Query["startedBefore"]; - var limitStr = req.Query["limit"]; - var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 500) : 100; + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var sessionId = query["sessionId"]; + var eventType = query["eventType"]; + var severity = query["severity"]; + var source = query["source"]; + var startedAfter = query["startedAfter"]; + var startedBefore = query["startedBefore"]; - List events; + var pagination = QueryRawEventsPagination.ParsePagination(query); + if (pagination.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = pagination.Error }); + return bad; + } + var callerTenantId = TenantHelper.GetTenantId(req); + + // Single-session path — paginated session-events walk so sessions + // with more events than pageSize remain fully reachable across + // multiple calls. Continuation token binds caller + sessionId so + // a cursor from session A cannot be replayed for session B. if (!string.IsNullOrEmpty(sessionId)) { if (string.IsNullOrEmpty(tenantId)) @@ -84,63 +101,137 @@ namespace AutopilotMonitor.Functions.Functions.Raw await bad.WriteAsJsonAsync(new { error = "tenantId is required when querying by sessionId" }); return bad; } - // Single session query — use existing repo method - events = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId, limit); - } - else - { - // Cross-session: We need to search by event type via the EventTypeIndex - // then fetch events for matched sessions - if (string.IsNullOrEmpty(eventType)) + + string? singleAzureToken = null; + if (pagination.Continuation != null) { + if (!QueryRawEventsPagination.TryAcceptContinuation( + pagination.Continuation, scope, callerTenantId, filterTenantId, + sessionId, eventType, source, severity, startedAfter, startedBefore, + out singleAzureToken, out var rejectReason)) + { + _logger.LogWarning("QueryRawEvents: single-session continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var sessionPage = await _sessionRepo.GetSessionEventsPageAsync( + tenantId, sessionId, pagination.PageSize, singleAzureToken); + + var filtered = ApplyClientFilters( + sessionPage.Items.ToList(), eventType, severity, source, startedAfter, startedBefore); + filtered = filtered.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); + + string? singleNextLink = null; + if (!string.IsNullOrEmpty(sessionPage.NextRawToken)) + { + var fp = QueryRawEventsPagination.Fingerprint( + scope, callerTenantId, filterTenantId, + sessionId, eventType, source, severity, startedAfter, startedBefore); + var wireToken = ContinuationToken.Encode(sessionPage.NextRawToken!, callerTenantId, fp); + singleNextLink = QueryRawEventsPagination.BuildNextLink( + basePath, pagination.PageSize, wireToken, query); + } + + return await req.OkAsync(new + { + tenantId, + count = filtered.Count, + events = filtered, + nextLink = singleNextLink, + }); + } + + if (string.IsNullOrEmpty(eventType)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" }); + return bad; + } + + // Cross-session path — paginated EventTypeIndex walk replaces the + // legacy hard-coded limit:20 (recall-loss bug from the audit). + string? azureToken = null; + if (pagination.Continuation != null) + { + if (!QueryRawEventsPagination.TryAcceptContinuation( + pagination.Continuation, scope, callerTenantId, filterTenantId, + sessionId: null, eventType, source, severity, startedAfter, startedBefore, + out azureToken, out var rejectReason)) + { + _logger.LogWarning("QueryRawEvents: continuation rejected ({Reason})", rejectReason); var bad = req.CreateResponse(HttpStatusCode.BadRequest); - await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" }); + await bad.WriteAsJsonAsync(new + { + error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); return bad; } - - // Use EventTypeIndex (pre-filtered by severity/source) to find sessions, - // then fetch only matching event types per session via server-side OData filter - var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, source, severity, null, limit: 20); - events = new List(); - foreach (var session in sessions) - { - var sessionEvents = await _sessionRepo.GetSessionEventsByTypeAsync(session.TenantId, session.SessionId, eventType, limit); - events.AddRange(sessionEvents.Where(e => - (string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) && - (string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)) - )); - - if (events.Count >= limit) break; - } - events = events.Take(limit).ToList(); } - // Apply client-side filters for single-session queries - if (!string.IsNullOrEmpty(sessionId)) + var sessionsPage = await _sessionRepo.SearchSessionsByEventPageAsync( + tenantId, eventType, source, severity, phase: null, + pageSize: pagination.PageSize, continuation: azureToken); + + var events = new List(); + foreach (var session in sessionsPage.Items) { - if (!string.IsNullOrEmpty(eventType)) - events = events.Where(e => e.EventType == eventType).ToList(); - if (!string.IsNullOrEmpty(severity)) - events = events.Where(e => e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList(); - if (!string.IsNullOrEmpty(source)) - events = events.Where(e => (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)).ToList(); + var sessionEvents = await _sessionRepo.GetSessionEventsByTypeAsync( + session.TenantId, session.SessionId, eventType, maxResults: 200); + events.AddRange(sessionEvents.Where(e => + (string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) && + (string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)))); } + events = ApplyDateFilters(events, startedAfter, startedBefore) + .OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); + + string? nextLink = null; + if (!string.IsNullOrEmpty(sessionsPage.NextRawToken)) + { + var fp = QueryRawEventsPagination.Fingerprint( + scope, callerTenantId, filterTenantId, + sessionId: null, eventType, source, severity, startedAfter, startedBefore); + var wireToken = ContinuationToken.Encode(sessionsPage.NextRawToken!, callerTenantId, fp); + nextLink = QueryRawEventsPagination.BuildNextLink( + basePath, pagination.PageSize, wireToken, query); + } + + return await req.OkAsync(new + { + tenantId, + count = events.Count, + events, + nextLink, + }); + } + + private static List ApplyClientFilters( + List events, string? eventType, string? severity, string? source, + string? startedAfter, string? startedBefore) + { + if (!string.IsNullOrEmpty(eventType)) + events = events.Where(e => e.EventType == eventType).ToList(); + if (!string.IsNullOrEmpty(severity)) + events = events.Where(e => e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList(); + if (!string.IsNullOrEmpty(source)) + events = events.Where(e => (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)).ToList(); + return ApplyDateFilters(events, startedAfter, startedBefore); + } + + private static List ApplyDateFilters( + List events, string? startedAfter, string? startedBefore) + { if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after)) events = events.Where(e => e.Timestamp >= after).ToList(); if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before)) events = events.Where(e => e.Timestamp <= before).ToList(); - - events = events.Take(limit).OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList(); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new - { - tenantId, - count = events.Count, - events - }); - return response; + return events; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs index 651726ba..b00e27ed 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs @@ -1,5 +1,9 @@ using System.Net; +using System.Web; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -28,16 +32,60 @@ namespace AutopilotMonitor.Functions.Functions.Reports try { // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware + var callerTenantId = TenantHelper.GetTenantId(req); - var reports = await _sessionReportService.GetAllReportsAsync(); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var parsed = SessionReportsPagination.ParseQuery(query); + if (parsed.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; + } - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new + if (parsed.PageSize == null) + { + var reports = await _sessionReportService.GetAllReportsAsync(parsed.FilterTenantId); + return await req.OkAsync(new { success = true, count = reports.Count, reports }); + } + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!SessionReportsPagination.TryAcceptContinuation( + parsed.Continuation, callerTenantId, parsed.FilterTenantId, + out azureToken, out var rejectReason)) + { + _logger.LogWarning("GetSessionReports: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _sessionReportService.GetReportsPageAsync( + parsed.FilterTenantId, parsed.PageSize.Value, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SessionReportsPagination.Fingerprint(callerTenantId, parsed.FilterTenantId); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = SessionReportsPagination.BuildNextLink( + parsed.PageSize.Value, wireToken, parsed.FilterTenantId); + } + + return await req.OkAsync(new { success = true, - reports + count = page.Items.Count, + reports = page.Items, + nextLink, }); - return response; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs index 3db7d3fa..a5e35302 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs @@ -1,7 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; -using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -31,46 +33,72 @@ namespace AutopilotMonitor.Functions.Functions.Sessions { // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware var userEmail = TenantHelper.GetUserIdentifier(req); - 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; + var callerTenantId = TenantHelper.GetTenantId(req); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); - var limitParam = query["limit"]; - int limit = 100; - if (!string.IsNullOrEmpty(limitParam) && int.TryParse(limitParam, out var parsedLimit)) - limit = Math.Clamp(parsedLimit, 1, 100); - - SessionPage page; - - if (!string.IsNullOrEmpty(tenantIdFilter)) + var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: true); + if (parsed.Error != null) { - if (!Guid.TryParse(tenantIdFilter, out _)) - { - var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); - await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId format" }); - return badRequest; - } - - _logger.LogInformation("Fetching sessions for tenant {TenantId} (User: {UserEmail}, cursor: {Cursor}, days: {Days}, limit: {Limit})", tenantIdFilter, userEmail, cursor ?? "none", days?.ToString() ?? "all", limit); - page = await _sessionRepo.GetSessionsAsync(tenantIdFilter, maxResults: limit, cursor: cursor, days: days); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; } - else + + if (!string.IsNullOrEmpty(parsed.FilterTenantId) && !Guid.TryParse(parsed.FilterTenantId, out _)) { - _logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor}, days: {Days}, limit: {Limit})", userEmail, cursor ?? "none", days?.ToString() ?? "all", limit); - page = await _sessionRepo.GetAllSessionsAsync(maxResults: limit, cursor: cursor, days: days); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId format" }); + return bad; + } + + _logger.LogInformation( + "Fetching sessions cross-tenant (filterTenantId={FilterTenant}, user={User}, days={Days}, pageSize={PageSize}, hasContinuation={HasContinuation})", + parsed.FilterTenantId ?? "none", userEmail, parsed.Days, parsed.PageSize, parsed.Continuation != null); + + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!SessionListPagination.TryAcceptContinuation( + parsed.Continuation, scope: "sessions:global", + callerTenantId: callerTenantId, days: parsed.Days, + filterTenantId: parsed.FilterTenantId, + out azureToken, out var rejectReason)) + { + _logger.LogWarning("GetAllSessions: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _sessionRepo.GetAllSessionsPageAsync( + parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SessionListPagination.Fingerprint( + scope: "sessions:global", callerTenantId: callerTenantId, + days: parsed.Days, filterTenantId: parsed.FilterTenantId); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = SessionListPagination.BuildNextLink( + basePath: "/api/global/sessions", + pageSize: parsed.PageSize, + wireContinuation: wireToken, + days: parsed.Days, + filterTenantId: parsed.FilterTenantId); } return await req.OkAsync(new { success = true, - count = page.Sessions.Count, - hasMore = page.HasMore, - cursor = page.Cursor, - sessions = page.Sessions + count = page.Items.Count, + sessions = page.Items, + nextLink, }); } catch (Exception ex) @@ -83,7 +111,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions success = false, message = "Internal server error", count = 0, - hasMore = false, sessions = Array.Empty() }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index 6ec0272e..da69eaa0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -33,34 +36,123 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]"; - _logger.LogInformation($"{sessionPrefix} GetSessionEvents: Fetching events"); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + var pagination = SessionEventsPagination.ParseQuery(query); + if (pagination.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = pagination.Error }); + return bad; + } + + _logger.LogInformation( + "{Prefix} GetSessionEvents: Fetching events (pageSize={PageSize}, hasContinuation={HasContinuation})", + sessionPrefix, pagination.PageSize?.ToString() ?? "all", pagination.Continuation != null); try { // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware // Cross-tenant access check handled by middleware (TargetTenantId) var requestCtx = req.GetRequestContext(); + var tenantIdQueryParam = query["tenantId"]; - // Get events from storage using the resolved tenant ID - var events = await _sessionRepo.GetSessionEventsAsync(requestCtx.TargetTenantId, sessionId); - - // Global Admin cross-tenant fallback: if no events found in the effective tenant, - // try resolving the actual tenant via SessionsIndex - if (events.Count == 0 && requestCtx.IsGlobalAdmin) + if (pagination.PageSize == null) { - var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); - if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + // Legacy unpaginated path — full list, no nextLink. + var events = await _sessionRepo.GetSessionEventsAsync(requestCtx.TargetTenantId, sessionId); + + if (events.Count == 0 && requestCtx.IsGlobalAdmin) { - events = await _sessionRepo.GetSessionEventsAsync(resolvedTenantId, sessionId); + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase)) + { + events = await _sessionRepo.GetSessionEventsAsync(resolvedTenantId, sessionId); + } } + + return await req.OkAsync(new + { + success = true, + sessionId, + count = events.Count, + events, + }); + } + + // Paginated path. Continuation token binds (tenantId, sessionId), so the + // tenantId on every page must match the tenant the token was issued for. + // + // For Global Admin cross-tenant, the nextLink we emit echoes that tenant + // back as ?tenantId=, so follow-up pages can re-bind to it. On page 1, if + // GA hasn't passed tenantId yet, we resolve it from the session lookup. + // + // For non-GA, ?tenantId= in the URL is ignored — middleware-bound JWT + // tenant is authoritative; we must never let a query param override it. + var effectiveTenantId = requestCtx.TargetTenantId; + + if (requestCtx.IsGlobalAdmin) + { + if (!string.IsNullOrEmpty(tenantIdQueryParam)) + { + // Caller passed an explicit tenantId — including via the + // nextLink we emitted on a prior page. Cheap-path: trust it, + // skip the storage probe. + effectiveTenantId = tenantIdQueryParam; + } + else + { + // No tenantId in URL. Resolve it from the session lookup + // *on every page*, not just page 1, so external callers + // that strip nextLink down to the bare continuation token + // (older MCP clients, deployed agents) still validate. + // The lookup is one indexed point-read on SessionsIndex — + // negligible compared to the events page fetch itself. + var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId); + if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase)) + { + effectiveTenantId = resolvedTenantId; + } + } + } + + string? azureToken = null; + if (pagination.Continuation != null) + { + if (!SessionEventsPagination.TryAcceptContinuation( + pagination.Continuation, effectiveTenantId, sessionId, out azureToken, out var reason)) + { + _logger.LogWarning( + "{Prefix} GetSessionEvents: continuation rejected ({Reason})", + sessionPrefix, reason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({reason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _sessionRepo.GetSessionEventsPageAsync( + effectiveTenantId, sessionId, pagination.PageSize.Value, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SessionEventsPagination.Fingerprint(effectiveTenantId, sessionId); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, effectiveTenantId, fp); + nextLink = SessionEventsPagination.BuildNextLink( + sessionId, pagination.PageSize.Value, wireToken, effectiveTenantId); } return await req.OkAsync(new { success = true, - sessionId = sessionId, - count = events.Count, - events = events + sessionId, + count = page.Items.Count, + events = page.Items, + nextLink, }); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs index 37d4977c..0190d25f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -28,29 +31,61 @@ 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"]; - var daysParam = query["days"]; - int? days = null; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); - var limitParam = query["limit"]; - int limit = 100; - if (!string.IsNullOrEmpty(limitParam) && int.TryParse(limitParam, out var parsedLimit)) - limit = Math.Clamp(parsedLimit, 1, 100); + var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: false); + if (parsed.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error }); + return bad; + } - _logger.LogInformation("Fetching sessions for tenant {TenantId} (cursor: {Cursor}, days: {Days}, limit: {Limit})", tenantId, cursor ?? "none", days?.ToString() ?? "all", limit); + _logger.LogInformation( + "Fetching sessions (tenant={TenantId}, days={Days}, pageSize={PageSize}, hasContinuation={HasContinuation})", + tenantId, parsed.Days, parsed.PageSize, parsed.Continuation != null); - var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: limit, cursor: cursor, days: days); + string? azureToken = null; + if (parsed.Continuation != null) + { + if (!SessionListPagination.TryAcceptContinuation( + parsed.Continuation, scope: "sessions:tenant", + callerTenantId: tenantId, days: parsed.Days, filterTenantId: null, + out azureToken, out var rejectReason)) + { + _logger.LogWarning("GetSessions: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _sessionRepo.GetSessionsPageAsync(tenantId, parsed.Days, parsed.PageSize, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SessionListPagination.Fingerprint( + scope: "sessions:tenant", callerTenantId: tenantId, days: parsed.Days); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, tenantId, fp); + nextLink = SessionListPagination.BuildNextLink( + basePath: "/api/sessions", + pageSize: parsed.PageSize, + wireContinuation: wireToken, + days: parsed.Days, + filterTenantId: null); + } return await req.OkAsync(new { success = true, - count = page.Sessions.Count, - hasMore = page.HasMore, - cursor = page.Cursor, - sessions = page.Sessions + count = page.Items.Count, + sessions = page.Items, + nextLink, }); } catch (Exception ex) @@ -63,7 +98,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions success = false, message = "Internal server error", count = 0, - hasMore = false, sessions = Array.Empty() }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs index dd34bddd..f9d3953c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs @@ -1,6 +1,9 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -32,16 +35,27 @@ public class SearchSessionsByEventFunction { try { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); string? tenantId; + string? filterTenantId; + string scope; + string basePath; + var callerTenantId = TenantHelper.GetTenantId(req); + if (isTenantScoped) { - tenantId = TenantHelper.GetTenantId(req); + tenantId = callerTenantId; + filterTenantId = null; + scope = "search-by-event:tenant"; + basePath = "/api/search/sessions-by-event"; } else { - tenantId = query["tenantId"]; + filterTenantId = query["tenantId"]; + tenantId = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId; + scope = "search-by-event:global"; + basePath = "/api/global/search/sessions-by-event"; } var eventType = query["eventType"]; @@ -52,15 +66,50 @@ public class SearchSessionsByEventFunction return badReq; } - var limit = int.TryParse(query["limit"], out var lim) ? Math.Clamp(lim, 1, 100) : 50; + var pagination = SearchSessionsByEventPagination.ParsePagination(query); + if (pagination.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = pagination.Error }); + return bad; + } - var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, null, null, null, limit); + string? azureToken = null; + if (pagination.Continuation != null) + { + if (!SearchSessionsByEventPagination.TryAcceptContinuation( + pagination.Continuation, scope, callerTenantId, filterTenantId, eventType, + out azureToken, out var rejectReason)) + { + _logger.LogWarning("SearchSessionsByEvent: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; + } + } + + var page = await _sessionRepo.SearchSessionsByEventPageAsync( + tenantId, eventType, source: null, severity: null, phase: null, + pageSize: pagination.PageSize, continuation: azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SearchSessionsByEventPagination.Fingerprint(scope, callerTenantId, filterTenantId, eventType); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = SearchSessionsByEventPagination.BuildNextLink(basePath, pagination.PageSize, wireToken, query); + } return await req.OkAsync(new { success = true, - count = sessions.Count, - sessions + count = page.Items.Count, + sessions = page.Items, + nextLink, }); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index ad173ce2..056b48a8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -1,7 +1,10 @@ using System.Net; +using System.Web; using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -33,62 +36,77 @@ public class SearchSessionsFunction { try { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); + // The endpoint binding tenantId — for tenant-scoped routes the JWT + // is authoritative. For global routes the JWT just identifies the + // caller; tenantId in the query is a filter, not authorization. string? tenantId; + string callerTenantId = TenantHelper.GetTenantId(req); + string? filterTenantId = null; + string scope; + string basePath; + if (isTenantScoped) { - tenantId = TenantHelper.GetTenantId(req); + tenantId = callerTenantId; + scope = "search:tenant"; + basePath = "/api/search/sessions"; } else { - // Global: tenantId is optional filter - tenantId = query["tenantId"]; + filterTenantId = query["tenantId"]; + tenantId = filterTenantId; + scope = "search:global"; + basePath = "/api/global/search/sessions"; } - var filter = new SessionSearchFilter - { - Status = query["status"], - SerialNumber = query["serialNumber"], - DeviceName = query["deviceName"], - Manufacturer = query["manufacturer"], - Model = query["model"], - OsBuild = query["osBuild"], - EnrollmentType = query["enrollmentType"], - GeoCountry = query["geoCountry"], - AgentVersion = query["agentVersion"], - ImeAgentVersion = query["imeAgentVersion"], - Limit = int.TryParse(query["limit"], out var lim) ? Math.Clamp(lim, 1, 100) : 50, - }; + var filter = BuildFilter(query); - if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp; - if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj; - if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa; - if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb; - - // Dynamic device property filters: any query param starting with "prop." - // e.g. ?prop.tpm_status.specVersion=2.0&prop.hardware_spec.ramTotalGB=>=8 - var deviceProperties = new Dictionary(); - foreach (string? key in query.AllKeys) + var pagination = SearchSessionsPagination.ParsePagination(query); + if (pagination.Error != null) { - if (key != null && key.StartsWith("prop.", StringComparison.OrdinalIgnoreCase)) + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = pagination.Error }); + return bad; + } + + string? azureToken = null; + if (pagination.Continuation != null) + { + if (!SearchSessionsPagination.TryAcceptContinuation( + pagination.Continuation, scope, callerTenantId, filterTenantId, filter, + out azureToken, out var rejectReason)) { - var propName = key.Substring(5); - var value = query[key]; - if (!string.IsNullOrEmpty(value)) - deviceProperties[propName] = value; + _logger.LogWarning("SearchSessions: continuation rejected ({Reason})", rejectReason); + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new + { + success = false, + message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.", + }); + return bad; } } - if (deviceProperties.Count > 0) - filter.DeviceProperties = deviceProperties; - var sessions = await _sessionRepo.SearchSessionsAsync(tenantId, filter); + var page = await _sessionRepo.SearchSessionsPageAsync( + tenantId, filter, pagination.PageSize, azureToken); + + string? nextLink = null; + if (!string.IsNullOrEmpty(page.NextRawToken)) + { + var fp = SearchSessionsPagination.Fingerprint(scope, callerTenantId, filterTenantId, filter); + var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); + nextLink = SearchSessionsPagination.BuildNextLink( + basePath, pagination.PageSize, wireToken, query); + } return await req.OkAsync(new { success = true, - count = sessions.Count, - sessions + count = page.Items.Count, + sessions = page.Items, + nextLink, }); } catch (Exception ex) @@ -96,4 +114,44 @@ public class SearchSessionsFunction return await req.InternalServerErrorAsync(_logger, ex, "Search sessions"); } } + + private static SessionSearchFilter BuildFilter(System.Collections.Specialized.NameValueCollection query) + { + var filter = new SessionSearchFilter + { + Status = query["status"], + SerialNumber = query["serialNumber"], + DeviceName = query["deviceName"], + Manufacturer = query["manufacturer"], + Model = query["model"], + OsBuild = query["osBuild"], + EnrollmentType = query["enrollmentType"], + GeoCountry = query["geoCountry"], + AgentVersion = query["agentVersion"], + ImeAgentVersion = query["imeAgentVersion"], + // Limit field is a no-op in the paged path — pagination drives count. + }; + + if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp; + if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj; + if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa; + if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb; + + // Dynamic device property filters: any query param starting with "prop." + var deviceProperties = new Dictionary(); + foreach (string? key in query.AllKeys) + { + if (key != null && key.StartsWith("prop.", StringComparison.OrdinalIgnoreCase)) + { + var propName = key.Substring(5); + var value = query[key]; + if (!string.IsNullOrEmpty(value)) + deviceProperties[propName] = value; + } + } + if (deviceProperties.Count > 0) + filter.DeviceProperties = deviceProperties; + + return filter; + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/AzureTablesPaginator.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/AzureTablesPaginator.cs new file mode 100644 index 00000000..871b3253 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/AzureTablesPaginator.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Data.Tables; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Thin wrapper around that surfaces + /// the raw Azure-Tables continuation token for one page at a time. + /// + /// + /// The wider continuation envelope (tenant binding, filter fingerprint, + /// expiry) lives in AutopilotMonitor.Shared.Pagination.ContinuationToken. + /// This helper deliberately returns the SDK's opaque token so the caller + /// can stitch the wire token together with its own filter fingerprint. + /// + public static class AzureTablesPaginator + { + /// + /// Fetches a single page of entities. Honours + /// as both the SDK maxPerPage and the page-size hint when + /// resuming from . + /// + /// + /// A tuple of the page entities and the raw continuation string for + /// the next page (null when the page is the last). + /// + public static async Task<(IReadOnlyList Items, string? NextRawToken)> FetchPageAsync( + TableClient client, + string? filter, + int pageSize, + string? continuation, + IEnumerable? select = null, + CancellationToken cancellationToken = default) + where T : class, ITableEntity, new() + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "must be >= 1"); + + string[]? selectArr = null; + if (select != null) + { + var list = new List(); + foreach (var s in select) + { + if (!string.IsNullOrEmpty(s)) list.Add(s); + } + if (list.Count > 0) selectArr = list.ToArray(); + } + + var query = client.QueryAsync( + filter: filter, + maxPerPage: pageSize, + select: selectArr, + cancellationToken: cancellationToken); + + await foreach (var page in query.AsPages(continuationToken: continuation, pageSizeHint: pageSize) + .WithCancellation(cancellationToken)) + { + return (page.Values, page.ContinuationToken); + } + + return (Array.Empty(), null); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs new file mode 100644 index 00000000..21654ca9 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs @@ -0,0 +1,218 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Linq; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Shared pagination helpers for date-windowed forensics endpoints + /// (audit logs, ops events, session reports). These all share the same + /// query-string surface (dateFrom / dateTo / pageSize + /// / continuation) and the same default-window contract, so the + /// parsing + fingerprint logic lives in one place. + /// + /// + /// Per the pagination rollout plan (PR-3): + /// + /// No dateFrom/dateTo ⇒ default to a + /// 30-day window ending now. + /// Either bound supplied ⇒ honoured exactly; the + /// other bound stays open. + /// pageSize opts in to pagination + /// (default , max + /// ); without it the endpoint returns the full + /// filtered window with no nextLink and no row cap. + /// Continuation tokens bind scope, + /// category (when applicable) and the resolved + /// dateFrom/dateTo via filter fingerprint, plus the caller's + /// tenantId. + /// + /// + public static class DateWindowPagination + { + public const int DefaultPageSize = 200; + public const int MaxPageSize = 1000; + public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30); + + /// + /// Parsed query-state. PageSize == null indicates the legacy + /// unpaginated path; Error non-null indicates a 400 should be + /// returned without inspecting the rest. + /// + public sealed class Parsed + { + public DateTime? DateFrom { get; init; } + public DateTime? DateTo { get; init; } + public int? PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + /// + /// Parses dateFrom / dateTo / pageSize / + /// continuation from the URL query and resolves the default + /// 30-day window if both bounds are absent. Resolved bounds are + /// returned in / + /// so the caller can fingerprint + emit them on nextLink + /// unchanged across follow-up calls. + /// + public static Parsed ParseQuery(NameValueCollection? query, DateTimeOffset? now = null) + { + var dateFromRaw = query?["dateFrom"]; + var dateToRaw = query?["dateTo"]; + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + + DateTime? dateFrom = null, dateTo = null; + if (!string.IsNullOrEmpty(dateFromRaw)) + { + if (!TryParseUtc(dateFromRaw!, out var parsed)) + return new Parsed { Error = "dateFrom must be ISO 8601" }; + dateFrom = parsed; + } + if (!string.IsNullOrEmpty(dateToRaw)) + { + if (!TryParseUtc(dateToRaw!, out var parsed)) + return new Parsed { Error = "dateTo must be ISO 8601" }; + dateTo = parsed; + } + if (dateFrom.HasValue && dateTo.HasValue && dateFrom > dateTo) + { + return new Parsed { Error = "dateFrom must be <= dateTo" }; + } + + // Default window: when caller supplies neither bound, pick the last + // 30 days ending at the resolution time. We resolve to a concrete + // value here so the same window survives the cross-page nextLink + // round trip — without this, follow-up pages would see a slightly + // different "now" and blow the fingerprint check. + if (!dateFrom.HasValue && !dateTo.HasValue) + { + var anchor = (now ?? DateTimeOffset.UtcNow).UtcDateTime; + dateFrom = anchor - DefaultWindow; + dateTo = anchor; + } + + int? pageSize = null; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + + // continuation is meaningless without pageSize — silently drop. + var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw) + ? continuationRaw + : null; + + return new Parsed + { + DateFrom = dateFrom, + DateTo = dateTo, + PageSize = pageSize, + Continuation = continuation, + }; + } + + /// + /// Builds a filter fingerprint over (scope, callerTenantId, dateFrom, dateTo, + /// extras...) in canonical form. is for + /// per-endpoint discriminators (e.g. ops-events category). + /// + public static string Fingerprint( + string scope, + string callerTenantId, + DateTime? dateFrom, + DateTime? dateTo, + IEnumerable>? extras = null) + { + var pairs = new List> + { + new KeyValuePair("scope", scope), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("dateFrom", dateFrom?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), + new KeyValuePair("dateTo", dateTo?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), + }; + if (extras != null) pairs.AddRange(extras); + return ContinuationToken.ComputeFingerprint(pairs); + } + + /// + /// Validates an incoming continuation against the recomputed fingerprint + /// of the current request and the caller's tenantId. + /// + public static bool TryAcceptContinuation( + string raw, + string scope, + string callerTenantId, + DateTime? dateFrom, + DateTime? dateTo, + IEnumerable>? extras, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(scope, callerTenantId, dateFrom, dateTo, extras); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + /// + /// Builds a relative-on-host nextLink URL preserving the resolved + /// window and any extra query params the endpoint cares about. + /// + public static string BuildNextLink( + string basePath, + int pageSize, + string wireContinuation, + DateTime? dateFrom, + DateTime? dateTo, + IEnumerable>? extras = null) + { + var sb = new StringBuilder(basePath); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation)); + if (dateFrom.HasValue) + { + sb.Append("&dateFrom=").Append(Uri.EscapeDataString(dateFrom.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture))); + } + if (dateTo.HasValue) + { + sb.Append("&dateTo=").Append(Uri.EscapeDataString(dateTo.Value.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture))); + } + if (extras != null) + { + foreach (var kv in extras) + { + if (string.IsNullOrEmpty(kv.Key) || string.IsNullOrEmpty(kv.Value)) continue; + sb.Append('&').Append(Uri.EscapeDataString(kv.Key)).Append('=').Append(Uri.EscapeDataString(kv.Value!)); + } + } + return sb.ToString(); + } + + private static bool TryParseUtc(string s, out DateTime result) + { + // Accept ISO 8601 in any of the standard forms; force UTC. + // AdjustToUniversal + AssumeUniversal handles strings without a TZ + // offset (treat as UTC) and converts strings with an offset to UTC; + // RoundtripKind is mutually exclusive with these and not needed. + if (DateTime.TryParse( + s, CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed)) + { + result = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + return true; + } + result = default; + return false; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs new file mode 100644 index 00000000..5aea8328 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for /api/raw/events + /api/global/raw/events. + /// PR-6 of mcp-pagination-rollout: replaces the legacy ?limit= wire + /// shape and the hard-coded limit:20 EventTypeIndex lookup with + /// ?pageSize= + ?continuation= + nextLink over the + /// underlying index walk. + /// + /// + /// + /// Pagination unit is EventTypeIndex rows scanned per page, NOT + /// events returned: a single indexed (session, eventType) pair may yield + /// many events. Total events per page can therefore exceed + /// pageSize; AI/clients should follow nextLink until absent + /// for forensics-grade exact recall. + /// + /// + /// Continuation tokens bind (scope, callerTenantId, filterTenantId, + /// eventType, source, severity, startedAfter, startedBefore) via + /// fingerprint — flipping any filter invalidates the cursor (no recall + /// drift between pages). + /// + /// + public static class QueryRawEventsPagination + { + public const int DefaultPageSize = 200; + public const int MaxPageSize = 1000; + + public static string Fingerprint( + string scope, + string callerTenantId, + string? filterTenantId, + string? sessionId, + string? eventType, + string? source, + string? severity, + string? startedAfter, + string? startedBefore) + { + return ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("scope", scope), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("filterTenantId", filterTenantId), + // sessionId is null on the cross-session path; on the single-session + // path it MUST be in the fingerprint so a cursor from session A cannot + // be replayed against session B by the same caller. + new KeyValuePair("sessionId", sessionId), + new KeyValuePair("eventType", eventType), + new KeyValuePair("source", source), + new KeyValuePair("severity", severity), + new KeyValuePair("startedAfter", startedAfter), + new KeyValuePair("startedBefore", startedBefore), + }); + } + + public sealed class Parsed + { + public int PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + public static Parsed ParsePagination(NameValueCollection? query) + { + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + + int pageSize = DefaultPageSize; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + + return new Parsed + { + PageSize = pageSize, + Continuation = string.IsNullOrEmpty(continuationRaw) ? null : continuationRaw, + }; + } + + public static bool TryAcceptContinuation( + string raw, + string scope, + string callerTenantId, + string? filterTenantId, + string? sessionId, + string? eventType, + string? source, + string? severity, + string? startedAfter, + string? startedBefore, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(scope, callerTenantId, filterTenantId, sessionId, eventType, source, severity, startedAfter, startedBefore); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + public static string BuildNextLink( + string basePath, + int pageSize, + string wireContinuation, + NameValueCollection originalQuery) + { + var sb = new StringBuilder(basePath); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + + // Echo every filter param the caller sent so the bookmark survives + // round-trips. Drop pageSize/continuation/limit (legacy/owned). + foreach (string? key in originalQuery.AllKeys) + { + if (string.IsNullOrEmpty(key)) continue; + if (string.Equals(key, "pageSize", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "continuation", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "limit", StringComparison.OrdinalIgnoreCase)) continue; + var value = originalQuery[key]; + if (string.IsNullOrEmpty(value)) continue; + sb.Append('&').Append(System.Uri.EscapeDataString(key!)).Append('=').Append(System.Uri.EscapeDataString(value!)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs new file mode 100644 index 00000000..daf02b39 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for /api/search/sessions-by-event + + /// /api/global/search/sessions-by-event. Same paged + /// EventTypeIndex walk as , but + /// returns sessions (not events) — so it gets its own scope tag in the + /// fingerprint to keep tokens non-fungible across the two endpoints. + /// + public static class SearchSessionsByEventPagination + { + public const int DefaultPageSize = 50; + public const int MaxPageSize = 1000; + + public static string Fingerprint( + string scope, + string callerTenantId, + string? filterTenantId, + string eventType) + { + return ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("scope", scope), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("filterTenantId", filterTenantId), + new KeyValuePair("eventType", eventType), + }); + } + + public sealed class Parsed + { + public int PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + public static Parsed ParsePagination(NameValueCollection? query) + { + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + + int pageSize = DefaultPageSize; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + + return new Parsed + { + PageSize = pageSize, + Continuation = string.IsNullOrEmpty(continuationRaw) ? null : continuationRaw, + }; + } + + public static bool TryAcceptContinuation( + string raw, + string scope, + string callerTenantId, + string? filterTenantId, + string eventType, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(scope, callerTenantId, filterTenantId, eventType); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + public static string BuildNextLink( + string basePath, + int pageSize, + string wireContinuation, + NameValueCollection originalQuery) + { + var sb = new StringBuilder(basePath); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + + // Echo every filter param the caller sent (eventType + filter tenantId) + // so the bookmark is self-contained. Drop pagination-owned + legacy keys. + foreach (string? key in originalQuery.AllKeys) + { + if (string.IsNullOrEmpty(key)) continue; + if (string.Equals(key, "pageSize", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "continuation", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "limit", StringComparison.OrdinalIgnoreCase)) continue; + var value = originalQuery[key]; + if (string.IsNullOrEmpty(value)) continue; + sb.Append('&').Append(System.Uri.EscapeDataString(key!)).Append('=').Append(System.Uri.EscapeDataString(value!)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs new file mode 100644 index 00000000..ad386ec7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Linq; +using System.Text; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for /api/search/sessions + /api/global/search/sessions. + /// Continuation tokens bind the caller's tenantId, the endpoint scope, and a + /// canonical fingerprint of the full filter — flipping any filter param + /// (status, manufacturer, hardware filters, ...) invalidates the token, so a + /// cursor produced by the scan path can't be replayed into the device-snapshot + /// path or vice-versa. + /// + public static class SearchSessionsPagination + { + public const int DefaultPageSize = 50; + public const int MaxPageSize = 1000; + + public static string Fingerprint(string scope, string callerTenantId, string? filterTenantId, SessionSearchFilter filter) + { + // Canonicalize the filter into a flat KV set the shared + // ContinuationToken hasher already understands. "path" is derived + // from filter.HasDeviceSnapshotFilters so a token can't bleed + // between the two query backends. + var pairs = new List> + { + new KeyValuePair("scope", scope), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("filterTenantId", filterTenantId), + new KeyValuePair("path", filter.HasDeviceSnapshotFilters ? "snapshot" : "scan"), + new KeyValuePair("status", filter.Status), + new KeyValuePair("serialNumber", filter.SerialNumber), + new KeyValuePair("agentVersion", filter.AgentVersion), + new KeyValuePair("imeAgentVersion", filter.ImeAgentVersion), + new KeyValuePair("deviceName", filter.DeviceName), + new KeyValuePair("manufacturer", filter.Manufacturer), + new KeyValuePair("model", filter.Model), + new KeyValuePair("osBuild", filter.OsBuild), + new KeyValuePair("enrollmentType", filter.EnrollmentType), + new KeyValuePair("isPreProvisioned", filter.IsPreProvisioned?.ToString()), + new KeyValuePair("isHybridJoin", filter.IsHybridJoin?.ToString()), + new KeyValuePair("geoCountry", filter.GeoCountry), + new KeyValuePair("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), + new KeyValuePair("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), + }; + if (filter.DeviceProperties != null) + { + foreach (var kv in filter.DeviceProperties.OrderBy(k => k.Key, StringComparer.Ordinal)) + { + pairs.Add(new KeyValuePair($"prop.{kv.Key}", kv.Value)); + } + } + return ContinuationToken.ComputeFingerprint(pairs); + } + + public sealed class Parsed + { + public int PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + public static Parsed ParsePagination(NameValueCollection? query) + { + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + + int pageSize = DefaultPageSize; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + return new Parsed + { + PageSize = pageSize, + Continuation = string.IsNullOrEmpty(continuationRaw) ? null : continuationRaw, + }; + } + + public static bool TryAcceptContinuation( + string raw, + string scope, + string callerTenantId, + string? filterTenantId, + SessionSearchFilter filter, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(scope, callerTenantId, filterTenantId, filter); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + public static string BuildNextLink( + string basePath, + int pageSize, + string wireContinuation, + NameValueCollection originalQuery) + { + var sb = new StringBuilder(basePath); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + + // Echo back every filter param the caller sent so they don't have + // to track it in their bookmark — drop pageSize/continuation/limit + // (the new contract owns these). + foreach (string? key in originalQuery.AllKeys) + { + if (string.IsNullOrEmpty(key)) continue; + if (string.Equals(key, "pageSize", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "continuation", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(key, "limit", StringComparison.OrdinalIgnoreCase)) continue; + var value = originalQuery[key]; + if (string.IsNullOrEmpty(value)) continue; + sb.Append('&').Append(System.Uri.EscapeDataString(key!)).Append('=').Append(System.Uri.EscapeDataString(value!)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs new file mode 100644 index 00000000..7e46ad3f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs @@ -0,0 +1,116 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for the GET /api/sessions/{sessionId}/events pagination + /// surface. Kept pure so they can be unit-tested without standing up the + /// Functions HTTP runtime. + /// + public static class SessionEventsPagination + { + public const int DefaultPageSize = 200; + public const int MaxPageSize = 1000; + + /// Fingerprint binding tokens to (tenantId, sessionId). + public static string Fingerprint(string tenantId, string sessionId) => + ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("tenantId", tenantId), + new KeyValuePair("sessionId", sessionId), + }); + + /// + /// Result of . PageSize is null when the + /// request did not opt in to pagination — the function then returns the + /// full event list, no nextLink (legacy behavior). + /// + public sealed class Parsed + { + public int? PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + /// + /// Parses pageSize + continuation from the URL query. + /// Tolerant: missing pageSize means "unpaginated"; invalid pageSize is + /// surfaced as for the caller to 400 on. + /// + public static Parsed ParseQuery(NameValueCollection? query) + { + if (query == null) + { + return new Parsed { PageSize = null, Continuation = null }; + } + + var rawPageSize = query["pageSize"]; + var rawContinuation = query["continuation"]; + + int? pageSize = null; + if (!string.IsNullOrEmpty(rawPageSize)) + { + if (!int.TryParse(rawPageSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + { + return new Parsed { Error = "pageSize must be an integer" }; + } + if (n < 1 || n > MaxPageSize) + { + return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; + } + pageSize = n; + } + + // continuation without pageSize is meaningless — silently drop it + // rather than 400, so a caller that toggles pageSize off does not get + // a confusing rejection on the leftover token. + var continuation = pageSize.HasValue && !string.IsNullOrEmpty(rawContinuation) + ? rawContinuation + : null; + + return new Parsed { PageSize = pageSize, Continuation = continuation }; + } + + /// + /// Validates a wire-token + decodes the embedded Azure-Tables continuation. + /// + public static bool TryAcceptContinuation( + string raw, + string tenantId, + string sessionId, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(tenantId, sessionId); + return ContinuationToken.TryDecode(raw, tenantId, fp, out azureToken, out rejectReason); + } + + /// + /// Builds the absolute(-on-host) nextLink URL the AI/UI should + /// follow. Caller passes the tenantId the continuation was bound to so + /// follow-up pages can re-bind to the same tenant — critical for GA + /// cross-tenant reads where the JWT tenant differs from the session's + /// actual tenant. + /// + public static string BuildNextLink( + string sessionId, + int pageSize, + string wireContinuation, + string? tenantId) + { + var sb = new StringBuilder(); + sb.Append("/api/sessions/").Append(sessionId).Append("/events"); + sb.Append("?pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + if (!string.IsNullOrEmpty(tenantId)) + { + sb.Append("&tenantId=").Append(System.Uri.EscapeDataString(tenantId)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs new file mode 100644 index 00000000..2d4613bb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for the session-list pagination surface + /// (/api/sessions + /api/global/sessions). Replaces the + /// legacy ?limit= + ?cursor= wire shape with the rollout + /// plan's ?pageSize= + ?continuation= + nextLink + /// contract. + /// + public static class SessionListPagination + { + public const int DefaultPageSize = 100; + public const int MaxPageSize = 1000; + + /// + /// Fingerprint binding the token to (scope, callerTenantId, days, filterTenantId). + /// is for the global endpoint only. + /// + public static string Fingerprint(string scope, string callerTenantId, int? days, string? filterTenantId = null) => + ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("scope", scope), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("days", days?.ToString(CultureInfo.InvariantCulture)), + new KeyValuePair("filterTenantId", filterTenantId), + }); + + public sealed class Parsed + { + public int PageSize { get; init; } + public string? Continuation { get; init; } + public int? Days { get; init; } + public string? FilterTenantId { get; init; } + public string? Error { get; init; } + } + + public static Parsed ParseQuery(NameValueCollection? query, bool acceptFilterTenantId) + { + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + var daysRaw = query?["days"]; + var filterTenantIdRaw = acceptFilterTenantId ? query?["tenantId"] : null; + + int pageSize = DefaultPageSize; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + + int? days = null; + if (!string.IsNullOrEmpty(daysRaw)) + { + if (!int.TryParse(daysRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) || d < 1) + return new Parsed { PageSize = pageSize, Error = "days must be a positive integer" }; + days = d; + } + + return new Parsed + { + PageSize = pageSize, + Continuation = string.IsNullOrEmpty(continuationRaw) ? null : continuationRaw, + Days = days, + FilterTenantId = string.IsNullOrEmpty(filterTenantIdRaw) ? null : filterTenantIdRaw, + }; + } + + public static bool TryAcceptContinuation( + string raw, + string scope, + string callerTenantId, + int? days, + string? filterTenantId, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(scope, callerTenantId, days, filterTenantId); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + public static string BuildNextLink( + string basePath, + int pageSize, + string wireContinuation, + int? days, + string? filterTenantId) + { + var sb = new StringBuilder(basePath); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + if (days.HasValue) + { + sb.Append("&days=").Append(days.Value.ToString(CultureInfo.InvariantCulture)); + } + if (!string.IsNullOrEmpty(filterTenantId)) + { + sb.Append("&tenantId=").Append(System.Uri.EscapeDataString(filterTenantId!)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs new file mode 100644 index 00000000..fcfb1889 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Text; +using AutopilotMonitor.Shared.Pagination; + +namespace AutopilotMonitor.Functions.Pagination +{ + /// + /// Pure helpers for the GET /api/global/session-reports pagination + /// surface. Stays GA-only at the policy layer; the optional + /// tenantId here is a server-side filter, not an + /// authorization scope, so the continuation token only binds the caller's + /// identity + the active filter (so a token issued for tenantA's report + /// view can't silently start serving tenantB's reports if the caller + /// rewrites the query). + /// + public static class SessionReportsPagination + { + public const int DefaultPageSize = 200; + public const int MaxPageSize = 1000; + + public static string Fingerprint(string callerTenantId, string? filterTenantId) => + ContinuationToken.ComputeFingerprint(new[] + { + new KeyValuePair("scope", "session-reports"), + new KeyValuePair("tenantId", callerTenantId), + new KeyValuePair("filterTenantId", filterTenantId), + }); + + public sealed class Parsed + { + public string? FilterTenantId { get; init; } + public int? PageSize { get; init; } + public string? Continuation { get; init; } + public string? Error { get; init; } + } + + public static Parsed ParseQuery(NameValueCollection? query) + { + var filterTenantIdRaw = query?["tenantId"]; + var pageSizeRaw = query?["pageSize"]; + var continuationRaw = query?["continuation"]; + + int? pageSize = null; + if (!string.IsNullOrEmpty(pageSizeRaw)) + { + if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) + return new Parsed { Error = "pageSize must be an integer" }; + if (n < 1 || n > MaxPageSize) + return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; + pageSize = n; + } + + // continuation is meaningless without pageSize — silently drop. + var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw) + ? continuationRaw + : null; + + return new Parsed + { + FilterTenantId = string.IsNullOrEmpty(filterTenantIdRaw) ? null : filterTenantIdRaw, + PageSize = pageSize, + Continuation = continuation, + }; + } + + public static bool TryAcceptContinuation( + string raw, + string callerTenantId, + string? filterTenantId, + out string azureToken, + out string? rejectReason) + { + var fp = Fingerprint(callerTenantId, filterTenantId); + return ContinuationToken.TryDecode(raw, callerTenantId, fp, out azureToken, out rejectReason); + } + + public static string BuildNextLink(int pageSize, string wireContinuation, string? filterTenantId) + { + var sb = new StringBuilder("/api/global/session-reports"); + sb.Append('?'); + sb.Append("pageSize=").Append(pageSize.ToString(CultureInfo.InvariantCulture)); + sb.Append("&continuation=").Append(System.Uri.EscapeDataString(wireContinuation)); + if (!string.IsNullOrEmpty(filterTenantId)) + { + sb.Append("&tenantId=").Append(System.Uri.EscapeDataString(filterTenantId!)); + } + return sb.ToString(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index b06fd4bd..d5e8782b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -400,8 +400,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var tid in tenantIds) { - var sessionsPage = await _sessionRepo.GetSessionsAsync(tid, maxResults: 10000); - var sessions = sessionsPage.Sessions; + var sessions = await _sessionRepo.GetSessionsAsync(tid); totalEnrollments += sessions.Count; successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs index dc98facb..6552fddd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs @@ -81,10 +81,10 @@ namespace AutopilotMonitor.Functions.Services private async Task ComputePlatformMetricsInternalAsync(int days) { - // Fetch all sessions in the requested window across all tenants. When `days` is set, - // GetAllSessionsAsync ignores `maxResults` and returns the full date-bounded set. - var page = await _sessionRepo.GetAllSessionsAsync(maxResults: 500, days: days); - var allSessions = page.Sessions; + // Fetch all sessions in the requested window across all tenants — drains + // every page so the platform metric covers the full date-bounded set + // (the previous maxResults:500 was a silent truncation on busy installs). + var allSessions = await _sessionRepo.GetAllSessionsAsync(tenantIdFilter: null, days: days); if (allSessions.Count == 0) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index 60e9145a..61ae2274 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Azure.Storage.Blobs.Models; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -156,12 +157,20 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Returns all session reports, newest first (by inverted RowKey). + /// Returns all session reports newest-first, optionally filtered to a single tenant. /// - public async Task> GetAllReportsAsync() - { - return await _notificationRepo.GetSessionReportsAsync(); - } + public Task> GetAllReportsAsync(string? tenantId = null) + => _notificationRepo.GetSessionReportsAsync(tenantId); + + /// + /// Returns a single page of session reports newest-first, optionally + /// filtered to a single tenant. The opaque NextRawToken on the + /// returned is what the function layer wraps + /// with the wire continuation envelope. + /// + public Task> GetReportsPageAsync( + string? tenantId, int pageSize, string? continuation) + => _notificationRepo.GetSessionReportsPageAsync(tenantId, pageSize, continuation); /// /// Updates the AdminNote field for a report identified by reportId. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs index 72a83da5..cb268fb9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaBreachEvaluationService.cs @@ -249,13 +249,13 @@ namespace AutopilotMonitor.Functions.Services var threshold = config.SlaConsecutiveFailureThreshold; if (threshold < 2) threshold = 5; - // Query the most recent N sessions for this tenant - var page = await _sessionRepo.GetSessionsAsync(tenantId, threshold); - if (page?.Sessions == null || page.Sessions.Count < threshold) + // Query the most recent N sessions for this tenant — single page is enough. + var page = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: threshold, continuation: null); + if (page.Items.Count < threshold) return; // Check if all N are failed - var allFailed = page.Sessions + var allFailed = page.Items .Take(threshold) .All(s => s.Status == SessionStatus.Failed); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index f069e54e..baafea94 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -1,8 +1,10 @@ using Azure; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services @@ -442,6 +444,128 @@ namespace AutopilotMonitor.Functions.Services return await SearchSessionsByScanAsync(tenantId, filter); } + /// + /// Paged variant of backing + /// /api/search/sessions + /api/global/search/sessions. + /// The filter.Limit field is ignored — pagination uses + /// + . + /// Both code paths (scan + device-snapshot) emit Azure-Tables raw + /// continuation tokens; the caller's filter fingerprint must include + /// the filter args so a token from one path can't be replayed into + /// the other. + /// + public async Task> SearchSessionsPageAsync( + string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + return filter.HasDeviceSnapshotFilters + ? await SearchSessionsByDeviceSnapshotPageAsync(tenantId, filter, pageSize, continuation) + : await SearchSessionsByScanPageAsync(tenantId, filter, pageSize, continuation); + } + + private async Task> SearchSessionsByScanPageAsync( + string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation) + { + var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex); + var oDataFilter = BuildSearchScanFilter(tenantId, filter); + + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: indexTableClient, + filter: oDataFilter, + pageSize: pageSize, + continuation: continuation); + + var sessions = new List(entities.Count); + foreach (var entity in entities) + { + var session = MapIndexEntityToSessionSummary(entity); + if (!MatchesScanClientFilters(session, filter)) continue; + sessions.Add(session); + } + // Note: a page may legitimately contain fewer than pageSize items after + // client-side filters; callers should follow nextLink until absent for + // forensics-grade exact-count semantics (Plan §"consume until absent"). + return new RawPage(sessions, nextRawToken); + } + + private async Task> SearchSessionsByDeviceSnapshotPageAsync( + string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation) + { + var snapshotTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot); + var oDataFilter = string.IsNullOrEmpty(tenantId) ? null : $"PartitionKey eq '{tenantId}'"; + + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: snapshotTableClient, + filter: oDataFilter, + pageSize: pageSize, + continuation: continuation); + + var sessionIds = new List(); + foreach (var entity in entities) + { + var properties = ReconstructDeviceProperties(entity); + if (properties == null || properties.Count == 0) continue; + if (!MatchesAllDeviceFilters(properties, filter.DeviceProperties!)) continue; + sessionIds.Add(entity.RowKey); + } + + if (sessionIds.Count == 0) + { + return new RawPage(Array.Empty(), nextRawToken); + } + + var sessions = await BatchGetSessionsAsync(tenantId, sessionIds); + sessions = ApplyBasicFilters(sessions, filter); + return new RawPage(sessions, nextRawToken); + } + + private static string? BuildSearchScanFilter(string? tenantId, SessionSearchFilter filter) + { + var parts = new List(); + if (!string.IsNullOrEmpty(tenantId)) + parts.Add($"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'"); + if (!string.IsNullOrEmpty(filter.Status)) + parts.Add($"Status eq '{ODataSanitizer.EscapeValue(filter.Status)}'"); + if (!string.IsNullOrEmpty(filter.Manufacturer)) + parts.Add($"Manufacturer eq '{ODataSanitizer.EscapeValue(filter.Manufacturer)}'"); + if (!string.IsNullOrEmpty(filter.Model)) + parts.Add($"Model eq '{ODataSanitizer.EscapeValue(filter.Model)}'"); + if (!string.IsNullOrEmpty(filter.EnrollmentType)) + parts.Add($"EnrollmentType eq '{ODataSanitizer.EscapeValue(filter.EnrollmentType)}'"); + if (!string.IsNullOrEmpty(filter.DeviceName)) + { + var safeName = ODataSanitizer.EscapeValue(filter.DeviceName); + parts.Add($"DeviceName ge '{safeName}' and DeviceName lt '{safeName}~'"); + } + if (!string.IsNullOrEmpty(filter.OsBuild)) + { + var safeBuild = ODataSanitizer.EscapeValue(filter.OsBuild); + parts.Add($"OsBuild ge '{safeBuild}' and OsBuild lt '{safeBuild}~'"); + } + return parts.Count == 0 ? null : string.Join(" and ", parts); + } + + private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter) + { + if (!string.IsNullOrEmpty(filter.SerialNumber) && + !string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase)) + return false; + if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) return false; + if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) return false; + if (!string.IsNullOrEmpty(filter.GeoCountry) && + !string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) + return false; + if (filter.StartedAfter.HasValue && session.StartedAt < filter.StartedAfter.Value) return false; + if (filter.StartedBefore.HasValue && session.StartedAt > filter.StartedBefore.Value) return false; + if (!string.IsNullOrEmpty(filter.AgentVersion) && + !string.Equals(session.AgentVersion, filter.AgentVersion, StringComparison.OrdinalIgnoreCase)) + return false; + if (!string.IsNullOrEmpty(filter.ImeAgentVersion) && + !string.Equals(session.ImeAgentVersion, filter.ImeAgentVersion, StringComparison.OrdinalIgnoreCase)) + return false; + return true; + } + private async Task> SearchSessionsByDeviceSnapshotAsync(string? tenantId, SessionSearchFilter filter) { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.DeviceSnapshot); @@ -736,6 +860,15 @@ namespace AutopilotMonitor.Functions.Services minSeverity = (int)parsedSeverity; } + // Tenant-scoped: partition-targeted (cheap, exact-match on PK). + // Cross-tenant: server-side filter on the EventType column. The + // PartitionKey shape is `{tenantId}_{eventType}` and tenantIds may + // contain dashes but never underscores, while eventType itself may + // contain underscores (e.g. `app_install_failed`) — so a PK-suffix + // split is brittle. Filtering on the explicit EventType column + // avoids that ambiguity entirely (regression: previously used + // `LastIndexOf('_')` and silently dropped every multi-underscore + // event type). string? oDataFilter; if (!string.IsNullOrEmpty(tenantId)) { @@ -744,27 +877,15 @@ namespace AutopilotMonitor.Functions.Services oDataFilter += $" and MaxSeverity ge {minSeverity.Value}"; } else - oDataFilter = null; + { + oDataFilter = $"EventType eq '{ODataSanitizer.EscapeValue(eventType)}'"; + if (minSeverity.HasValue) + oDataFilter += $" and MaxSeverity ge {minSeverity.Value}"; + } var sessionIds = new List(); await foreach (var entity in tableClient.QueryAsync(filter: oDataFilter)) { - // For cross-tenant search, filter by eventType in the PartitionKey - if (string.IsNullOrEmpty(tenantId)) - { - var pk = entity.PartitionKey; - var underscoreIdx = pk.LastIndexOf('_'); - if (underscoreIdx < 0) continue; - var pkEventType = pk.Substring(underscoreIdx + 1); - if (!string.Equals(pkEventType, eventType, StringComparison.OrdinalIgnoreCase)) continue; - - // Cross-tenant severity filter (client-side since no PK filter) - if (minSeverity.HasValue) - { - var entitySeverity = entity.GetInt32("MaxSeverity"); - if (entitySeverity == null || entitySeverity < minSeverity.Value) continue; - } - } // Client-side source filter on the Sources field if (!string.IsNullOrEmpty(source)) @@ -786,6 +907,85 @@ namespace AutopilotMonitor.Functions.Services return sessions.Take(limit).ToList(); } + /// + /// Paged variant of — drives + /// QueryRawEventsFunction's cross-session walk. Returns up to + /// distinct sessions plus the underlying + /// Azure-Tables continuation; caller follows the wire nextLink + /// to drain the full recall set. + /// + public async Task> SearchSessionsByEventPageAsync( + string? tenantId, string eventType, string? source, string? severity, string? phase, + int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.EventTypeIndex); + + // Build OData filter — same as legacy SearchSessionsByEventAsync but + // without the limit*2 over-fetch, since pagination handles depth. + int? minSeverity = null; + if (!string.IsNullOrEmpty(severity) && + Enum.TryParse(severity, ignoreCase: true, out var parsedSeverity)) + { + minSeverity = (int)parsedSeverity; + } + + // Tenant-scoped: partition-targeted query (PK exact match). + // Cross-tenant: server-side filter on the EventType column. The + // PartitionKey shape is `{tenantId}_{eventType}` and tenantIds may + // contain dashes but never underscores, while eventType itself may + // contain underscores (e.g. `app_install_failed`) — so the previous + // PK-suffix split via LastIndexOf('_') silently filtered out every + // multi-underscore event type. Filtering on the EventType column + // avoids that ambiguity entirely. + string? oDataFilter; + if (!string.IsNullOrEmpty(tenantId)) + { + oDataFilter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}_{ODataSanitizer.EscapeValue(eventType)}'"; + if (minSeverity.HasValue) + oDataFilter += $" and MaxSeverity ge {minSeverity.Value}"; + } + else + { + oDataFilter = $"EventType eq '{ODataSanitizer.EscapeValue(eventType)}'"; + if (minSeverity.HasValue) + oDataFilter += $" and MaxSeverity ge {minSeverity.Value}"; + } + + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: tableClient, + filter: oDataFilter, + pageSize: pageSize, + continuation: continuation); + + var sessionIds = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entity in entities) + { + + if (!string.IsNullOrEmpty(source)) + { + var sources = entity.GetString("Sources") ?? string.Empty; + if (!sources.Contains(source, StringComparison.OrdinalIgnoreCase)) continue; + } + + var sessionId = entity.GetString("SessionId"); + if (!string.IsNullOrEmpty(sessionId) && seen.Add(sessionId)) + { + sessionIds.Add(sessionId); + } + } + + if (sessionIds.Count == 0) + { + return new RawPage(Array.Empty(), nextRawToken); + } + + var sessions = await BatchGetSessionsAsync(tenantId, sessionIds); + return new RawPage(sessions, nextRawToken); + } + /// /// Searches events across sessions for multiple event types using the EventTypeIndex for pre-filtering, /// then fetches only matching events per session via server-side OData filter on the Events table. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs index 85dea641..e04d6da6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs @@ -1,9 +1,11 @@ using Azure; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -14,7 +16,30 @@ namespace AutopilotMonitor.Functions.Services // ===== AUDIT LOG METHODS ===== /// - /// Logs an audit entry + /// RowKey prefix marking entries written under the new reverse-tick scheme. + /// '!' (0x21) sorts before all hex digits ('0'-'9','a'-'f'), so new rows always + /// land in front of legacy bare-GUID rows during the migration window — letting + /// us drop in-memory re-sorts on paged reads while keeping legacy entries + /// reachable at the tail until they age out. + /// + internal const string AuditLogRowKeyPrefix = "!"; + + /// + /// Builds the RowKey for a new audit entry: prefix + zero-padded reverse-tick + /// + GUID suffix (same-tick collision protection). Exposed for unit testing + /// of the ordering invariants. + /// + internal static string BuildAuditLogRowKey(DateTime timestampUtc, Guid collisionSuffix) + { + var revTick = DateTime.MaxValue.Ticks - timestampUtc.Ticks; + return $"{AuditLogRowKeyPrefix}{revTick:D19}_{collisionSuffix:N}"; + } + + /// + /// Logs an audit entry. RowKey uses a fixed prefix + reverse-tick (newest-first + /// natural ordering) + GUID suffix (same-tick collision protection). Paged reads + /// then need no in-memory re-sorting because Azure Tables returns rows in + /// (PK asc, RK asc) order, which here is (tenant, newest-first). /// public async Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null) { @@ -22,8 +47,9 @@ namespace AutopilotMonitor.Functions.Services { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); var timestamp = DateTime.UtcNow; + var rowKey = BuildAuditLogRowKey(timestamp, Guid.NewGuid()); - var entity = new TableEntity(tenantId, Guid.NewGuid().ToString()) + var entity = new TableEntity(tenantId, rowKey) { { "Action", action }, { "EntityType", entityType }, @@ -45,37 +71,26 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Gets audit log entries for a tenant + /// Gets audit log entries for a tenant within an optional UTC date window. + /// No row cap — returns the full filtered set, sorted newest-first. /// - public async Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) + public async Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'"); + var filter = BuildAuditLogFilter(tenantId, dateFrom, dateTo); + var query = tableClient.QueryAsync(filter: filter); var logs = new List(); await foreach (var entity in query) { - logs.Add(new AuditLogEntry - { - Id = entity.RowKey, - TenantId = entity.PartitionKey, - Action = entity.GetString("Action") ?? string.Empty, - EntityType = entity.GetString("EntityType") ?? string.Empty, - EntityId = entity.GetString("EntityId") ?? string.Empty, - PerformedBy = entity.GetString("PerformedBy") ?? string.Empty, - Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow, - Details = entity.GetString("Details") ?? string.Empty - }); - - if (logs.Count >= maxResults) break; + logs.Add(MapToAuditLogEntry(entity)); } - - // Sort by timestamp descending (most recent first) - return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList(); + logs.Sort((a, b) => b.Timestamp.CompareTo(a.Timestamp)); + return logs; } catch (Exception ex) { @@ -85,35 +100,25 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Gets audit log entries across all tenants (Global Admin Mode) + /// Gets audit log entries across all tenants (Global Admin Mode), optional UTC window. /// - public async Task> GetAllAuditLogsAsync(int maxResults = 100) + public async Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null) { try { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); - var query = tableClient.QueryAsync(); + var filter = BuildAuditLogFilter(tenantId: null, dateFrom, dateTo); + var query = string.IsNullOrEmpty(filter) + ? tableClient.QueryAsync() + : tableClient.QueryAsync(filter: filter); var logs = new List(); await foreach (var entity in query) { - logs.Add(new AuditLogEntry - { - Id = entity.RowKey, - TenantId = entity.PartitionKey, - Action = entity.GetString("Action") ?? string.Empty, - EntityType = entity.GetString("EntityType") ?? string.Empty, - EntityId = entity.GetString("EntityId") ?? string.Empty, - PerformedBy = entity.GetString("PerformedBy") ?? string.Empty, - Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow, - Details = entity.GetString("Details") ?? string.Empty - }); - - if (logs.Count >= maxResults) break; + logs.Add(MapToAuditLogEntry(entity)); } - - // Sort by timestamp descending (most recent first) - return logs.OrderByDescending(l => l.Timestamp).Take(maxResults).ToList(); + logs.Sort((a, b) => b.Timestamp.CompareTo(a.Timestamp)); + return logs; } catch (Exception ex) { @@ -122,6 +127,174 @@ namespace AutopilotMonitor.Functions.Services } } + public Task> GetAuditLogsPageAsync( + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + return FetchAuditLogPageInternalAsync(BuildAuditLogFilter(tenantId, dateFrom, dateTo), pageSize, continuation); + } + + public Task> GetAllAuditLogsPageAsync( + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + { + // 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); + } + + private async Task> FetchAuditLogPageInternalAsync( + string? filter, int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + 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)); + return new RawPage(logs, nextRawToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get audit logs page"); + return RawPage.Empty; + } + } + + private async Task> FetchAllAuditLogsPageAsync( + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + try + { + var continuations = AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge + .DecodeMultiContinuation(continuation); + + // Tenants come from TenantConfiguration (1 row per tenant — cheap). + // PLUS the synthetic global-tenant partition (Constants.AuditGlobalTenantId) + // where platform-action audits are written from TenantOffboardFunction, + // VersionBlockFunction, UpdateAdminConfigurationFunction, … TenantConfiguration + // has no row for it because it's a virtual partition, so it'd be + // silently skipped without this explicit add. + var configTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantConfiguration); + var tenantIds = new HashSet(StringComparer.OrdinalIgnoreCase) + { + Constants.AuditGlobalTenantId, + }; + await foreach (var entity in configTableClient.QueryAsync( + select: new[] { "PartitionKey" }, maxPerPage: 1000)) + { + tenantIds.Add(entity.PartitionKey); + } + + var activeTenantIds = tenantIds + .Where(t => !(continuations.TryGetValue(t, out var c) && c.Exhausted)) + .ToList(); + if (activeTenantIds.Count == 0) + return new RawPage(new List(), null); + + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs); + + // Per-tenant fetch in parallel: filter `PartitionKey eq tenantId` plus + // `RowKey gt LastRowKey` (revtick scheme → strictly older than what + // we already returned for this tenant), plus the optional date window. + var fetchTasks = activeTenantIds.Select(async tid => + { + continuations.TryGetValue(tid, out var prior); + var tenantFilter = BuildAuditLogFilterWithRowKeyBound(tid, dateFrom, dateTo, prior?.LastRowKey); + var fetched = new List<(string RowKey, AuditLogEntry Item)>(); + await foreach (var e in tableClient.QueryAsync(filter: tenantFilter, maxPerPage: pageSize)) + { + fetched.Add((e.RowKey, MapToAuditLogEntry(e))); + if (fetched.Count >= pageSize) break; + } + return new AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge + .PartitionFetchResult(tid, fetched); + }).ToList(); + + var results = await Task.WhenAll(fetchTasks); + + var (items, nextContinuations) = AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge + .MergeAndAdvance(results, continuations, pageSize, e => e.Timestamp); + + bool anyActive = nextContinuations.Any(kv => !kv.Value.Exhausted); + string? nextRawToken = anyActive + ? AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge + .EncodeMultiContinuation(nextContinuations) + : null; + return new RawPage(items, nextRawToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get all audit logs page"); + return RawPage.Empty; + } + } + + private static string BuildAuditLogFilterWithRowKeyBound( + string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey) + { + var clauses = new List + { + $"PartitionKey eq '{tenantId}'", + }; + if (!string.IsNullOrEmpty(lastRowKey)) + clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'"); + if (dateFrom.HasValue) + clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'"); + if (dateTo.HasValue) + clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'"); + return string.Join(" and ", clauses); + } + + private static AuditLogEntry MapToAuditLogEntry(TableEntity entity) => new AuditLogEntry + { + Id = entity.RowKey, + TenantId = entity.PartitionKey, + Action = entity.GetString("Action") ?? string.Empty, + EntityType = entity.GetString("EntityType") ?? string.Empty, + EntityId = entity.GetString("EntityId") ?? string.Empty, + PerformedBy = entity.GetString("PerformedBy") ?? string.Empty, + Timestamp = entity.GetDateTimeOffset("Timestamp")?.UtcDateTime ?? DateTime.UtcNow, + Details = entity.GetString("Details") ?? string.Empty, + }; + + // The system Timestamp is auto-managed by Azure on insert and reliably + // 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) + { + var clauses = new List(); + if (!string.IsNullOrEmpty(tenantId)) + { + clauses.Add($"PartitionKey eq '{tenantId}'"); + } + if (dateFrom.HasValue) + { + clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'"); + } + if (dateTo.HasValue) + { + clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'"); + } + return clauses.Count == 0 ? null : string.Join(" and ", clauses); + } + + private static DateTime ToUtc(DateTime dt) + => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime(); + // ===== DATA RETENTION METHODS ===== /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index e8aa6ded..0c34f128 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -1,8 +1,10 @@ using Azure; using Azure.Data.Tables; +using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -639,12 +641,12 @@ namespace AutopilotMonitor.Functions.Services } /// - /// 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). + /// Internal SessionsIndex scan with custom RowKey-based cursor. Used by both + /// (drain loop) and + /// (single page). /// - public async Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null) + private async Task<(List Sessions, bool HasMore, string? NextCursor)> FetchSessionsPageInternalAsync( + string tenantId, int maxResults, string? cursor, int? days) { SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); @@ -668,42 +670,35 @@ namespace AutopilotMonitor.Functions.Services filter += $" and RowKey lt '{cutoffRowKeyPrefix}'"; } - // When days is set, return all matching sessions (no pagination); otherwise paginate. - var safetyCap = days.HasValue ? 10000 : maxResults + 1; + // Sentinel: fetch one extra row to detect HasMore in a single query. + // The `days` cutoff is applied as a RowKey filter above — pagination + // mechanics are independent of the date window so a tenant with more + // than `maxResults` matching sessions remains fully reachable across + // multiple calls (was: silently capped at 10k pre-fix). var query = indexTableClient.QueryAsync( filter: filter, - maxPerPage: Math.Min(safetyCap, 1000) + maxPerPage: Math.Min(maxResults + 1, 1000) ); var sessions = new List(); await foreach (var entity in query) { sessions.Add(MapIndexEntityToSessionSummary(entity)); - if (sessions.Count >= safetyCap) break; + if (sessions.Count > maxResults) break; } - // If SessionsIndex is empty and no cursor, fall back to Sessions table (pre-migration) + // 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, days); - } - - if (days.HasValue) - { - // Date-bounded query: all matching sessions returned, no pagination - return new SessionPage - { - Sessions = sessions, - HasMore = false, - Cursor = null - }; + var fallback = await FetchSessionsFromPrimaryTableInternalAsync(tenantId, maxResults, days); + return (fallback.Sessions, fallback.HasMore, NextCursor: null); } var hasMore = sessions.Count > maxResults; if (hasMore) sessions.RemoveAt(sessions.Count - 1); - // Cursor = RowKey of the last returned item (opaque to frontend) + // Cursor = RowKey of the last returned item (opaque to caller). string? nextCursor = null; if (hasMore && sessions.Count > 0) { @@ -711,25 +706,98 @@ namespace AutopilotMonitor.Functions.Services nextCursor = ComputeIndexRowKey(lastSession.StartedAt, lastSession.SessionId); } - return new SessionPage - { - Sessions = sessions, - HasMore = hasMore, - Cursor = nextCursor - }; + return (sessions, hasMore, 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 SessionPage(); + return (new List(), false, null); } } + /// + /// Drains all sessions for a tenant — newest-first, no row cap. Internally + /// loops until the next-token is null. + /// + public async Task> GetSessionsAsync(string tenantId, int? days = null) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + + var all = new List(); + string? token = null; + do + { + var page = await GetSessionsPageAsync(tenantId, days, pageSize: 1000, continuation: token); + all.AddRange(page.Items); + token = page.NextRawToken; + } while (!string.IsNullOrEmpty(token)); + return all; + } + + /// + /// Reads a single page of sessions. Builds on the internal RowKey-cursor + /// scan so callers don't see the cursor mechanics — only the + /// envelope with the opaque NextRawToken. + /// + public async Task> GetSessionsPageAsync( + string tenantId, int? days, int pageSize, string? continuation) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + var page = await FetchSessionsPageInternalAsync(tenantId, pageSize, continuation, days); + return new RawPage(page.Sessions, page.HasMore ? page.NextCursor : null); + } + + /// + /// Cross-tenant paged variant of . + /// optionally restricts to one tenant. + /// + public async Task> GetAllSessionsPageAsync( + string? tenantIdFilter, int? days, int pageSize, string? continuation) + { + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); + + // When tenantIdFilter is set and is a valid tenantId, route through + // the per-tenant SessionsIndex scan — natively-ordered, cheaper. + if (!string.IsNullOrEmpty(tenantIdFilter)) + { + return await GetSessionsPageAsync(tenantIdFilter!, days, pageSize, continuation); + } + + var page = await FetchAllSessionsPageInternalAsync(maxResults: pageSize, cursor: continuation, days: days); + return new RawPage(page.Sessions, page.HasMore ? page.NextCursor : null); + } + + /// + /// Drains all sessions across all tenants — newest-first, no row cap. + /// optionally restricts to a single tenant + /// (routed through the per-tenant index for efficiency). + /// + public async Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null) + { + if (!string.IsNullOrEmpty(tenantIdFilter)) + { + return await GetSessionsAsync(tenantIdFilter!, days); + } + + var all = new List(); + string? token = null; + do + { + var page = await GetAllSessionsPageAsync(tenantIdFilter: null, days, pageSize: 1000, continuation: token); + all.AddRange(page.Items); + token = page.NextRawToken; + } while (!string.IsNullOrEmpty(token)); + return all; + } + /// /// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated). /// - private async Task GetSessionsFromPrimaryTableAsync(string tenantId, int maxResults, int? days = null) + private async Task<(List Sessions, bool HasMore)> FetchSessionsFromPrimaryTableInternalAsync( + string tenantId, int maxResults, int? days) { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); var filter = $"PartitionKey eq '{tenantId}'"; @@ -749,22 +817,23 @@ namespace AutopilotMonitor.Functions.Services { var cutoffDate = DateTime.UtcNow.AddDays(-days.Value); sessions = sessions.Where(s => s.StartedAt >= cutoffDate).ToList(); - return new SessionPage { Sessions = sessions, HasMore = false }; + return (sessions, false); } var hasMore = sessions.Count > maxResults; if (hasMore) sessions.RemoveAt(sessions.Count - 1); - return new SessionPage { Sessions = sessions, HasMore = hasMore }; + return (sessions, hasMore); } /// - /// Gets all sessions across all tenants (global admin mode), ordered newest-first, - /// with cursor-based pagination. Uses per-tenant fan-out to leverage the - /// inverted-tick RowKey ordering within each partition, then merge-sorts. + /// Internal cross-tenant scan with custom merge-cursor. Used by both + /// (drain) and + /// (single page). /// - public async Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null) + private async Task<(List Sessions, bool HasMore, string? NextCursor)> FetchAllSessionsPageInternalAsync( + int maxResults, string? cursor, int? days) { try { @@ -780,7 +849,10 @@ namespace AutopilotMonitor.Functions.Services } if (tenantIds.Count == 0 && string.IsNullOrEmpty(cursor)) - return await GetAllSessionsFromPrimaryTableAsync(maxResults, days); + { + var fallback = await FetchAllSessionsFromPrimaryTableInternalAsync(maxResults, days); + return (fallback.Sessions, fallback.HasMore, NextCursor: null); + } // Step 2: Per-tenant fan-out using RowKey ordering (inverted ticks → newest first). // Parse cursor into invertedTicks prefix for cross-tenant RowKey filtering. @@ -796,8 +868,13 @@ namespace AutopilotMonitor.Functions.Services } } - // When days is set, fetch all matching sessions per tenant (with safety cap); otherwise paginate. - var fetchPerTenant = days.HasValue ? 5000 : maxResults + 1; + // Per-tenant fetch budget: maxResults+1 is sufficient because the + // outer merge-sort only ever surfaces top `maxResults` across all + // tenants per page. The `days` cutoff is enforced via the RowKey + // upper-bound filter below — pagination mechanics stay identical + // whether or not a date window is present (was: 5k per tenant + // hard cap when days set, silently truncating high-volume tenants). + var fetchPerTenant = maxResults + 1; // Compute RowKey upper bound for date filtering string? cutoffRowKeyPrefix = days.HasValue @@ -837,17 +914,6 @@ 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(); @@ -858,24 +924,20 @@ namespace AutopilotMonitor.Functions.Services nextCursor = ComputeIndexRowKey(lastSession.StartedAt, lastSession.SessionId); } - return new SessionPage - { - Sessions = allSessions, - HasMore = hasMore, - Cursor = nextCursor - }; + return (allSessions, hasMore, nextCursor); } catch (Exception ex) { _logger.LogError(ex, "Failed to get all sessions"); - return new SessionPage(); + return (new List(), false, null); } } /// /// Fallback: queries the Sessions table directly for global admin (pre-migration). /// - private async Task GetAllSessionsFromPrimaryTableAsync(int maxResults, int? days = null) + private async Task<(List Sessions, bool HasMore)> FetchAllSessionsFromPrimaryTableInternalAsync( + int maxResults, int? days) { var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions); var safetyCap = days.HasValue ? 10000 : maxResults + 1; @@ -894,14 +956,14 @@ namespace AutopilotMonitor.Functions.Services { var cutoffDate = DateTime.UtcNow.AddDays(-days.Value); sessions = sessions.Where(s => s.StartedAt >= cutoffDate).ToList(); - return new SessionPage { Sessions = sessions, HasMore = false }; + return (sessions, false); } var hasMore = sessions.Count > maxResults; if (hasMore) sessions.RemoveAt(sessions.Count - 1); - return new SessionPage { Sessions = sessions, HasMore = hasMore }; + return (sessions, hasMore); } /// @@ -1555,6 +1617,46 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Reads a single page of session events. Honours the supplied + /// and resumes from when supplied. The returned page's + /// items are sorted by Sequence ascending; the next-page cursor is null when this was + /// the last page. + /// + public async Task> GetSessionEventsPageAsync( + string tenantId, string sessionId, int pageSize, string? continuation) + { + SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); + SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId)); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be >= 1"); + + try + { + var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Events); + var partitionKey = $"{tenantId}_{sessionId}"; + + var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync( + client: tableClient, + filter: $"PartitionKey eq '{partitionKey}'", + pageSize: pageSize, + continuation: continuation); + + var events = new List(entities.Count); + foreach (var entity in entities) + { + events.Add(MapToEnrollmentEvent(entity)); + } + events.Sort((a, b) => a.Sequence.CompareTo(b.Sequence)); + + return new RawPage(events, nextRawToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get events page for session {SessionId}", sessionId); + return RawPage.Empty; + } + } + /// /// Gets events for a specific session filtered by event type (server-side OData filter). /// Much more efficient than GetSessionEventsAsync when only one event type is needed. diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index cca1e7e2..ca83cd25 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -101,4 +101,28 @@ function buildQuery(params: Record, + continuationOrNextLink?: string, +): string { + if (continuationOrNextLink && continuationOrNextLink.startsWith('/api/')) { + return continuationOrNextLink; + } + return `${basePath}${buildQuery({ ...extraParams, continuation: continuationOrNextLink })}`; +} + +export { apiFetch, buildQuery, followNextLink }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 4e963b6d..433e4ad3 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { apiFetch, buildQuery } from '../client.js'; +import { apiFetch, buildQuery, followNextLink } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; import { READ_ONLY, READ_ONLY_OPEN } from './shared.js'; import { toolError } from './error-handler.js'; @@ -211,18 +211,32 @@ export function registerAdminTools(server: McpServer): void { server.tool( 'get_audit_logs', 'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' + - 'Omit tenantId for cross-tenant audit log (Global Admin). Returns up to 100 most recent entries.', + 'Omit tenantId for cross-tenant audit log (Global Admin). ' + + 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'Pagination: when "nextLink" is present in the response, more entries are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/global/audit/logs?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + + '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + + 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', { tenantId: z.string().optional().describe('Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).'), + dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the audit window.'), + dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the audit window.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many entries per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), }, READ_ONLY, async (args) => withToolTelemetry('get_audit_logs', async () => { try { - const { tenantId } = args; - const endpoint = tenantId - ? `/api/audit/logs${buildQuery({ tenantId })}` - : '/api/global/audit/logs'; - const data = await apiFetch(endpoint); + const { tenantId, dateFrom, dateTo, pageSize, continuation } = args; + const basePath = tenantId ? '/api/audit/logs' : '/api/global/audit/logs'; + const path = followNextLink( + basePath, + { tenantId, dateFrom, dateTo, pageSize }, + continuation, + ); + const data = await apiFetch(path); return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } catch (error: unknown) { return toolError('get_audit_logs', args, error); @@ -235,18 +249,31 @@ export function registerAdminTools(server: McpServer): void { 'get_ops_events', 'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' + 'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' + - 'Use category filter to narrow results (Consent, Maintenance, Security, Tenant, Agent).', + 'Use category to narrow results (Consent, Maintenance, Security, Tenant, Agent, SLA). ' + + 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'Pagination: when "nextLink" is present in the response, more events are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/global/ops-events?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + + '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + + 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', { - category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent'), - maxResults: z.coerce.number().min(1).max(500).optional().default(200).describe('Max events to return (default: 200)'), + category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent, SLA'), + dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the window.'), + dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many events per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), }, READ_ONLY, async (args) => withToolTelemetry('get_ops_events', async () => { try { - const params: Record = {}; - if (args.category) params.category = args.category; - if (args.maxResults) params.maxResults = args.maxResults; - const data = await apiFetch(`/api/global/ops-events${buildQuery(params)}`); + const { category, dateFrom, dateTo, pageSize, continuation } = args; + const path = followNextLink( + '/api/global/ops-events', + { category, dateFrom, dateTo, pageSize }, + continuation, + ); + const data = await apiFetch(path); return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } catch (error: unknown) { return toolError('get_ops_events', args, error); @@ -258,12 +285,24 @@ export function registerAdminTools(server: McpServer): void { server.tool( 'list_session_reports', 'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' + - 'Global Admin only — returns reports across all tenants.', - {}, + 'Global Admin only — returns reports across all tenants by default; pass tenantId to filter to one tenant. ' + + 'Pagination: when "nextLink" is present in the response, more reports are available — call this tool again with "continuation" set to the value embedded in nextLink. Stop when nextLink is absent.', + { + tenantId: z.string().optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many reports per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Pagination cursor — pass the "continuation" value from the previous response\'s nextLink to fetch the next page.'), + }, READ_ONLY, async (args) => withToolTelemetry('list_session_reports', async () => { try { - const data = await apiFetch('/api/global/session-reports'); + const { tenantId, pageSize, continuation } = args; + const data = await apiFetch(`/api/global/session-reports${buildQuery({ + tenantId, + pageSize, + continuation, + })}`); return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } catch (error: unknown) { return toolError('list_session_reports', args, error); @@ -280,7 +319,11 @@ export function registerAdminTools(server: McpServer): void { 'Query raw enrollment events with flexible filters across sessions. ' + 'Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant. ' + 'Use this when search_events_semantic does not cover the time range or session scope you need, ' + - 'or when you need exact event-type filtering across many sessions. Returns raw event data.', + 'or when you need exact event-type filtering across many sessions. Returns raw event data. ' + + 'Pagination: cross-session queries walk the EventTypeIndex page-by-page. When the response includes ' + + '"nextLink", more sessions are available — call this tool again with "continuation" set to the value ' + + 'embedded in nextLink. Stop when nextLink is absent. Note: pageSize controls the index-scan cadence; ' + + 'a single indexed session can contribute multiple events, so total events per page may exceed pageSize.', { tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'), sessionId: z.string().optional().describe('Filter to a specific session'), @@ -289,7 +332,10 @@ export function registerAdminTools(server: McpServer): void { source: z.string().optional().describe('Filter by event source/app name (substring match)'), startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'), startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'), - limit: z.coerce.number().min(1).max(500).optional().default(100), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Controls index-scan depth per call; follow nextLink for more.'), + continuation: z.string().optional() + .describe('Pagination cursor — pass the "continuation" value from the previous response\'s nextLink to fetch the next page.'), }, READ_ONLY, async (args) => withToolTelemetry('query_raw_events', async () => { diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index e518a759..777839f4 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { apiFetch, buildQuery } from '../client.js'; +import { apiFetch, buildQuery, followNextLink } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; import { READ_ONLY } from './shared.js'; import { toolError } from './error-handler.js'; @@ -40,7 +40,8 @@ export function registerSessionTools(server: McpServer): void { 'Use deviceProperties for ANY device hardware/config filter — keys use "eventType.propertyName" notation. ' + 'Consult the device_properties resource for available keys. ' + 'Examples: {"tpm_status.specVersion": "2.0"}, {"hardware_spec.ramTotalGB": ">=8"}, {"secureboot_status.uefiSecureBootEnabled": "True"}. ' + - 'Array values are searched as substring match (e.g. disks containing "NVMe").', + 'Array values are searched as substring match (e.g. disks containing "NVMe"). ' + + 'Pagination: when "nextLink" is present in the response, more sessions are available — call this tool again with "continuation" set to the value embedded in nextLink. Stop when nextLink is absent.', { tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'), status: z.enum(['InProgress', 'Succeeded', 'Failed']).optional().describe('Enrollment status filter'), @@ -63,13 +64,20 @@ export function registerSessionTools(server: McpServer): void { 'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' + 'Booleans: use "True" or "False". Arrays: substring match in any element.' ), - limit: z.coerce.number().min(1).max(100).optional().default(50).describe('Maximum number of results (1-100, default 50)'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50) + .describe('Page size (1-1000, default 50). Returns this many sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Pagination cursor — pass the "continuation" value from the previous response\'s nextLink to fetch the next page.'), }, READ_ONLY, async (args) => withToolTelemetry('search_sessions', async () => { try { - const { deviceProperties, tenantId, ...rest } = args; - const queryParams: Record = { ...rest }; + const { deviceProperties, tenantId, pageSize, continuation, ...rest } = args; + const queryParams: Record = { + ...rest, + pageSize, + continuation, + }; if (tenantId) queryParams.tenantId = tenantId; if (deviceProperties) { for (const [key, value] of Object.entries(deviceProperties)) { @@ -91,11 +99,15 @@ export function registerSessionTools(server: McpServer): void { 'Find sessions that contain a specific event type (e.g. app install failure, phase transitions, errors). ' + 'Omit tenantId for cross-tenant search (Global Admin). ' + 'Check the event_types resource for valid eventType values. ' + - 'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase.', + 'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase. ' + + 'Pagination: when "nextLink" is present in the response, more sessions are available — call this tool again with "continuation" set to the value embedded in nextLink. Stop when nextLink is absent.', { eventType: z.string().describe('Event type string — see event_types resource for valid values (e.g. "app_install_failed", "enrollment_failed")'), tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'), - limit: z.coerce.number().min(1).max(100).optional().default(50), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50) + .describe('Page size (1-1000, default 50). Returns this many sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Pagination cursor — pass the "continuation" value from the previous response\'s nextLink to fetch the next page.'), }, READ_ONLY, async (args) => withToolTelemetry('search_sessions_by_event', async () => { @@ -146,25 +158,39 @@ export function registerSessionTools(server: McpServer): void { server.tool( 'get_session_events', 'TIER 2 — RAW EVENT RETRIEVAL (fallback when semantic search misses). ' + - 'Get the complete event timeline for a single session. Filter by eventType, severity, or source (app name). ' + + 'Returns up to pageSize events from a single session. Filter by eventType, severity, or source (app name). ' + 'Use this when search_events_semantic returns incomplete results and you need the full unfiltered event stream, ' + 'or for root cause analysis when you need every event in chronological sequence. ' + - 'If you omit tenantId, the backend auto-resolves it from the session (Global Admin can access any tenant).', + 'If you omit tenantId, the backend auto-resolves it from the session (Global Admin can access any tenant). ' + + 'Pagination: if the response includes "nextLink", more events are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/sessions/{id}/events?pageSize=...&continuation=...&tenantId=...") as ' + + '"continuation". The tool follows it verbatim so query params the backend echoes (tenantId on cross-tenant reads, ' + + 'filters, etc.) round-trip correctly. Stop when the response no longer contains a nextLink. Sessions with ' + + 'thousands of events are fully reachable across multiple calls.', { sessionId: z.string().describe('Session UUID'), tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session.'), eventType: z.string().optional().describe('Filter to only events of this type'), severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). The endpoint returns this many events per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so query params the backend echoes round-trip correctly.'), }, READ_ONLY, async (args) => withToolTelemetry('get_session_events', async () => { try { - const { sessionId, tenantId, ...filters } = args; - const q = buildQuery({ tenantId } as Record); - const data = await apiFetch(`/api/sessions/${sessionId}/events${q}`) as { + const { sessionId, tenantId, pageSize, continuation, ...filters } = args; + const path = followNextLink( + `/api/sessions/${sessionId}/events`, + { tenantId, pageSize }, + continuation, + ); + const data = await apiFetch(path) as { events?: Array<{ eventType?: string; severity?: string; source?: string }>; count?: number; + nextLink?: string; }; if (data?.events && (filters.eventType || filters.severity || filters.source)) { data.events = data.events.filter((e) => { diff --git a/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj b/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj index 2e218256..4d0c0c29 100644 --- a/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj +++ b/src/Shared/AutopilotMonitor.Shared/AutopilotMonitor.Shared.csproj @@ -10,4 +10,9 @@ + + + + + diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs index 4b7cff13..14d23249 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; namespace AutopilotMonitor.Shared.DataAccess { @@ -14,8 +15,31 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Audit Logs --- Task LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary? details = null); - Task> GetAuditLogsAsync(string tenantId, int maxResults = 100); - Task> GetAllAuditLogsAsync(int maxResults = 100); + + /// + /// Returns all audit log entries for in the + /// given / window + /// (UTC, inclusive). Either bound may be null. Sorted newest-first. + /// No row cap — callers passing very wide windows MUST consider + /// to bound memory. + /// + Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null); + + /// Cross-tenant variant of (Global Admin only). + Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null); + + /// + /// Reads a single page of audit log entries for + /// 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. + /// + Task> GetAuditLogsPageAsync( + string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); + + /// Cross-tenant variant of (Global Admin only). + Task> GetAllAuditLogsPageAsync( + DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); // --- Data Retention Queries --- Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs index 50073482..cc3f5b8b 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; namespace AutopilotMonitor.Shared.DataAccess { @@ -19,7 +20,23 @@ namespace AutopilotMonitor.Shared.DataAccess // --- Session Reports --- Task StoreSessionReportMetadataAsync(SessionReportMetadata metadata); - Task> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50); + + /// + /// Returns all session reports, newest-first. Optional + /// filters server-side. No row cap on the full-fetch path; for installations + /// with very large report archives prefer . + /// + Task> GetSessionReportsAsync(string? tenantId = null); + + /// + /// Reads a single page of session reports newest-first. Optional + /// applies server-side. The returned + /// carries the underlying store's opaque + /// continuation; null when this page was the last. + /// + Task> GetSessionReportsPageAsync( + string? tenantId, int pageSize, string? continuation); + Task GetSessionReportAsync(string reportId); Task UpdateSessionReportAdminNoteAsync(string reportId, string adminNote); } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs index b68201d3..9eaab709 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IOpsEventRepository.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using AutopilotMonitor.Shared.Pagination; namespace AutopilotMonitor.Shared.DataAccess { @@ -11,8 +12,25 @@ namespace AutopilotMonitor.Shared.DataAccess public interface IOpsEventRepository { Task SaveOpsEventAsync(OpsEventEntry entry); - Task> GetOpsEventsAsync(int maxResults = 200); - Task> GetOpsEventsByCategoryAsync(string category, int maxResults = 100); + + /// + /// Returns all matching ops events in the given UTC window. + /// is optional — when provided, scopes to a single PartitionKey for an + /// indexed lookup. Sorted newest-first. No row cap; for unbounded + /// windows on busy installations, prefer . + /// + Task> GetOpsEventsAsync( + string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null); + + /// + /// Reads a single page of ops events. 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. + /// + Task> GetOpsEventsPageAsync( + string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation); + Task DeleteOpsEventsOlderThanAsync(DateTime cutoff); } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs index f367d81f..03c37659 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; namespace AutopilotMonitor.Shared.DataAccess { @@ -15,8 +16,37 @@ namespace AutopilotMonitor.Shared.DataAccess Task StoreSessionAsync(SessionRegistration registration); Task GetSessionAsync(string tenantId, string sessionId); Task FindSessionTenantIdAsync(string sessionId); - Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null); - Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null); + /// + /// Returns all sessions for newest-first. + /// Optional scopes to the last N days. No row cap — + /// drains the underlying paged scan; for large tenants prefer + /// . + /// + Task> GetSessionsAsync(string tenantId, int? days = null); + + /// + /// Cross-tenant variant of (Global Admin). + /// optionally restricts to a single tenant. + /// + Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null); + + /// + /// Reads a single page of sessions for , newest-first + /// (SessionsIndex inverted-tick RowKey order). The opaque NextRawToken + /// on the returned is the Azure-Tables continuation + /// the function layer wraps with the wire envelope. Optional + /// scopes to the last N days via RowKey range; null means all time. + /// + Task> GetSessionsPageAsync( + string tenantId, int? days, int pageSize, string? continuation); + + /// + /// Cross-tenant variant of (Global Admin). + /// optionally restricts to a single tenant. + /// + Task> GetAllSessionsPageAsync( + string? tenantIdFilter, int? days, int pageSize, string? continuation); + Task DeleteSessionAsync(string tenantId, string sessionId); // --- Session Updates --- @@ -77,12 +107,44 @@ namespace AutopilotMonitor.Shared.DataAccess Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000); Task> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200); + /// + /// Reads a single page of session events. The returned + /// carries the underlying store's opaque continuation token for the next page, + /// or null when the page just read was the last. Items in each page are + /// sorted by Sequence ascending; cross-page ordering follows the table's + /// RowKey iteration (Timestamp+Sequence) and may diverge from strict Sequence + /// order across clock-jump windows — consumers stitching multiple pages should + /// re-sort by Sequence after merging. + /// + Task> GetSessionEventsPageAsync( + string tenantId, string sessionId, int pageSize, string? continuation); + // --- Search --- Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10); Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter); + + /// + /// Paged variant of . Used by the + /// /api/search/sessions + /api/global/search/sessions endpoints. + /// The field is ignored — pagination + /// is driven by + . + /// + Task> SearchSessionsPageAsync( + string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation); Task> SearchSessionsByEventAsync( string? tenantId, string eventType, string? source, string? severity, string? phase, int limit = 50); + + /// + /// Paged variant of . Walks the + /// EventTypeIndex table page-by-page so callers can drain large + /// result sets via nextLink. Replaces the legacy hard-coded + /// limit:20 session-index lookup that silently dropped recall on + /// large tenants (mcp-pagination-rollout PR-6, audit finding D4). + /// + Task> SearchSessionsByEventPageAsync( + string? tenantId, string eventType, string? source, string? severity, string? phase, + int pageSize, string? continuation); Task> SearchEventsByTypesAsync( string? tenantId, IEnumerable eventTypes, string? source, string? severity, int sessionLimit = 10, int eventLimit = 50); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 5161402e..93685f4c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -312,17 +312,6 @@ namespace AutopilotMonitor.Shared.Models public string FailureSnapshotJson { get; set; } = string.Empty; } - /// - /// Paginated result of session listings. - /// Used by GetSessionsAsync and GetAllSessionsAsync to support cursor-based "Load More". - /// - public class SessionPage - { - public List Sessions { get; set; } = new(); - public bool HasMore { get; set; } - public string? Cursor { get; set; } - } - /// /// Lightweight result for the global quick-search typeahead. /// diff --git a/src/Shared/AutopilotMonitor.Shared/Pagination/ContinuationToken.cs b/src/Shared/AutopilotMonitor.Shared/Pagination/ContinuationToken.cs new file mode 100644 index 00000000..b458cea9 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Pagination/ContinuationToken.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AutopilotMonitor.Shared.Pagination +{ + /// + /// Stateless codec for paginated-endpoint continuation tokens. + /// + /// + /// + /// Each token wraps the underlying Azure-Tables continuation string together + /// with a SHA-256 fingerprint of the canonical filter args, the tenantId of + /// the originating request, a UTC issued-at timestamp, and an HMAC-SHA256 + /// signature over the payload. Validation is stateless: the function + /// endpoint recomputes the expected fingerprint from the current request + /// and asks to compare. The HMAC is verified against + /// the server's signing key. + /// + /// + /// Security properties: + /// + /// Cross-tenant replay → caller's resolved tenantId + /// must match the token's; mismatch is rejected. + /// Tampered filter args → recomputed fingerprint will + /// not match the token's, request rejected. + /// Tampered payload (incl. iat bypass) → HMAC + /// verification fails, request rejected. + /// 24h expiry — tokens older than + /// are rejected (HMAC prevents bypass by + /// client-side iat tampering). + /// + /// + /// + /// Signing key sourced from the PaginationTokenSigningKey + /// environment variable (base64-encoded random bytes, ≥32 bytes + /// recommended). Tests may inject a key via + /// . Key rotation invalidates + /// in-flight tokens; callers receive a clean rejection and restart + /// pagination from page 1 — acceptable given the 24h TTL. + /// + /// + public static class ContinuationToken + { + /// Default 24h token expiry — see plan §"Open questions" 3. + public static readonly TimeSpan DefaultTtl = TimeSpan.FromHours(24); + + private const string SigningKeyEnvVar = "PaginationTokenSigningKey"; + + private static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault + }; + + private static byte[]? _testOverrideKey; + + /// + /// Test-only hook to inject a fixed signing key. Production code reads + /// the key from the PaginationTokenSigningKey env var. + /// + internal static void SetSigningKeyForTesting(byte[]? key) => _testOverrideKey = key; + + private static byte[] GetSigningKey() + { + var test = _testOverrideKey; + if (test != null) return test; + + var raw = Environment.GetEnvironmentVariable(SigningKeyEnvVar); + if (string.IsNullOrEmpty(raw)) + throw new InvalidOperationException( + $"{SigningKeyEnvVar} environment variable is required for ContinuationToken HMAC signing. " + + "Set it to a base64-encoded random 32-byte key."); + try + { + return Convert.FromBase64String(raw); + } + catch (FormatException ex) + { + throw new InvalidOperationException( + $"{SigningKeyEnvVar} is not valid base64.", ex); + } + } + + /// + /// Encodes a continuation token. is the + /// raw Azure.Data.Tables page continuation; the remaining + /// parameters bind the token to the originating request. The token is + /// HMAC-SHA256 signed with the server's signing key — any modification + /// (including iat tampering) will fail decode-side verification. + /// + public static string Encode( + string azureToken, + string tenantId, + string filterFingerprint, + DateTimeOffset? issuedAt = null) + { + if (azureToken == null) throw new ArgumentNullException(nameof(azureToken)); + if (string.IsNullOrWhiteSpace(tenantId)) throw new ArgumentException("tenantId required", nameof(tenantId)); + if (string.IsNullOrWhiteSpace(filterFingerprint)) throw new ArgumentException("fingerprint required", nameof(filterFingerprint)); + + var payload = new TokenPayload + { + T = azureToken, + Tid = tenantId, + Fp = filterFingerprint, + Iat = (issuedAt ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(), + // Sig deliberately left null here so the canonical-bytes + // computation below excludes it (JsonIgnoreCondition.WhenWritingDefault). + }; + + var canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + payload.Sig = ComputeSignature(canonicalBytes); + + var signedJson = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + return Base64UrlEncode(signedJson); + } + + /// + /// Validates and decodes a token. Returns true only if the + /// caller's tenantId, the recomputed fingerprint, and the token age + /// all match the policy. On failure + /// reports a stable, log-safe code. + /// + public static bool TryDecode( + string raw, + string expectedTenantId, + string expectedFingerprint, + out string azureToken, + out string? rejectReason, + DateTimeOffset? now = null, + TimeSpan? ttl = null) + { + azureToken = string.Empty; + rejectReason = null; + + if (string.IsNullOrWhiteSpace(raw)) + { + rejectReason = "empty"; + return false; + } + + TokenPayload? payload; + try + { + var bytes = Base64UrlDecode(raw); + payload = JsonSerializer.Deserialize(bytes, JsonOpts); + } + catch (FormatException) + { + rejectReason = "malformed_base64"; + return false; + } + catch (JsonException) + { + rejectReason = "malformed_json"; + return false; + } + + if (payload == null) + { + rejectReason = "null_payload"; + return false; + } + if (string.IsNullOrEmpty(payload.Tid)) + { + rejectReason = "missing_tenant"; + return false; + } + if (string.IsNullOrEmpty(payload.Fp)) + { + rejectReason = "missing_fingerprint"; + return false; + } + + if (string.IsNullOrEmpty(payload.Sig)) + { + rejectReason = "missing_signature"; + return false; + } + + // Verify HMAC before any other field check: a tampered payload (e.g. + // iat reset to bypass expiry, fp swapped to match the new request) + // is rejected here regardless of how plausible the other fields look. + // Cheap to fail fast with a fixed-time compare. + var providedSig = payload.Sig!; // null-checked above; suppress nullable warning + payload.Sig = null; // exclude from canonical bytes (matches Encode) + byte[] canonicalBytes; + try + { + canonicalBytes = JsonSerializer.SerializeToUtf8Bytes(payload, JsonOpts); + } + catch (JsonException) + { + rejectReason = "malformed_json"; + return false; + } + var expectedSig = ComputeSignature(canonicalBytes); + if (!FixedTimeEquals(providedSig, expectedSig)) + { + rejectReason = "bad_signature"; + return false; + } + + if (!FixedTimeEquals(payload.Tid, expectedTenantId ?? string.Empty)) + { + rejectReason = "cross_tenant"; + return false; + } + if (!FixedTimeEquals(payload.Fp, expectedFingerprint ?? string.Empty)) + { + rejectReason = "filter_mismatch"; + return false; + } + + var nowTs = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds(); + var maxAge = (long)(ttl ?? DefaultTtl).TotalSeconds; + if (payload.Iat <= 0 || nowTs - payload.Iat > maxAge) + { + rejectReason = "expired"; + return false; + } + + azureToken = payload.T ?? string.Empty; + return true; + } + + /// + /// Builds the SHA-256 fingerprint over canonicalized filter arguments. + /// Keys are case-insensitive and ordered; null/empty values are dropped + /// so callers can pass through optional filter params unconditionally. + /// + public static string ComputeFingerprint(IEnumerable> filterArgs) + { + if (filterArgs == null) throw new ArgumentNullException(nameof(filterArgs)); + + var canonical = new StringBuilder(); + var first = true; + foreach (var kv in filterArgs + .Where(kv => !string.IsNullOrEmpty(kv.Key) && !string.IsNullOrEmpty(kv.Value)) + .Select(kv => new KeyValuePair(kv.Key.ToLowerInvariant(), kv.Value!)) + .OrderBy(kv => kv.Key, StringComparer.Ordinal)) + { + if (!first) canonical.Append('&'); + canonical.Append(kv.Key); + canonical.Append('='); + canonical.Append(kv.Value); + first = false; + } + + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString())); + var hex = new StringBuilder(hash.Length * 2); + for (int i = 0; i < hash.Length; i++) + { + hex.Append(hash[i].ToString("x2", CultureInfo.InvariantCulture)); + } + return hex.ToString(); + } + + // Constant-time comparison — protects against timing-side-channel snooping + // on the tenantId / fingerprint / signature checks. Critical for the HMAC + // signature: a leaky compare would let an attacker forge tokens via + // byte-by-byte timing oracle. + private static bool FixedTimeEquals(string a, string b) + { + if (a.Length != b.Length) return false; + var diff = 0; + for (int i = 0; i < a.Length; i++) diff |= a[i] ^ b[i]; + return diff == 0; + } + + private static string ComputeSignature(byte[] payloadBytes) + { + using var hmac = new HMACSHA256(GetSigningKey()); + var hash = hmac.ComputeHash(payloadBytes); + return Convert.ToBase64String(hash); + } + + private static string Base64UrlEncode(byte[] bytes) + { + var s = Convert.ToBase64String(bytes); + return s.Replace('+', '-').Replace('/', '_').TrimEnd('='); + } + + private static byte[] Base64UrlDecode(string s) + { + var padded = s.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + case 0: break; + default: throw new FormatException("invalid base64url length"); + } + return Convert.FromBase64String(padded); + } + + private sealed class TokenPayload + { + [JsonPropertyName("t")] public string T { get; set; } = string.Empty; + [JsonPropertyName("tid")] public string Tid { get; set; } = string.Empty; + [JsonPropertyName("fp")] public string Fp { get; set; } = string.Empty; + [JsonPropertyName("iat")] public long Iat { get; set; } + + // HMAC-SHA256(canonical-payload-bytes, key), base64. Excluded from the + // canonical bytes via JsonIgnoreCondition.WhenWritingDefault when null. + [JsonPropertyName("sig")] public string? Sig { get; set; } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Pagination/PaginatedResult.cs b/src/Shared/AutopilotMonitor.Shared/Pagination/PaginatedResult.cs new file mode 100644 index 00000000..8cd1bd18 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Pagination/PaginatedResult.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Pagination +{ + /// + /// Internal page envelope used by paginated endpoints. The wire shape + /// (collection key + count + nextLink) is produced by the endpoint when it + /// projects this onto the response — we keep events/sessions/ + /// logs/reports etc. as the legacy collection key per the + /// pagination rollout plan, so this generic container deliberately does not + /// dictate the JSON property name. + /// + public sealed class PaginatedResult + { + public IReadOnlyList Items { get; } + public int Count { get; } + public string? NextLink { get; } + + public PaginatedResult(IReadOnlyList items, string? nextLink) + { + Items = items ?? throw new ArgumentNullException(nameof(items)); + Count = items.Count; + NextLink = nextLink; + } + + public static PaginatedResult Empty { get; } = new PaginatedResult(Array.Empty(), null); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Pagination/RawPage.cs b/src/Shared/AutopilotMonitor.Shared/Pagination/RawPage.cs new file mode 100644 index 00000000..1fc4f05d --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Pagination/RawPage.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Pagination +{ + /// + /// Repository-layer page envelope. Carries the underlying store's opaque + /// continuation token (e.g. an Azure-Tables continuation) for the function + /// layer to wrap with tenant + filter binding via + /// . + /// + public sealed class RawPage + { + public IReadOnlyList Items { get; } + public string? NextRawToken { get; } + + public RawPage(IReadOnlyList items, string? nextRawToken) + { + Items = items ?? throw new ArgumentNullException(nameof(items)); + NextRawToken = nextRawToken; + } + + public static RawPage Empty { get; } = new RawPage(Array.Empty(), null); + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index 775d02c3..a18db3fc 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation } from "@/lib/paginationLink"; interface OpsEvent { id: string; @@ -43,6 +44,30 @@ const CATEGORY_STYLES: Record = { const ALL_CATEGORIES = ["Consent", "Maintenance", "Security", "Tenant", "Agent"]; +const PAGE_SIZE = 50; + +function defaultIsoDateFrom(): string { + const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + d.setUTCHours(0, 0, 0, 0); + return d.toISOString(); +} + +function defaultIsoDateTo(): string { + return new Date().toISOString(); +} + +function isoToDateInputValue(iso: string): string { + return iso.slice(0, 10); +} + +function dateInputToIsoStart(value: string): string { + return value ? `${value}T00:00:00.000Z` : ""; +} + +function dateInputToIsoEnd(value: string): string { + return value ? `${value}T23:59:59.999Z` : ""; +} + function SeverityBadge({ severity }: { severity: string }) { const style = SEVERITY_STYLES[severity] ?? SEVERITY_STYLES.Info; return ( @@ -102,29 +127,37 @@ export function OpsEventsSection({ const [selectedEvent, setSelectedEvent] = useState(null); const [categoryFilter, setCategoryFilter] = useState(""); const [searchQuery, setSearchQuery] = useState(""); - const [currentPage, setCurrentPage] = useState(0); - const pageSize = 15; - useEffect(() => { - fetchEvents(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [categoryFilter]); + const [dateFromIso, setDateFromIso] = useState(defaultIsoDateFrom()); + const [dateToIso, setDateToIso] = useState(defaultIsoDateTo()); - const fetchEvents = async () => { + // Pattern B1 click-next replace state — backend pagination + const [continuation, setContinuation] = useState(null); + const [nextLink, setNextLink] = useState(null); + const [continuationStack, setContinuationStack] = useState>([]); + const [pageNumber, setPageNumber] = useState(1); + + const fetchEvents = useCallback(async (cursor: string | null) => { try { setLoading(true); const res = await authenticatedFetch( - api.opsEvents.list(categoryFilter || undefined), + api.opsEvents.list(categoryFilter || undefined, { + dateFrom: dateFromIso, + dateTo: dateToIso, + pageSize: PAGE_SIZE, + continuation: cursor ?? undefined, + }), getAccessToken ); if (res.status === 404) { setEvents([]); + setNextLink(null); return; } if (!res.ok) throw new Error(`Failed to load ops events: ${res.statusText}`); const data = await res.json(); setEvents(data.events ?? []); - setCurrentPage(0); + setNextLink(data.nextLink ?? null); } catch (err) { if (err instanceof TokenExpiredError) { console.error("Session expired while loading ops events"); @@ -133,9 +166,38 @@ export function OpsEventsSection({ } finally { setLoading(false); } + }, [categoryFilter, dateFromIso, dateToIso, getAccessToken, setError]); + + // Reset pagination + refetch whenever the filter window or category changes. + useEffect(() => { + setContinuation(null); + setContinuationStack([]); + setPageNumber(1); + fetchEvents(null); + }, [categoryFilter, dateFromIso, dateToIso, fetchEvents]); + + const handleNextPage = () => { + const nextCont = extractContinuation(nextLink); + if (!nextCont) return; + setContinuationStack(stack => [...stack, continuation]); + setContinuation(nextCont); + setPageNumber(n => n + 1); + fetchEvents(nextCont); }; - // Search filter (client-side across key fields) + const handlePrevPage = () => { + if (continuationStack.length === 0) return; + const prev = continuationStack[continuationStack.length - 1]; + setContinuationStack(stack => stack.slice(0, -1)); + setContinuation(prev ?? null); + setPageNumber(n => Math.max(1, n - 1)); + fetchEvents(prev ?? null); + }; + + const handleRefresh = () => fetchEvents(continuation); + + // Search filter operates on the current backend page only — Pattern B1 shows + // one page at a time, so cross-page totals are intentionally not surfaced. const filteredEvents = searchQuery.trim() ? events.filter((e) => { const q = searchQuery.toLowerCase(); @@ -191,7 +253,7 @@ export function OpsEventsSection({ Configure Alerts - - {currentPage + 1} / {Math.ceil(filteredEvents.length / pageSize)} - - - + {/* Pagination (Pattern B1 — backend-driven) */} +
+ + {filteredEvents.length} on page {pageNumber}{searchQuery && ` (filtered)`}{nextLink ? "" : " (last)"} + +
+ +
- )} +
)} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index 85ae3eba..09cab751 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -1,10 +1,14 @@ "use client"; -import { useState, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation } from "@/lib/paginationLink"; +import { isGuid } from "@/utils/inputValidation"; import { trackEvent } from "@/lib/appInsights"; +const PAGE_SIZE = 50; + interface SessionReport { reportId: string; tenantId: string; @@ -57,17 +61,18 @@ export function SessionReportsSection({ const [reports, setReports] = useState([]); const [loading, setLoading] = useState(false); const [selectedReport, setSelectedReport] = useState(null); - const [currentPage, setCurrentPage] = useState(0); - const pageSize = 5; const [downloadingBlob, setDownloadingBlob] = useState(null); const [adminNoteValue, setAdminNoteValue] = useState(""); const [savingNote, setSavingNote] = useState(false); const [noteSaveResult, setNoteSaveResult] = useState<"saved" | string | null>(null); - useEffect(() => { - fetchReports(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // Pattern B1 click-next replace state — backend pagination + const [tenantFilterInput, setTenantFilterInput] = useState(""); + const [tenantFilterApplied, setTenantFilterApplied] = useState(undefined); + const [continuation, setContinuation] = useState(null); + const [nextLink, setNextLink] = useState(null); + const [continuationStack, setContinuationStack] = useState>([]); + const [pageNumber, setPageNumber] = useState(1); useEffect(() => { if (selectedReport) { @@ -135,20 +140,29 @@ export function SessionReportsSection({ } }; - const fetchReports = async () => { + const fetchReports = useCallback(async (cursor: string | null, filterTenantId: string | undefined) => { try { setLoading(true); - const res = await authenticatedFetch(api.reports.list(), getAccessToken); + const res = await authenticatedFetch( + api.reports.list({ + tenantId: filterTenantId, + pageSize: PAGE_SIZE, + continuation: cursor ?? undefined, + }), + getAccessToken, + ); if (res.status === 404) { // Table/container doesn't exist yet — no reports submitted so far setReports([]); + setNextLink(null); return; } if (!res.ok) throw new Error(`Failed to load reports: ${res.statusText}`); const data = await res.json(); setReports(data.reports ?? []); + setNextLink(data.nextLink ?? null); } catch (err) { if (err instanceof TokenExpiredError) { console.error("Session expired while loading reports"); @@ -157,8 +171,51 @@ export function SessionReportsSection({ } finally { setLoading(false); } + }, [getAccessToken, setError]); + + // Initial load + reload whenever the applied tenant filter changes. + useEffect(() => { + setContinuation(null); + setContinuationStack([]); + setPageNumber(1); + fetchReports(null, tenantFilterApplied); + }, [tenantFilterApplied, fetchReports]); + + const handleApplyTenantFilter = () => { + const trimmed = tenantFilterInput.trim(); + if (trimmed && !isGuid(trimmed)) { + setError("Tenant ID must be a valid GUID"); + return; + } + setError(null); + setTenantFilterApplied(trimmed || undefined); }; + const handleClearTenantFilter = () => { + setTenantFilterInput(""); + setTenantFilterApplied(undefined); + }; + + const handleNextPage = () => { + const nextCont = extractContinuation(nextLink); + if (!nextCont) return; + setContinuationStack(stack => [...stack, continuation]); + setContinuation(nextCont); + setPageNumber(n => n + 1); + fetchReports(nextCont, tenantFilterApplied); + }; + + const handlePrevPage = () => { + if (continuationStack.length === 0) return; + const prev = continuationStack[continuationStack.length - 1]; + setContinuationStack(stack => stack.slice(0, -1)); + setContinuation(prev ?? null); + setPageNumber(n => Math.max(1, n - 1)); + fetchReports(prev ?? null, tenantFilterApplied); + }; + + const handleRefresh = () => fetchReports(continuation, tenantFilterApplied); + return (
{/* Section Header */} @@ -178,8 +235,51 @@ export function SessionReportsSection({
+ {/* Tenant filter + refresh */} +
+ Filter: + setTenantFilterInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleApplyTenantFilter(); + }} + className="w-72 px-2 py-1 text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" + /> + + {tenantFilterApplied && ( + + )} + {tenantFilterApplied && ( + + scoped to {tenantFilterApplied.slice(0, 8)}… + + )} + +
+ {/* Reports Table */} -
+
{loading ? (
@@ -206,7 +306,7 @@ export function SessionReportsSection({ - {reports.slice(currentPage * pageSize, (currentPage + 1) * pageSize).map(r => ( + {reports.map(r => ( setSelectedReport(r)} @@ -244,33 +344,28 @@ export function SessionReportsSection({ - {/* Pagination */} - {reports.length > pageSize && ( -
- - {currentPage * pageSize + 1}–{Math.min((currentPage + 1) * pageSize, reports.length)} of {reports.length} - -
- - - {currentPage + 1} / {Math.ceil(reports.length / pageSize)} - - -
+ {/* Pagination (Pattern B1 — backend-driven) */} +
+ + {reports.length} on page {pageNumber}{nextLink ? "" : " (last)"} + +
+ +
- )} +
)}
diff --git a/src/Web/autopilot-monitor-web/app/audit/page.tsx b/src/Web/autopilot-monitor-web/app/audit/page.tsx index 45d4eba4..f8cb53e2 100644 --- a/src/Web/autopilot-monitor-web/app/audit/page.tsx +++ b/src/Web/autopilot-monitor-web/app/audit/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useEffect, useState } from 'react'; +import { Fragment, useCallback, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { useTenant } from '../../contexts/TenantContext'; import { useAuth } from '../../contexts/AuthContext'; @@ -8,6 +8,7 @@ import { useNotifications } from '../../contexts/NotificationContext'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { api } from '@/lib/api'; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation } from "@/lib/paginationLink"; import { useAdminMode } from "@/hooks/useAdminMode"; @@ -25,12 +26,42 @@ interface AuditLogEntry { type ActionFilter = 'ALL' | 'DELETE' | 'UPDATE' | 'CREATE'; type EntityTypeFilter = string; +const PAGE_SIZE = 50; + +function defaultIsoDateFrom(): string { + // 30 days ago, ISO 8601 UTC, midnight — matches the backend's default + // window so first paint without a manual filter shows the same data set. + const d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + d.setUTCHours(0, 0, 0, 0); + return d.toISOString(); +} + +function defaultIsoDateTo(): string { + return new Date().toISOString(); +} + +function isoToDateInputValue(iso: string): string { + // wants yyyy-MM-dd — we display the date portion of UTC. + return iso.slice(0, 10); +} + +function dateInputToIsoStart(value: string): string { + // Treat the picker as a UTC midnight on that calendar date. + return value ? `${value}T00:00:00.000Z` : ''; +} + +function dateInputToIsoEnd(value: string): string { + // Inclusive upper bound: end of that calendar day in UTC. + return value ? `${value}T23:59:59.999Z` : ''; +} + export default function AuditPage() { const router = useRouter(); const { tenantId } = useTenant(); const { getAccessToken } = useAuth(); const { addNotification } = useNotifications(); + const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -38,35 +69,45 @@ export default function AuditPage() { const [actionFilter, setActionFilter] = useState('ALL'); const [entityTypeFilter, setEntityTypeFilter] = useState('ALL'); const [expandedRow, setExpandedRow] = useState(null); - const [currentPage, setCurrentPage] = useState(1); - const logsPerPage = 12; + + // Date window — both default to the last 30 days (matches backend default). + const [dateFromIso, setDateFromIso] = useState(defaultIsoDateFrom()); + const [dateToIso, setDateToIso] = useState(defaultIsoDateTo()); + + // Pattern B1 click-next replace state + const [continuation, setContinuation] = useState(null); + const [nextLink, setNextLink] = useState(null); + const [continuationStack, setContinuationStack] = useState>([]); + const [pageNumber, setPageNumber] = useState(1); const { globalAdminMode } = useAdminMode(); - const fetchAuditLogs = async (showRefreshing = false) => { + const fetchPage = useCallback(async ( + nextContinuation: string | null, + isInitial: boolean, + ) => { try { - if (showRefreshing) { - setRefreshing(true); - } else { - setLoading(true); - } + if (isInitial) setLoading(true); else setRefreshing(true); - const endpoint = globalAdminMode - ? api.audit.globalLogs() - : api.audit.logs(); + const opts = { + dateFrom: dateFromIso, + dateTo: dateToIso, + pageSize: PAGE_SIZE, + continuation: nextContinuation ?? undefined, + }; + const endpoint = globalAdminMode ? api.audit.globalLogs(opts) : api.audit.logs(opts); const response = await authenticatedFetch(endpoint, getAccessToken); - if (!response.ok) { addNotification('error', 'Backend Error', `Failed to load audit logs: ${response.statusText}`, 'audit-fetch-error'); return; } - const data = await response.json(); - if (data.success) { - setLogs(data.logs || []); - } else { + if (!data.success) { addNotification('error', 'Backend Error', data.message || 'Failed to load audit logs', 'audit-fetch-error'); + return; } + setLogs(data.logs || []); + setNextLink(data.nextLink ?? null); } catch (err) { if (err instanceof TokenExpiredError) { addNotification('error', 'Session Expired', err.message, 'session-expired-error'); @@ -78,15 +119,42 @@ export default function AuditPage() { setLoading(false); setRefreshing(false); } - }; + }, [addNotification, dateFromIso, dateToIso, getAccessToken, globalAdminMode]); + // Initial / window-change fetch resets pagination state. useEffect(() => { if (!globalAdminMode && !tenantId) return; - fetchAuditLogs(); - }, [tenantId]); + setContinuation(null); + setContinuationStack([]); + setPageNumber(1); + fetchPage(null, true); + }, [tenantId, globalAdminMode, dateFromIso, dateToIso, fetchPage]); + const handleRefresh = () => { + fetchPage(continuation, false); + }; + + const handleNextPage = () => { + const nextCont = extractContinuation(nextLink); + if (!nextCont) return; + setContinuationStack(stack => [...stack, continuation]); + setContinuation(nextCont); + setPageNumber(n => n + 1); + fetchPage(nextCont, false); + }; + + const handlePrevPage = () => { + if (continuationStack.length === 0) return; + const prev = continuationStack[continuationStack.length - 1]; + setContinuationStack(stack => stack.slice(0, -1)); + setContinuation(prev ?? null); + setPageNumber(n => Math.max(1, n - 1)); + fetchPage(prev ?? null, false); + }; + + // Client-side filters operate on the current page only — Pattern B1 shows one + // backend page at a time, so global counts are intentionally not surfaced. const entityTypes = ['ALL', ...Array.from(new Set(logs.map(l => l.entityType).filter(Boolean)))]; - const filteredLogs = logs.filter(log => { if (actionFilter !== 'ALL' && log.action !== actionFilter) return false; if (entityTypeFilter !== 'ALL' && log.entityType !== entityTypeFilter) return false; @@ -103,23 +171,6 @@ export default function AuditPage() { return true; }); - // Reset to page 1 when filters change - useEffect(() => { - setCurrentPage(1); - }, [searchQuery, actionFilter, entityTypeFilter]); - - const totalPages = Math.ceil(filteredLogs.length / logsPerPage); - const startIndex = (currentPage - 1) * logsPerPage; - const paginatedLogs = filteredLogs.slice(startIndex, startIndex + logsPerPage); - - const handlePreviousPage = () => { - setCurrentPage(prev => Math.max(1, prev - 1)); - }; - - const handleNextPage = () => { - setCurrentPage(prev => Math.min(totalPages, prev + 1)); - }; - const formatTimestamp = (timestamp: string) => { const date = new Date(timestamp); return date.toLocaleString(); @@ -177,13 +228,13 @@ export default function AuditPage() {

Audit Log

- {filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} - {filteredLogs.length !== logs.length && ` (of ${logs.length} total)`} + {filteredLogs.length} {filteredLogs.length === 1 ? 'entry' : 'entries'} on this page + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`}

- -
+ {/* Pagination Controls (Pattern B1) */} +
+
+ Page {pageNumber} + {nextLink ? '' : ' (last)'}
- )} +
+ + +
+
diff --git a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts index c3424c84..883436a4 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts +++ b/src/Web/autopilot-monitor-web/app/dashboard/hooks/useDashboardSessions.ts @@ -3,10 +3,14 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation } from "@/lib/paginationLink"; import { asGuidOrUndefined } from "@/utils/inputValidation"; import type { NotificationType } from "@/contexts/NotificationContext"; import type { Session } from "../types"; +const DEFAULT_PAGE_SIZE = 10; +const MAX_PAGE_SIZE = 1000; + type AddNotification = ( type: NotificationType, title: string, @@ -81,7 +85,7 @@ export function useDashboardSessions({ const [loading, setLoading] = useState(true); const [hasMore, setHasMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false); - const [cursor, setCursor] = useState(null); + const [continuation, setContinuation] = useState(null); // Refs for SignalR handlers to access current filter state without restarting subscriptions const tenantIdFilterRef = useRef(tenantIdFilter); @@ -95,8 +99,8 @@ export function useDashboardSessions({ // always see current filter values without forcing dependency-driven recreation) const adminModeRef = useRef(adminMode); adminModeRef.current = adminMode; - const cursorRef = useRef(cursor); - cursorRef.current = cursor; + const continuationRef = useRef(continuation); + continuationRef.current = continuation; const loadingMoreRef = useRef(loadingMore); loadingMoreRef.current = loadingMore; @@ -157,41 +161,44 @@ export function useDashboardSessions({ } }, [getAccessToken, addNotification, setBlockedDevicesSet]); - const getInitialLimit = (): number | undefined => { - if (typeof window === "undefined") return undefined; + const getInitialPageSize = (): number => { + // Pattern B2 default first-paint pageSize is 10; localStorage may override + // (legacy "sessionsPerPage" key). Cap to backend MAX_PAGE_SIZE. + if (typeof window === "undefined") return DEFAULT_PAGE_SIZE; const stored = window.localStorage.getItem("sessionsPerPage"); const parsed = stored ? parseInt(stored, 10) : NaN; - const value = Number.isFinite(parsed) && parsed > 0 ? parsed : 10; - return Math.min(value, 100); + const value = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PAGE_SIZE; + return Math.min(value, MAX_PAGE_SIZE); }; // Internal batch fetcher — returns data without touching state so callers can - // decide how to apply it (single append vs. progressive loop). + // decide how to apply it (single append vs. progressive loop). For first-page + // calls we use getInitialPageSize() (Pattern B2: typically 10). For load-more + // we keep the same pageSize so the user-visible cadence is uniform. const fetchSessionsBatch = useCallback(async ( - loadMoreCursor?: string, + loadMoreContinuation?: string, globalTenantIdOverride?: string, - ): Promise<{ sessions: Session[]; hasMore: boolean; cursor: string | null } | null> => { + ): Promise<{ sessions: Session[]; hasMore: boolean; nextContinuation: string | null } | null> => { try { const rawFilter = globalTenantIdOverride !== undefined ? globalTenantIdOverride : tenantIdFilterRef.current.trim(); const effectiveTenantFilter = asGuidOrUndefined(rawFilter); - const initialLimit = loadMoreCursor ? undefined : getInitialLimit(); - let endpoint = globalAdminModeRef.current - ? api.globalSessions.list(effectiveTenantFilter, undefined, initialLimit) - : api.sessions.list(tenantIdRef.current ?? undefined, undefined, initialLimit); - - if (loadMoreCursor) { - endpoint += endpoint.includes("?") ? "&" : "?"; - endpoint += `cursor=${encodeURIComponent(loadMoreCursor)}`; - } + const pageSize = getInitialPageSize(); + const opts = loadMoreContinuation + ? { pageSize, continuation: loadMoreContinuation } + : { pageSize }; + const endpoint = globalAdminModeRef.current + ? api.globalSessions.list(effectiveTenantFilter, undefined, opts) + : api.sessions.list(tenantIdRef.current ?? undefined, undefined, opts); const response = await authenticatedFetch(endpoint, getAccessToken); if (response.ok) { const data = await response.json(); + const nextContinuation = extractContinuation(data.nextLink); return { sessions: data.sessions || [], - hasMore: data.hasMore || false, - cursor: data.cursor || null, + hasMore: !!nextContinuation, + nextContinuation, }; } else { addNotification("error", "Backend Error", `Failed to fetch sessions: ${response.statusText}`, "backend-error"); @@ -214,18 +221,18 @@ export function useDashboardSessions({ }, [getAccessToken, addNotification]); // High-level fetch that applies result to state (initial load + single load-more). - const fetchSessions = useCallback(async (loadMoreCursor?: string, globalTenantIdOverride?: string) => { - const result = await fetchSessionsBatch(loadMoreCursor, globalTenantIdOverride); + const fetchSessions = useCallback(async (loadMoreContinuation?: string, globalTenantIdOverride?: string) => { + const result = await fetchSessionsBatch(loadMoreContinuation, globalTenantIdOverride); if (result) { - if (loadMoreCursor) { + if (loadMoreContinuation) { setSessions((prev) => [...prev, ...result.sessions]); } else { setSessions(result.sessions); fetchBlockedDevices(result.sessions); } setHasMore(result.hasMore); - setCursor(result.cursor); + setContinuation(result.nextContinuation); } setLoading(false); @@ -245,10 +252,10 @@ export function useDashboardSessions({ }, [fetchSessions]); const loadMore = useCallback(() => { - if (!cursorRef.current || fetchLockRef.current) return; + if (!continuationRef.current || fetchLockRef.current) return; fetchLockRef.current = true; setLoadingMore(true); - fetchSessions(cursorRef.current).finally(() => { + fetchSessions(continuationRef.current).finally(() => { fetchLockRef.current = false; }); }, [fetchSessions]); @@ -256,20 +263,20 @@ export function useDashboardSessions({ // Progressive loader — fetches ALL remaining sessions batch by batch. // Used when search is active and local results are insufficient. const loadAll = useCallback(async () => { - if (!cursorRef.current || fetchLockRef.current) return; + if (!continuationRef.current || fetchLockRef.current) return; fetchLockRef.current = true; const myToken = ++loadAllTokenRef.current; setLoadingMore(true); try { - let currentCursor: string | null = cursorRef.current; - while (currentCursor && loadAllTokenRef.current === myToken) { - const result = await fetchSessionsBatch(currentCursor); + let currentContinuation: string | null = continuationRef.current; + while (currentContinuation && loadAllTokenRef.current === myToken) { + const result = await fetchSessionsBatch(currentContinuation); if (!result || loadAllTokenRef.current !== myToken) break; setSessions((prev) => [...prev, ...result.sessions]); - setCursor(result.cursor); + setContinuation(result.nextContinuation); setHasMore(result.hasMore); - currentCursor = result.hasMore ? result.cursor : null; + currentContinuation = result.hasMore ? result.nextContinuation : null; } } finally { fetchLockRef.current = false; @@ -421,7 +428,7 @@ export function useDashboardSessions({ } setSessions([]); - setCursor(null); + setContinuation(null); setLoading(true); fetchSessions(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index dc0a0443..ce3be228 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -9,10 +9,13 @@ import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation, MAX_EAGER_PAGES } from "@/lib/paginationLink"; import FleetStatCard from "./components/FleetStatCard"; import { Session } from "@/types"; import { useAdminMode } from "@/hooks/useAdminMode"; +const FLEET_PAGE_SIZE = 200; + interface AppMetric { appName: string; totalInstalls: number; @@ -174,15 +177,41 @@ export default function FleetHealthPage() { const fetchSessions = async (range: "7d" | "30d" | "90d" = timeRange) => { try { const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const endpoint = isGlobalAdmin - ? api.globalSessions.list(selectedTenantId || undefined, days) - : api.sessions.list(tenantId, days); - const response = await authenticatedFetch(endpoint, getAccessToken); - if (response.ok) { - const data = await response.json(); - setSessions(data.sessions || []); - } else { - addNotification('error', 'Backend Error', `Failed to load sessions: ${response.statusText}`, 'fleet-health-sessions-error'); + // Pattern A — progressive eager fetch: render the first batch quickly, then + // drain remaining pages in the background so fleet stats reflect the full + // window even when the tenant has thousands of sessions. The previous + // single-call (default 100) silently truncated busy installations. + const opts = (continuation?: string) => ({ pageSize: FLEET_PAGE_SIZE, continuation }); + const buildUrl = (continuation?: string) => isGlobalAdmin + ? api.globalSessions.list(selectedTenantId || undefined, days, opts(continuation)) + : api.sessions.list(tenantId, days, opts(continuation)); + + const firstResponse = await authenticatedFetch(buildUrl(), getAccessToken); + if (!firstResponse.ok) { + addNotification('error', 'Backend Error', `Failed to load sessions: ${firstResponse.statusText}`, 'fleet-health-sessions-error'); + return; + } + const firstData = await firstResponse.json(); + const firstBatch: Session[] = firstData.sessions || []; + setSessions(firstBatch); + setLoading(false); + + let nextContinuation = extractContinuation(firstData.nextLink); + let pages = 1; + while (nextContinuation && pages < MAX_EAGER_PAGES) { + const resp = await authenticatedFetch(buildUrl(nextContinuation), getAccessToken); + if (!resp.ok) { + console.warn(`[FleetHealth] eager-fetch stopped at page ${pages + 1} (status=${resp.status})`); + break; + } + const pageData = await resp.json(); + const batch: Session[] = pageData.sessions || []; + if (batch.length > 0) setSessions(prev => prev.concat(batch)); + nextContinuation = extractContinuation(pageData.nextLink); + pages++; + } + if (pages >= MAX_EAGER_PAGES) { + console.warn(`[FleetHealth] eager-fetch hit MAX_EAGER_PAGES=${MAX_EAGER_PAGES}; remaining sessions not loaded`); } } catch (error) { if (error instanceof TokenExpiredError) { diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts index 9e187ced..21412a60 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/hooks/useSessionEvents.ts @@ -3,10 +3,13 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { extractContinuation, MAX_EAGER_PAGES } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; import { EnrollmentEvent, Session } from "@/types"; import type { NotificationType } from "@/contexts/NotificationContext"; +const TIMELINE_PAGE_SIZE = 200; + type AddNotification = ( type: NotificationType, title: string, @@ -33,6 +36,12 @@ export interface UseSessionEventsReturn { setEvents: React.Dispatch>; fetchEvents: () => Promise; scheduleFetchEvents: (delayMs?: number) => void; + /** + * True while a Pattern-A eager-fetch is still streaming pages after the first + * batch has rendered. Surfaces a "loading more events…" indicator on the + * timeline page without delaying first paint. + */ + isStreamingMore: boolean; } /** @@ -58,6 +67,7 @@ export function useSessionEvents({ addNotification, }: UseSessionEventsParams): UseSessionEventsReturn { const [events, setEvents] = useState([]); + const [isStreamingMore, setIsStreamingMore] = useState(false); // Debounce real-time event refreshes to avoid burst reads in Table Storage. const eventRefreshTimeoutRef = useRef(null); @@ -79,47 +89,96 @@ export function useSessionEvents({ fetchEventsInFlight.current = true; fetchEventsQueued.current = false; + // Pattern A — progressive eager fetch: + // 1. Fetch the first page (pageSize=200), render immediately so the user sees + // the timeline populate within milliseconds even on huge sessions. + // 2. If a nextLink is returned, keep fetching pages in the background and + // append to the rendered list. Surface isStreamingMore so the page can + // show a small "loading more events…" indicator while batches stream. + // 3. The merged list is sorted by Sequence at render time (eventsByPhase + // useMemo) — cross-page Azure-Tables row-key order is therefore irrelevant. + let aborted = false; try { - const response = await authenticatedFetch( - api.sessions.events(sessionId, effectiveTenantId), - getAccessToken - ); - if (response.ok) { - const data = await response.json(); - // Replace entire event list with fresh data from Table Storage (canonical truth). - // No merge needed — fetchEvents() is the single source; SignalR only signals. - // phaseName is computed at render time in eventsByPhase useMemo (no race condition). - const fetchedEvents = Array.isArray(data.events) ? data.events : []; - setEvents(prevEvents => { - // Keep the last known-good snapshot if backend transiently returns an empty list. - if (fetchedEvents.length === 0 && prevEvents.length > 0) { - console.warn( - `[SessionDetail] Ignoring empty event refresh for session ${sessionIdRef.current} (tenant ${effectiveTenantId}); keeping ${prevEvents.length} cached events` - ); - return prevEvents; - } - return fetchedEvents; - }); + const firstPageUrl = api.sessions.events(sessionId, effectiveTenantId, { + pageSize: TIMELINE_PAGE_SIZE, + }); + const firstResponse = await authenticatedFetch(firstPageUrl, getAccessToken); + if (!firstResponse.ok) { + addNotification('error', 'Backend Error', `Failed to load session events: ${firstResponse.statusText}`, 'session-events-fetch-error'); + return; + } + const firstData = await firstResponse.json(); + const firstBatch: EnrollmentEvent[] = Array.isArray(firstData.events) ? firstData.events : []; - // Surgical status-stale detection: if events contain a terminal event but session - // status is still InProgress, the SignalR status delta was likely lost. Refetch - // session details from the DB (single read, only when truly needed). - const currentStatus = sessionRef.current?.status; - if (currentStatus && currentStatus !== "Succeeded" && currentStatus !== "Failed") { - const hasTerminalEvent = fetchedEvents.some( - (e: EnrollmentEvent) => e.eventType === "enrollment_complete" || e.eventType === "enrollment_failed" + setEvents(prevEvents => { + // Keep the last known-good snapshot if backend transiently returns an empty list + // AND there is no nextLink to follow (avoids wiping a populated timeline). + if (firstBatch.length === 0 && prevEvents.length > 0 && !firstData.nextLink) { + console.warn( + `[SessionDetail] Ignoring empty event refresh for session ${sessionIdRef.current} (tenant ${effectiveTenantId}); keeping ${prevEvents.length} cached events` ); - if (hasTerminalEvent) { - console.info( - `[SessionDetail] Terminal event detected but session status is '${currentStatus}' — refetching session details` - ); - fetchSessionDetails(); - } + return prevEvents; } - } else { - addNotification('error', 'Backend Error', `Failed to load session events: ${response.statusText}`, 'session-events-fetch-error'); + return firstBatch; + }); + + // Trigger an early loading=false now: first paint is unblocked. + setLoading(false); + + // Surgical status-stale detection: if any event in the merged stream is + // terminal but the session is still InProgress, the SignalR status delta + // was likely lost — refetch session details. We check incrementally + // across every page (not only firstBatch) because for sessions with + // >TIMELINE_PAGE_SIZE events the terminal event lives on page 2+, and a + // first-batch-only check would miss it entirely. + const isTerminalEvent = (e: EnrollmentEvent) => + e.eventType === "enrollment_complete" || e.eventType === "enrollment_failed"; + let foundTerminalEvent = firstBatch.some(isTerminalEvent); + + let continuation = extractContinuation(firstData.nextLink); + if (continuation) { + setIsStreamingMore(true); + let pagesFetched = 1; + while (continuation && pagesFetched < MAX_EAGER_PAGES) { + const url = api.sessions.events(sessionId, effectiveTenantId, { + pageSize: TIMELINE_PAGE_SIZE, + continuation, + }); + const resp = await authenticatedFetch(url, getAccessToken); + if (!resp.ok) { + console.warn( + `[SessionDetail] eager-fetch stopped at page ${pagesFetched + 1} (status=${resp.status})`, + ); + break; + } + const pageData = await resp.json(); + const batch: EnrollmentEvent[] = Array.isArray(pageData.events) ? pageData.events : []; + if (batch.length > 0) { + setEvents(prev => prev.concat(batch)); + if (!foundTerminalEvent && batch.some(isTerminalEvent)) { + foundTerminalEvent = true; + } + } + continuation = extractContinuation(pageData.nextLink); + pagesFetched++; + } + if (pagesFetched >= MAX_EAGER_PAGES) { + console.warn( + `[SessionDetail] eager-fetch hit MAX_EAGER_PAGES=${MAX_EAGER_PAGES}; remaining events not loaded`, + ); + } + setIsStreamingMore(false); + } + + const currentStatus = sessionRef.current?.status; + if (foundTerminalEvent && currentStatus && currentStatus !== "Succeeded" && currentStatus !== "Failed") { + console.info( + `[SessionDetail] Terminal event detected but session status is '${currentStatus}' — refetching session details` + ); + fetchSessionDetails(); } } catch (error) { + aborted = true; if (error instanceof TokenExpiredError) { addNotification('error', 'Session Expired', error.message, 'session-expired-error'); } else { @@ -128,6 +187,7 @@ export function useSessionEvents({ } } finally { setLoading(false); + if (aborted) setIsStreamingMore(false); fetchEventsInFlight.current = false; // If another fetch was requested while we were in flight, run it now @@ -175,5 +235,5 @@ export function useSessionEvents({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, sessionTenantId, isConnected]); - return { events, setEvents, fetchEvents, scheduleFetchEvents }; + return { events, setEvents, fetchEvents, scheduleFetchEvents, isStreamingMore }; } diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx index 4ae8f1ac..5c0da7c7 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/page.tsx @@ -588,6 +588,19 @@ export default function SessionDetailPage() { {/* Event Timeline (with severity filters, expand/collapse, WhiteGlove split) */}
+ {eventsApi.isStreamingMore && ( +
+ + + + + Loading more events… +
+ )} { + it("returns null for empty input", () => { + expect(extractContinuation(null)).toBeNull(); + expect(extractContinuation(undefined)).toBeNull(); + expect(extractContinuation("")).toBeNull(); + }); + + it("extracts the continuation param from a relative nextLink", () => { + const link = + "/api/sessions/abc/events?pageSize=200&continuation=eyJ0IjoiVE9LRU4ifQ&tenantId=tid"; + expect(extractContinuation(link)).toBe("eyJ0IjoiVE9LRU4ifQ"); + }); + + it("extracts the continuation param from an absolute nextLink", () => { + const link = + "https://api.example.com/api/sessions/abc/events?pageSize=200&continuation=NEXT&tenantId=tid"; + expect(extractContinuation(link)).toBe("NEXT"); + }); + + it("returns null when nextLink has no continuation param", () => { + expect(extractContinuation("/api/sessions/abc/events?pageSize=200")).toBeNull(); + }); + + it("returns null for malformed input", () => { + // URL constructor with placeholder origin actually accepts most strings — the + // fallback path is hit only when it really cannot parse anything sensible. + expect(extractContinuation("https://[invalid")).toBeNull(); + }); +}); + +describe("MAX_EAGER_PAGES", () => { + it("provides a sane upper bound on the eager-fetch loop", () => { + // The bound primarily guards against accidental backend-pagination cycles. + // 200 pages × 200 events = 40k events on a single timeline — beyond which + // the UI would be unresponsive anyway and we want the loop to bail out. + expect(MAX_EAGER_PAGES).toBeGreaterThanOrEqual(50); + expect(MAX_EAGER_PAGES).toBeLessThanOrEqual(1000); + }); +}); + +// ---------------------------------------------------------------------------- +// Pattern-A eager-fetch loop semantics — exercised against a stand-in fetcher +// so the test does not depend on the React hook plumbing. The contract under +// test is exactly what useSessionEvents enforces: +// 1. First call uses pageSize=200 with no continuation +// 2. While the response carries a nextLink, follow it (extract continuation) +// 3. Append batches to the rendered list +// 4. Stop as soon as nextLink is absent +// 5. Bound by MAX_EAGER_PAGES to prevent runaway loops +// ---------------------------------------------------------------------------- + +interface FakeResponse { + events: Array<{ sequence: number }>; + nextLink: string | null; +} + +async function eagerFetchLoop( + fetcher: (continuation: string | null) => Promise, + maxPages = MAX_EAGER_PAGES, +): Promise<{ events: Array<{ sequence: number }>; pages: number; truncated: boolean }> { + const collected: Array<{ sequence: number }> = []; + let continuation: string | null = null; + let pages = 0; + while (pages < maxPages) { + const data = await fetcher(continuation); + collected.push(...data.events); + pages++; + const next = extractContinuation(data.nextLink); + if (!next) { + return { events: collected, pages, truncated: false }; + } + continuation = next; + } + return { events: collected, pages, truncated: true }; +} + +describe("eager-fetch loop", () => { + it("terminates when nextLink is absent (single-page session)", async () => { + const fetcher = async (): Promise => ({ + events: [{ sequence: 1 }, { sequence: 2 }, { sequence: 3 }], + nextLink: null, + }); + const result = await eagerFetchLoop(fetcher); + expect(result.pages).toBe(1); + expect(result.events).toHaveLength(3); + expect(result.truncated).toBe(false); + }); + + it("follows nextLink across multiple pages and stops when absent", async () => { + const pages: FakeResponse[] = [ + { events: [{ sequence: 1 }, { sequence: 2 }], nextLink: "/x?continuation=p2" }, + { events: [{ sequence: 3 }, { sequence: 4 }], nextLink: "/x?continuation=p3" }, + { events: [{ sequence: 5 }], nextLink: null }, + ]; + let calls = 0; + const fetcher = async (cont: string | null): Promise => { + // continuation must be carried forward correctly + if (calls === 0) expect(cont).toBeNull(); + if (calls === 1) expect(cont).toBe("p2"); + if (calls === 2) expect(cont).toBe("p3"); + return pages[calls++]; + }; + const result = await eagerFetchLoop(fetcher); + expect(result.pages).toBe(3); + expect(result.events.map(e => e.sequence)).toEqual([1, 2, 3, 4, 5]); + expect(result.truncated).toBe(false); + }); + + it("bails out after maxPages when backend would loop forever", async () => { + // Pathological backend that always returns a continuation — defends against + // any future pagination bug that could otherwise hang the timeline page. + const fetcher = async (): Promise => ({ + events: [{ sequence: 0 }], + nextLink: "/x?continuation=loop", + }); + const result = await eagerFetchLoop(fetcher, 5); + expect(result.pages).toBe(5); + expect(result.truncated).toBe(true); + }); + + it("treats empty nextLink string as terminal", async () => { + const fetcher = async (): Promise => ({ + events: [{ sequence: 1 }], + nextLink: "", + }); + const result = await eagerFetchLoop(fetcher); + expect(result.pages).toBe(1); + expect(result.truncated).toBe(false); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/sessionListLoadMore.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/sessionListLoadMore.test.ts new file mode 100644 index 00000000..b41badca --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/sessionListLoadMore.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "vitest"; +import { extractContinuation } from "../paginationLink"; + +/** + * PR 5 (mcp-pagination-rollout) — verifies the Pattern B2 "load more append" + * loop semantics for the dashboard session list. Mirrors the contract + * useDashboardSessions enforces: + * 1. First call uses pageSize=10 with no continuation. + * 2. nextLink in the response carries the continuation cursor. + * 3. Load-More replays with that cursor and APPENDS the new batch. + * 4. No duplicates: each session id appears exactly once across pages. + * 5. Loop ends when nextLink is absent. + */ + +interface FakeSession { + sessionId: string; +} + +interface FakePage { + sessions: FakeSession[]; + nextLink: string | null; +} + +async function loadMoreLoop( + pages: FakePage[], + initialPageSize = 10, +): Promise<{ all: FakeSession[]; calls: number; lastPageSize: number }> { + const all: FakeSession[] = []; + let continuation: string | null = null; + let calls = 0; + let lastPageSize = initialPageSize; + + while (true) { + if (calls >= pages.length) break; + const page = pages[calls]; + calls++; + + if (calls === 1) { + // First call: no continuation expected — just initial pageSize. + lastPageSize = initialPageSize; + } + + // Append (Pattern B2) — never replace. + all.push(...page.sessions); + + const next = extractContinuation(page.nextLink); + if (!next) break; + continuation = next; + void continuation; // captured for clarity; loop end on null + } + + return { all, calls, lastPageSize }; +} + +function makeSession(id: string): FakeSession { + return { sessionId: id }; +} + +describe("dashboard session list — Pattern B2 load-more append", () => { + it("appends batches without duplicating session ids", async () => { + const pages: FakePage[] = [ + { + sessions: ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10"].map(makeSession), + nextLink: "/api/sessions?pageSize=10&continuation=p2", + }, + { + sessions: ["s11", "s12", "s13", "s14", "s15", "s16", "s17", "s18", "s19", "s20"].map(makeSession), + nextLink: "/api/sessions?pageSize=10&continuation=p3", + }, + { + sessions: ["s21", "s22"].map(makeSession), + nextLink: null, + }, + ]; + + const { all, calls } = await loadMoreLoop(pages); + + expect(calls).toBe(3); + expect(all).toHaveLength(22); + const ids = all.map(s => s.sessionId); + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); // no duplicates + expect(ids[0]).toBe("s1"); + expect(ids[ids.length - 1]).toBe("s22"); + }); + + it("terminates immediately when first page has no nextLink", async () => { + const pages: FakePage[] = [ + { + sessions: ["s1", "s2"].map(makeSession), + nextLink: null, + }, + ]; + const { all, calls } = await loadMoreLoop(pages); + expect(calls).toBe(1); + expect(all).toHaveLength(2); + }); + + it("continuation extraction handles relative + absolute nextLinks", () => { + expect( + extractContinuation("/api/sessions?pageSize=10&continuation=cursor-1"), + ).toBe("cursor-1"); + expect( + extractContinuation("https://api.example.com/api/sessions?pageSize=10&continuation=cursor-2"), + ).toBe("cursor-2"); + }); + + it("never reuses the same continuation across pages (avoids infinite loop)", async () => { + // Two pages with distinct continuations; the loop must use page-2's cursor + // when fetching what would be page 3. + const pages: FakePage[] = [ + { + sessions: [makeSession("s1")], + nextLink: "/api/sessions?pageSize=10&continuation=A", + }, + { + sessions: [makeSession("s2")], + nextLink: "/api/sessions?pageSize=10&continuation=B", + }, + { + sessions: [makeSession("s3")], + nextLink: null, + }, + ]; + + const seenContinuations: Array = []; + let continuation: string | null = null; + const collected: FakeSession[] = []; + for (let i = 0; i < pages.length; i++) { + seenContinuations.push(continuation); + collected.push(...pages[i].sessions); + continuation = extractContinuation(pages[i].nextLink); + if (!continuation) break; + } + + expect(seenContinuations).toEqual([null, "A", "B"]); + expect(collected.map(s => s.sessionId)).toEqual(["s1", "s2", "s3"]); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index b05e5d19..f7a90d0f 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -21,12 +21,29 @@ export const api = { // ── Sessions ────────────────────────────────────────────────────────────── sessions: { - list: (tenantId?: string, days?: number, limit?: number) => - `${API_BASE_URL}/api/sessions${qs({ tenantId, days: days?.toString(), limit: limit?.toString() })}`, + list: ( + tenantId?: string, + days?: number, + opts?: { pageSize?: number; continuation?: string }, + ) => + `${API_BASE_URL}/api/sessions${qs({ + tenantId, + days: days?.toString(), + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, get: (sessionId: string, tenantId?: string) => `${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`, - events: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/events${qs({ tenantId })}`, + events: ( + sessionId: string, + tenantId?: string, + opts?: { pageSize?: number; continuation?: string }, + ) => + `${API_BASE_URL}/api/sessions/${sessionId}/events${qs({ + tenantId, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, delete: (sessionId: string, tenantId: string) => `${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`, analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) => @@ -55,8 +72,17 @@ export const api = { // ── Global Sessions (global admin) ──────────────────────────────────────── globalSessions: { - list: (tenantId?: string, days?: number, limit?: number) => - `${API_BASE_URL}/api/global/sessions${qs({ tenantId, days: days?.toString(), limit: limit?.toString() })}`, + list: ( + tenantId?: string, + days?: number, + opts?: { pageSize?: number; continuation?: string }, + ) => + `${API_BASE_URL}/api/global/sessions${qs({ + tenantId, + days: days?.toString(), + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, }, // ── Config ──────────────────────────────────────────────────────────────── @@ -243,7 +269,12 @@ export const api = { // ── Reports ─────────────────────────────────────────────────────────────── reports: { - list: () => `${API_BASE_URL}/api/global/session-reports`, + list: (opts?: { tenantId?: string; pageSize?: number; continuation?: string }) => + `${API_BASE_URL}/api/global/session-reports${qs({ + tenantId: opts?.tenantId, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, downloadUrl: (blobName: string) => `${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`, note: (reportId: string) => @@ -262,8 +293,17 @@ export const api = { // ── Ops Events ─────────────────────────────────────────────────────────── opsEvents: { - list: (category?: string, maxResults?: number) => - `${API_BASE_URL}/api/global/ops-events${qs({ category, maxResults: maxResults?.toString() })}`, + list: ( + category?: string, + opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }, + ) => + `${API_BASE_URL}/api/global/ops-events${qs({ + category, + dateFrom: opts?.dateFrom, + dateTo: opts?.dateTo, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, }, // ── Notifications ───────────────────────────────────────────────────────── @@ -279,8 +319,20 @@ export const api = { // ── Audit ───────────────────────────────────────────────────────────────── audit: { - logs: () => `${API_BASE_URL}/api/audit/logs`, - globalLogs: () => `${API_BASE_URL}/api/global/audit/logs`, + logs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) => + `${API_BASE_URL}/api/audit/logs${qs({ + dateFrom: opts?.dateFrom, + dateTo: opts?.dateTo, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, + globalLogs: (opts?: { dateFrom?: string; dateTo?: string; pageSize?: number; continuation?: string }) => + `${API_BASE_URL}/api/global/audit/logs${qs({ + dateFrom: opts?.dateFrom, + dateTo: opts?.dateTo, + pageSize: opts?.pageSize?.toString(), + continuation: opts?.continuation, + })}`, }, // ── Feedback ────────────────────────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/paginationLink.ts b/src/Web/autopilot-monitor-web/lib/paginationLink.ts new file mode 100644 index 00000000..a4a1fec1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/paginationLink.ts @@ -0,0 +1,41 @@ +/** + * Helpers for following backend pagination "nextLink" URLs returned by paginated + * endpoints (see plan: mcp-pagination-rollout — Pattern A / B1 / B2). + * + * The backend hands the client an absolute-on-host path that already encodes the + * tenantId + opaque continuation token; the UI typically only needs the + * continuation value so it can issue the next call through its existing typed + * url builder. + */ + +export interface PaginatedResponse { + count: number; + nextLink?: string | null; + /** legacy collection key — varies per endpoint (events / sessions / logs / reports) */ + events?: T[]; + sessions?: T[]; + logs?: T[]; + reports?: T[]; +} + +/** + * Extracts the `continuation` query param from a backend-supplied nextLink. + * Returns null when the input is empty or carries no continuation. + * + * Tolerates both absolute and relative URLs — backend may return either. + */ +export function extractContinuation(nextLink: string | null | undefined): string | null { + if (!nextLink) return null; + // URL needs an origin to parse relative paths; the value of the origin is irrelevant + // because we only read the query. + let parsed: URL; + try { + parsed = new URL(nextLink, "https://placeholder.invalid"); + } catch { + return null; + } + return parsed.searchParams.get("continuation"); +} + +/** Maximum pages to follow in a single eager-fetch loop. Defends against runaway loops on the UI. */ +export const MAX_EAGER_PAGES = 200;