feat(backend,web,mcp,shared): MS-Graph nextLink pagination rollout

Lands the planned 6-PR pagination rollout (project_mcp_pagination_rollout)
as one tested batch covering session_events, audit+ops, session_reports,
search_sessions/list, and query_raw_events. Backend builds clean; Backend
1024 / Agent V2 1007 / Web 249 tests green.

Primitives (Shared/Pagination):
- ContinuationToken with fingerprint binding (tenantId, dateFrom/To, filters)
  so a forged or cross-tenant token fails validation on the next page.
- PaginatedResult<T> + RawPage<TEntity> as the canonical wire shapes.

Backend (AutopilotMonitor.Functions/Pagination):
- AzureTablesPaginator drives single-partition + per-partition pages.
- DateWindowPagination sweeps day-buckets for global audit/ops queries
  (the backend resolves "last 30d" once and echoes dateFrom/dateTo on
  nextLink so follow-up calls don't recompute "now" and break the token).
- PerPartitionFanOutMerge stable-merges N partition cursors for cross-
  tenant session search.
- Per-feature plans (SessionEventsPagination, SearchSessionsPagination,
  SearchSessionsByEventPagination, SessionListPagination,
  SessionReportsPagination, QueryRawEventsPagination).
- Functions adopt nextLink: GetSessionEvents, GetAuditLogs (tenant +
  global), GetOpsEvents, GetSessionReports, SearchSessions,
  SearchSessionsByEvent, GetSessions, GetAllSessions, QueryRawEvents.
- Repository contracts (ISessionRepository, IOpsEventRepository,
  INotificationRepository, IMaintenanceRepository) gain *PageAsync
  variants returning PaginatedResult; legacy GetSessionsAsync becomes
  a drain-all helper used by maintenance/feedback/SLA paths that still
  need bounded full-list semantics. SessionPage DTO removed.
- PlatformMetricsService now drains all date-bounded sessions via
  GetAllSessionsAsync (previous maxResults:500 was a silent truncation).

Web:
- lib/paginationLink.ts + ?? api.ts wrappers expose follow-nextLink in
  hooks (useSessionEvents, useDashboardSessions) and pages (audit,
  fleet-health, session detail, OpsEvents, SessionReports admin).
- Session list uses Pattern B2 (load-more append); audit + ops + reports
  use Pattern B1 (click-next replace); session detail timeline keeps
  Pattern A (eager-fetch full set).

MCP:
- followNextLink helper accepts either an opaque continuation OR the
  whole "/api/?" path the backend emitted, and follows the latter
  verbatim so server-resolved query params (tenantId fingerprint,
  resolved dateFrom/dateTo, page size) round-trip correctly. Without
  this, get_audit_logs / get_ops_events / list_session_reports / sessions
  follow-ups would silently fail token validation.
- get_audit_logs, get_ops_events, list_session_reports, and the session
  tools migrate to pageSize + continuation/nextLink, with tool docs
  spelling out the round-trip rule so external MCP clients pass the
  full nextLink instead of stripping it down to "continuation".

Tests:
- New: ContinuationTokenTests, DateWindowPaginationTests,
  PerPartitionFanOutMergeTests, AuditLogRowKeyOrderingTests,
  SessionEventsPaginationTests, SessionListPaginationTests,
  SearchSessionsPaginationTests, SearchSessionsByEventPaginationTests,
  SessionReportsPaginationTests, QueryRawEventsPaginationTests, and
  TestSetup helpers.
- Web: paginationLink.test.ts + sessionListLoadMore.test.ts.
- PlatformMetricsServiceWindowTests adjusted for the drain-all change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-06 20:53:46 +02:00
parent 9a511bac29
commit 70382e991b
69 changed files with 6760 additions and 679 deletions
@@ -1 +1 @@
606
628
@@ -1 +1 @@
1258
1274
@@ -0,0 +1,91 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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).
/// </summary>
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");
}
}
@@ -0,0 +1,299 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Round-trip + tamper + replay tests for the pagination continuation token
/// codec (PR-1 of mcp-pagination-rollout).
/// </summary>
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<KeyValuePair<string, string?>> FilterArgsA() => new[]
{
new KeyValuePair<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("eventType", "enrollment_complete"),
new KeyValuePair<string, string?>("dateFrom", "2026-04-01T00:00:00Z"),
new KeyValuePair<string, string?>("dateTo", "2026-05-01T00:00:00Z"),
};
private static IEnumerable<KeyValuePair<string, string?>> FilterArgsB() => new[]
{
new KeyValuePair<string, string?>("tenantId", TenantB),
new KeyValuePair<string, string?>("eventType", "enrollment_complete"),
new KeyValuePair<string, string?>("dateFrom", "2026-04-01T00:00:00Z"),
new KeyValuePair<string, string?>("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<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("eventType", "enrollment_failed"), // changed
new KeyValuePair<string, string?>("dateFrom", "2026-04-01T00:00:00Z"),
new KeyValuePair<string, string?>("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<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("dateFrom", "2026-04-01T00:00:00Z"),
new KeyValuePair<string, string?>("eventType", "x"),
});
var fp2 = ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("eventType", "x"),
new KeyValuePair<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("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<string, string?>("TenantId", TenantA),
new KeyValuePair<string, string?>("EventType", "x"),
});
var fp2 = ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("tenantid", TenantA),
new KeyValuePair<string, string?>("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<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("eventType", "x"),
new KeyValuePair<string, string?>("dateFrom", null),
new KeyValuePair<string, string?>("dateTo", ""),
});
var withoutOptionals = ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("tenantId", TenantA),
new KeyValuePair<string, string?>("eventType", "x"),
});
Assert.Equal(withOptionals, withoutOptionals);
}
[Fact]
public void ComputeFingerprint_changes_when_value_differs()
{
var a = ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("tenantId", TenantA),
});
var b = ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("tenantId", TenantB),
});
Assert.NotEqual(a, b);
}
[Fact]
public void Encode_throws_on_empty_tenant_or_fingerprint()
{
Assert.Throws<ArgumentException>(() => ContinuationToken.Encode(AzureToken, "", "fp"));
Assert.Throws<ArgumentException>(() => ContinuationToken.Encode(AzureToken, TenantA, ""));
Assert.Throws<ArgumentNullException>(() => 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<string, object>();
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);
}
}
@@ -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;
/// <summary>
/// PR 3 (mcp-pagination-rollout) — date-windowed forensics endpoints
/// (audit logs + ops events). Verifies the shared
/// <see cref="DateWindowPagination"/> helper and its cross-tenant /
/// cross-window guarantees.
/// </summary>
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<string, string?>("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<string, string?>("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<string, string?>("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<string, string?>("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);
}
}
@@ -0,0 +1,278 @@
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.DataAccess;
using static AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// 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.
/// </summary>
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: "<set-by-caller>", ts, id));
}
private static PartitionFetchResult<OpsEventEntry> 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<OpsEventEntry>(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<string, PartitionContinuation>(),
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<string, PartitionContinuation>(),
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<string, PartitionContinuation>(),
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<string, PartitionContinuation>(),
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<string, PartitionContinuation>
{
["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<PartitionFetchResult<OpsEventEntry>> SimulateFetch(
Dictionary<string, PartitionContinuation> continuations, int pageSize)
{
var fetched = new List<PartitionFetchResult<OpsEventEntry>>();
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<OpsEventEntry>(part, rows));
}
return fetched;
}
const int pageSize = 5;
var seen = new List<OpsEventEntry>();
var conts = new Dictionary<string, PartitionContinuation>();
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})");
}
}
}
@@ -16,8 +16,8 @@ public class PlatformMetricsServiceWindowTests
{
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo
.Setup(r => r.GetAllSessionsAsync(It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<int?>()))
.ReturnsAsync(new SessionPage { Sessions = new List<SessionSummary>(), HasMore = false });
.Setup(r => r.GetAllSessionsAsync(It.IsAny<string?>(), It.IsAny<int?>()))
.ReturnsAsync(new List<SessionSummary>());
sessionRepo
.Setup(r => r.GetSessionEventsAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>()))
.ReturnsAsync(new List<EnrollmentEvent>());
@@ -37,9 +37,9 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
.Setup(r => r.GetAllSessionsAsync(It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<int?>()))
.Callback<int, string?, int?>((_, _, d) => capturedDays = d)
.ReturnsAsync(new SessionPage { Sessions = new List<SessionSummary>(), HasMore = false });
.Setup(r => r.GetAllSessionsAsync(It.IsAny<string?>(), It.IsAny<int?>()))
.Callback<string?, int?>((_, d) => capturedDays = d)
.ReturnsAsync(new List<SessionSummary>());
var result = await service.ComputePlatformMetricsAsync(days);
@@ -54,9 +54,9 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
.Setup(r => r.GetAllSessionsAsync(It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<int?>()))
.Callback<int, string?, int?>((_, _, d) => capturedDays = d)
.ReturnsAsync(new SessionPage { Sessions = new List<SessionSummary>(), HasMore = false });
.Setup(r => r.GetAllSessionsAsync(It.IsAny<string?>(), It.IsAny<int?>()))
.Callback<string?, int?>((_, d) => capturedDays = d)
.ReturnsAsync(new List<SessionSummary>());
var result = await service.ComputePlatformMetricsAsync(0);
@@ -0,0 +1,393 @@
using System.Collections.Specialized;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// PR 6 (mcp-pagination-rollout) — pagination wiring on
/// <c>GET /api/raw/events</c> + <c>GET /api/global/raw/events</c>. Replaces the
/// legacy <c>?limit=</c> wire shape and the hard-coded <c>limit:20</c>
/// 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 <c>pageSize</c> stay reachable
/// across multiple <c>nextLink</c> follow-ups.
/// </summary>
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);
}
}
@@ -0,0 +1,183 @@
using System.Collections.Specialized;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pagination wiring on <c>GET /api/search/sessions-by-event</c> +
/// <c>GET /api/global/search/sessions-by-event</c> — closes the gap left after
/// PR-6 where the repo's paged variant existed but the endpoint kept its
/// legacy <c>?limit=</c> max-100 contract.
/// </summary>
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);
}
}
@@ -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;
/// <summary>
/// PR 5 (mcp-pagination-rollout) — pagination wiring on
/// <c>GET /api/search/sessions</c> + <c>GET /api/global/search/sessions</c>.
/// Replaces the legacy <c>?limit=</c> wire shape with the rollout plan's
/// <c>?pageSize=</c> + <c>?continuation=</c> + <c>nextLink</c> contract.
/// </summary>
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<string, string>
{
["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<string, string> { ["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<string, string> { ["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<string, string>
{
["a"] = "1",
["b"] = "2",
},
};
var f2 = new SessionSearchFilter
{
DeviceProperties = new Dictionary<string, string>
{
["b"] = "2",
["a"] = "1",
},
};
Assert.Equal(
SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f1),
SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f2));
}
}
@@ -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;
/// <summary>
/// PR 2 (mcp-pagination-rollout) — pagination wiring on
/// <c>GET /api/sessions/{sessionId}/events</c>.
/// </summary>
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<AutopilotMonitor.Shared.DataAccess.ISessionRepository>(MockBehavior.Strict);
var callIndex = 0;
repo
.Setup(r => r.GetSessionEventsPageAsync(TenantA, SessionA, pageSize, It.IsAny<string?>()))
.ReturnsAsync(() => pages[callIndex++]);
var collected = new List<EnrollmentEvent>();
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<long>(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<RawPage<EnrollmentEvent>> SplitIntoPages(int total, int pageSize)
{
var result = new List<RawPage<EnrollmentEvent>>();
long seq = 1;
var emitted = 0;
while (emitted < total)
{
var take = System.Math.Min(pageSize, total - emitted);
var batch = new List<EnrollmentEvent>(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<EnrollmentEvent>(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null));
}
return result;
}
}
@@ -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;
/// <summary>
/// PR 5 (mcp-pagination-rollout) — pagination wiring on
/// <c>GET /api/sessions</c> + <c>GET /api/global/sessions</c>. Replaces the
/// legacy <c>?limit=</c> + <c>?cursor=</c> wire shape with the rollout plan's
/// <c>?pageSize=</c> + <c>?continuation=</c> + <c>nextLink</c> contract.
/// </summary>
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<AutopilotMonitor.Shared.DataAccess.ISessionRepository>(MockBehavior.Strict);
var callIndex = 0;
repo
.Setup(r => r.GetSessionsPageAsync(TenantA, days, pageSize, It.IsAny<string?>()))
.ReturnsAsync(() => pages[callIndex++]);
var collected = new List<SessionSummary>();
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<string>(System.Linq.Enumerable.Select(collected, s => s.SessionId));
Assert.Equal(totalSessions, ids.Count);
}
private static List<RawPage<SessionSummary>> SplitSessionPages(int total, int pageSize)
{
var result = new List<RawPage<SessionSummary>>();
var emitted = 0;
while (emitted < total)
{
var take = System.Math.Min(pageSize, total - emitted);
var batch = new List<SessionSummary>(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<SessionSummary>(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null));
}
return result;
}
}
@@ -0,0 +1,185 @@
using System.Collections.Specialized;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// PR 4 (mcp-pagination-rollout) — pagination wiring on
/// <c>GET /api/global/session-reports</c>. The endpoint stays GA-only at the
/// policy layer; <c>tenantId</c> here is a server-side <em>filter</em>, 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.
/// </summary>
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);
}
}
@@ -0,0 +1,24 @@
using System.Runtime.CompilerServices;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Test-process-wide setup. Runs once before any test executes (via the
/// <c>[ModuleInitializer]</c> hook) so individual tests don't have to bother
/// with global state — in particular, the ContinuationToken signing key.
/// </summary>
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);
}
}
@@ -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
{
/// <summary>
/// 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).
/// </summary>
/// <remarks>
/// <para>
/// Per-partition continuation anchored on RowKey (not Azure's opaque token):
/// <c>LastRowKey</c> = the oldest RowKey we have already returned for this
/// partition. Next fetch filters <c>RowKey gt LastRowKey</c> — natural
/// ordering within the partition (revtick → newest-first) means this skips
/// exactly what we showed and re-considers everything older.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class PerPartitionFanOutMerge
{
internal sealed record PartitionContinuation(string? LastRowKey, bool Exhausted);
internal sealed record PartitionFetchResult<T>(
string Partition,
IReadOnlyList<(string RowKey, T Item)> Items);
/// <summary>
/// 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.
/// </summary>
public static (List<T> Items, Dictionary<string, PartitionContinuation> NextContinuations)
MergeAndAdvance<T>(
IReadOnlyList<PartitionFetchResult<T>> fetched,
IReadOnlyDictionary<string, PartitionContinuation> priorContinuations,
int pageSize,
Func<T, DateTime> 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<string, PartitionContinuation>(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<string, PartitionContinuation> continuations)
{
var doc = new Dictionary<string, object?>
{
["v"] = 1,
["c"] = continuations.ToDictionary(
kv => kv.Key,
kv => (object?)new Dictionary<string, object?>
{
["rk"] = kv.Value.LastRowKey,
["x"] = kv.Value.Exhausted,
}),
};
var json = JsonSerializer.Serialize(doc);
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
}
public static Dictionary<string, PartitionContinuation> DecodeMultiContinuation(string? raw)
{
var result = new Dictionary<string, PartitionContinuation>(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;
}
}
}
@@ -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<string, string>? details = null)
=> _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details);
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, int maxResults = 100)
=> _storage.GetAuditLogsAsync(tenantId, maxResults);
public Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null)
=> _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo);
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(int maxResults = 100)
=> _storage.GetAllAuditLogsAsync(maxResults);
public Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null)
=> _storage.GetAllAuditLogsAsync(dateFrom, dateTo);
public Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
=> _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation);
public Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
=> _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation);
public Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
=> _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate);
@@ -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<List<SessionReportMetadata>> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50)
public async Task<List<SessionReportMetadata>> GetSessionReportsAsync(string? tenantId = null)
{
var results = new List<SessionReportMetadata>();
var filter = BuildSessionReportFilter(tenantId);
try
{
var count = 0;
await foreach (var entity in _reportsTableClient.QueryAsync<TableEntity>(
filter: $"PartitionKey eq 'reports'"))
await foreach (var entity in _reportsTableClient.QueryAsync<TableEntity>(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<RawPage<SessionReportMetadata>> GetSessionReportsPageAsync(
string? tenantId, int pageSize, string? continuation)
{
if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
try
{
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
client: _reportsTableClient,
filter: BuildSessionReportFilter(tenantId),
pageSize: pageSize,
continuation: continuation);
var page = new List<SessionReportMetadata>(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<SessionReportMetadata>(page, nextRawToken);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogDebug("SessionReports table does not exist yet, returning empty page");
return RawPage<SessionReportMetadata>.Empty;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get session reports page");
return RawPage<SessionReportMetadata>.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<SessionReportMetadata?> GetSessionReportAsync(string reportId)
{
try
@@ -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<List<OpsEventEntry>> GetOpsEventsAsync(int maxResults = 200)
public async Task<List<OpsEventEntry>> GetOpsEventsAsync(
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null)
{
var result = new List<OpsEventEntry>();
var filter = BuildFilter(category, dateFrom, dateTo);
var query = string.IsNullOrEmpty(filter)
? _table.QueryAsync<TableEntity>()
: _table.QueryAsync<TableEntity>(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<TableEntity>(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<List<OpsEventEntry>> GetOpsEventsByCategoryAsync(string category, int maxResults = 100)
public async Task<RawPage<OpsEventEntry>> GetOpsEventsPageAsync(
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
{
var result = new List<OpsEventEntry>();
await foreach (var entity in _table.QueryAsync<TableEntity>(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<TableEntity>(
client: _table,
filter: filter,
pageSize: pageSize,
continuation: continuation);
return result;
var page = new List<OpsEventEntry>(entities.Count);
foreach (var entity in entities) page.Add(MapToEntry(entity));
return new RawPage<OpsEventEntry>(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<OpsEventEntry>.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<RawPage<OpsEventEntry>> 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<OpsEventEntry>(new List<OpsEventEntry>(), 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<TableEntity>(filter: filter, maxPerPage: pageSize))
{
fetched.Add((e.RowKey, MapToEntry(e)));
if (fetched.Count >= pageSize) break;
}
return new PerPartitionFanOutMerge.PartitionFetchResult<OpsEventEntry>(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<OpsEventEntry>(items, nextRawToken);
}
private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
{
var clauses = new List<string>
{
$"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<string>();
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<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff)
{
var deleted = 0;
@@ -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<string?> FindSessionTenantIdAsync(string sessionId)
=> _storage.FindSessionTenantIdAsync(sessionId);
public Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null)
=> _storage.GetSessionsAsync(tenantId, maxResults, cursor, days);
public Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null)
=> _storage.GetSessionsAsync(tenantId, days);
public Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null)
=> _storage.GetAllSessionsAsync(maxResults, cursor, days);
public Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null)
=> _storage.GetAllSessionsAsync(tenantIdFilter, days);
public Task<RawPage<SessionSummary>> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
=> _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
public Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation)
=> _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation);
public Task<bool> DeleteSessionAsync(string tenantId, string sessionId)
=> _storage.DeleteSessionAsync(tenantId, sessionId);
@@ -120,12 +127,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<EnrollmentEvent>> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200)
=> _storage.GetSessionEventsByTypeAsync(tenantId, sessionId, eventType, maxResults);
public Task<RawPage<EnrollmentEvent>> GetSessionEventsPageAsync(string tenantId, string sessionId, int pageSize, string? continuation)
=> _storage.GetSessionEventsPageAsync(tenantId, sessionId, pageSize, continuation);
public Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
public Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
=> _storage.SearchSessionsAsync(tenantId, filter);
public Task<RawPage<SessionSummary>> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
=> _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation);
public Task<RawPage<SessionSummary>> 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<List<SessionSummary>> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity,
string? phase, int limit = 50)
@@ -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)
{
@@ -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)
{
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<OpsEventEntry> 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<string, string?>("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)
{
@@ -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
@@ -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;
}
@@ -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<HttpResponseData> QueryEvents(HttpRequestData req, string? tenantId)
private async Task<HttpResponseData> 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<EnrollmentEvent> 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<EnrollmentEvent>();
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<EnrollmentEvent>();
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<EnrollmentEvent> ApplyClientFilters(
List<EnrollmentEvent> 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<EnrollmentEvent> ApplyDateFilters(
List<EnrollmentEvent> 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;
}
}
}
@@ -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)
{
@@ -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<object>()
});
@@ -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)
@@ -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<object>()
});
@@ -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)
@@ -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<string, string>();
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<string, string>();
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;
}
}
@@ -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
{
/// <summary>
/// Thin wrapper around <see cref="TableClient.QueryAsync{T}"/> that surfaces
/// the raw Azure-Tables continuation token for one page at a time.
/// </summary>
/// <remarks>
/// The wider continuation envelope (tenant binding, filter fingerprint,
/// expiry) lives in <c>AutopilotMonitor.Shared.Pagination.ContinuationToken</c>.
/// This helper deliberately returns the SDK's opaque token so the caller
/// can stitch the wire token together with its own filter fingerprint.
/// </remarks>
public static class AzureTablesPaginator
{
/// <summary>
/// Fetches a single page of entities. Honours <paramref name="pageSize"/>
/// as both the SDK <c>maxPerPage</c> and the page-size hint when
/// resuming from <paramref name="continuation"/>.
/// </summary>
/// <returns>
/// A tuple of the page entities and the raw continuation string for
/// the next page (<c>null</c> when the page is the last).
/// </returns>
public static async Task<(IReadOnlyList<T> Items, string? NextRawToken)> FetchPageAsync<T>(
TableClient client,
string? filter,
int pageSize,
string? continuation,
IEnumerable<string>? 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<string>();
foreach (var s in select)
{
if (!string.IsNullOrEmpty(s)) list.Add(s);
}
if (list.Count > 0) selectArr = list.ToArray();
}
var query = client.QueryAsync<T>(
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<T>(), null);
}
}
}
@@ -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
{
/// <summary>
/// Shared pagination helpers for date-windowed forensics endpoints
/// (audit logs, ops events, session reports). These all share the same
/// query-string surface (<c>dateFrom</c> / <c>dateTo</c> / <c>pageSize</c>
/// / <c>continuation</c>) and the same default-window contract, so the
/// parsing + fingerprint logic lives in one place.
/// </summary>
/// <remarks>
/// Per the pagination rollout plan (PR-3):
/// <list type="bullet">
/// <item><description>No <c>dateFrom</c>/<c>dateTo</c> ⇒ default to a
/// 30-day window ending now.</description></item>
/// <item><description>Either bound supplied ⇒ honoured exactly; the
/// other bound stays open.</description></item>
/// <item><description><c>pageSize</c> opts in to pagination
/// (default <see cref="DefaultPageSize"/>, max
/// <see cref="MaxPageSize"/>); without it the endpoint returns the full
/// filtered window with no <c>nextLink</c> and no row cap.</description></item>
/// <item><description>Continuation tokens bind <c>scope</c>,
/// <c>category</c> (when applicable) and the resolved
/// <c>dateFrom</c>/<c>dateTo</c> via filter fingerprint, plus the caller's
/// tenantId.</description></item>
/// </list>
/// </remarks>
public static class DateWindowPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30);
/// <summary>
/// Parsed query-state. <c>PageSize == null</c> indicates the legacy
/// unpaginated path; <c>Error</c> non-null indicates a 400 should be
/// returned without inspecting the rest.
/// </summary>
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; }
}
/// <summary>
/// Parses <c>dateFrom</c> / <c>dateTo</c> / <c>pageSize</c> /
/// <c>continuation</c> from the URL query and resolves the default
/// 30-day window if both bounds are absent. Resolved bounds are
/// returned in <see cref="Parsed.DateFrom"/> / <see cref="Parsed.DateTo"/>
/// so the caller can fingerprint + emit them on <c>nextLink</c>
/// unchanged across follow-up calls.
/// </summary>
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,
};
}
/// <summary>
/// Builds a filter fingerprint over <c>(scope, callerTenantId, dateFrom, dateTo,
/// extras...)</c> in canonical form. <paramref name="extras"/> is for
/// per-endpoint discriminators (e.g. ops-events <c>category</c>).
/// </summary>
public static string Fingerprint(
string scope,
string callerTenantId,
DateTime? dateFrom,
DateTime? dateTo,
IEnumerable<KeyValuePair<string, string?>>? extras = null)
{
var pairs = new List<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("scope", scope),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("dateTo", dateTo?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
};
if (extras != null) pairs.AddRange(extras);
return ContinuationToken.ComputeFingerprint(pairs);
}
/// <summary>
/// Validates an incoming continuation against the recomputed fingerprint
/// of the current request and the caller's tenantId.
/// </summary>
public static bool TryAcceptContinuation(
string raw,
string scope,
string callerTenantId,
DateTime? dateFrom,
DateTime? dateTo,
IEnumerable<KeyValuePair<string, string?>>? 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);
}
/// <summary>
/// Builds a relative-on-host nextLink URL preserving the resolved
/// window and any extra query params the endpoint cares about.
/// </summary>
public static string BuildNextLink(
string basePath,
int pageSize,
string wireContinuation,
DateTime? dateFrom,
DateTime? dateTo,
IEnumerable<KeyValuePair<string, string?>>? 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;
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for <c>/api/raw/events</c> + <c>/api/global/raw/events</c>.
/// PR-6 of mcp-pagination-rollout: replaces the legacy <c>?limit=</c> wire
/// shape and the hard-coded <c>limit:20</c> EventTypeIndex lookup with
/// <c>?pageSize=</c> + <c>?continuation=</c> + <c>nextLink</c> over the
/// underlying index walk.
/// </summary>
/// <remarks>
/// <para>
/// Pagination unit is <em>EventTypeIndex rows scanned per page</em>, NOT
/// events returned: a single indexed (session, eventType) pair may yield
/// many events. Total events per page can therefore exceed
/// <c>pageSize</c>; AI/clients should follow <c>nextLink</c> until absent
/// for forensics-grade exact recall.
/// </para>
/// <para>
/// Continuation tokens bind <c>(scope, callerTenantId, filterTenantId,
/// eventType, source, severity, startedAfter, startedBefore)</c> via
/// fingerprint — flipping any filter invalidates the cursor (no recall
/// drift between pages).
/// </para>
/// </remarks>
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<string, string?>("scope", scope),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("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<string, string?>("sessionId", sessionId),
new KeyValuePair<string, string?>("eventType", eventType),
new KeyValuePair<string, string?>("source", source),
new KeyValuePair<string, string?>("severity", severity),
new KeyValuePair<string, string?>("startedAfter", startedAfter),
new KeyValuePair<string, string?>("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();
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for <c>/api/search/sessions-by-event</c> +
/// <c>/api/global/search/sessions-by-event</c>. Same paged
/// EventTypeIndex walk as <see cref="QueryRawEventsPagination"/>, but
/// returns sessions (not events) — so it gets its own scope tag in the
/// fingerprint to keep tokens non-fungible across the two endpoints.
/// </summary>
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<string, string?>("scope", scope),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
new KeyValuePair<string, string?>("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();
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for <c>/api/search/sessions</c> + <c>/api/global/search/sessions</c>.
/// 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.
/// </summary>
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<KeyValuePair<string, string?>>
{
new KeyValuePair<string, string?>("scope", scope),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
new KeyValuePair<string, string?>("path", filter.HasDeviceSnapshotFilters ? "snapshot" : "scan"),
new KeyValuePair<string, string?>("status", filter.Status),
new KeyValuePair<string, string?>("serialNumber", filter.SerialNumber),
new KeyValuePair<string, string?>("agentVersion", filter.AgentVersion),
new KeyValuePair<string, string?>("imeAgentVersion", filter.ImeAgentVersion),
new KeyValuePair<string, string?>("deviceName", filter.DeviceName),
new KeyValuePair<string, string?>("manufacturer", filter.Manufacturer),
new KeyValuePair<string, string?>("model", filter.Model),
new KeyValuePair<string, string?>("osBuild", filter.OsBuild),
new KeyValuePair<string, string?>("enrollmentType", filter.EnrollmentType),
new KeyValuePair<string, string?>("isPreProvisioned", filter.IsPreProvisioned?.ToString()),
new KeyValuePair<string, string?>("isHybridJoin", filter.IsHybridJoin?.ToString()),
new KeyValuePair<string, string?>("geoCountry", filter.GeoCountry),
new KeyValuePair<string, string?>("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("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<string, string?>($"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();
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for the <c>GET /api/sessions/{sessionId}/events</c> pagination
/// surface. Kept pure so they can be unit-tested without standing up the
/// Functions HTTP runtime.
/// </summary>
public static class SessionEventsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
/// <summary>Fingerprint binding tokens to <c>(tenantId, sessionId)</c>.</summary>
public static string Fingerprint(string tenantId, string sessionId) =>
ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("tenantId", tenantId),
new KeyValuePair<string, string?>("sessionId", sessionId),
});
/// <summary>
/// Result of <see cref="ParseQuery"/>. <c>PageSize</c> is null when the
/// request did not opt in to pagination — the function then returns the
/// full event list, no <c>nextLink</c> (legacy behavior).
/// </summary>
public sealed class Parsed
{
public int? PageSize { get; init; }
public string? Continuation { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Parses <c>pageSize</c> + <c>continuation</c> from the URL query.
/// Tolerant: missing pageSize means "unpaginated"; invalid pageSize is
/// surfaced as <see cref="Parsed.Error"/> for the caller to 400 on.
/// </summary>
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 };
}
/// <summary>
/// Validates a wire-token + decodes the embedded Azure-Tables continuation.
/// </summary>
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);
}
/// <summary>
/// Builds the absolute(-on-host) <c>nextLink</c> 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.
/// </summary>
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();
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for the session-list pagination surface
/// (<c>/api/sessions</c> + <c>/api/global/sessions</c>). Replaces the
/// legacy <c>?limit=</c> + <c>?cursor=</c> wire shape with the rollout
/// plan's <c>?pageSize=</c> + <c>?continuation=</c> + <c>nextLink</c>
/// contract.
/// </summary>
public static class SessionListPagination
{
public const int DefaultPageSize = 100;
public const int MaxPageSize = 1000;
/// <summary>
/// Fingerprint binding the token to <c>(scope, callerTenantId, days, filterTenantId)</c>.
/// <paramref name="filterTenantId"/> is for the global endpoint only.
/// </summary>
public static string Fingerprint(string scope, string callerTenantId, int? days, string? filterTenantId = null) =>
ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("scope", scope),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("days", days?.ToString(CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("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();
}
}
}
@@ -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
{
/// <summary>
/// Pure helpers for the <c>GET /api/global/session-reports</c> pagination
/// surface. Stays GA-only at the policy layer; the optional
/// <c>tenantId</c> here is a server-side <em>filter</em>, 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).
/// </summary>
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<string, string?>("scope", "session-reports"),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("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();
}
}
}
@@ -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);
@@ -81,10 +81,10 @@ namespace AutopilotMonitor.Functions.Services
private async Task<PlatformAgentMetricsResponse> 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)
{
@@ -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
}
/// <summary>
/// Returns all session reports, newest first (by inverted RowKey).
/// Returns all session reports newest-first, optionally filtered to a single tenant.
/// </summary>
public async Task<List<SessionReportMetadata>> GetAllReportsAsync()
{
return await _notificationRepo.GetSessionReportsAsync();
}
public Task<List<SessionReportMetadata>> GetAllReportsAsync(string? tenantId = null)
=> _notificationRepo.GetSessionReportsAsync(tenantId);
/// <summary>
/// Returns a single page of session reports newest-first, optionally
/// filtered to a single tenant. The opaque <c>NextRawToken</c> on the
/// returned <see cref="RawPage{T}"/> is what the function layer wraps
/// with the wire continuation envelope.
/// </summary>
public Task<RawPage<SessionReportMetadata>> GetReportsPageAsync(
string? tenantId, int pageSize, string? continuation)
=> _notificationRepo.GetSessionReportsPageAsync(tenantId, pageSize, continuation);
/// <summary>
/// Updates the AdminNote field for a report identified by reportId.
@@ -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);
@@ -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);
}
/// <summary>
/// Paged variant of <see cref="SearchSessionsAsync"/> backing
/// <c>/api/search/sessions</c> + <c>/api/global/search/sessions</c>.
/// The <c>filter.Limit</c> field is ignored — pagination uses
/// <paramref name="pageSize"/> + <paramref name="continuation"/>.
/// 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.
/// </summary>
public async Task<RawPage<SessionSummary>> 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<RawPage<SessionSummary>> 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<TableEntity>(
client: indexTableClient,
filter: oDataFilter,
pageSize: pageSize,
continuation: continuation);
var sessions = new List<SessionSummary>(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<SessionSummary>(sessions, nextRawToken);
}
private async Task<RawPage<SessionSummary>> 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<TableEntity>(
client: snapshotTableClient,
filter: oDataFilter,
pageSize: pageSize,
continuation: continuation);
var sessionIds = new List<string>();
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<SessionSummary>(Array.Empty<SessionSummary>(), nextRawToken);
}
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
sessions = ApplyBasicFilters(sessions, filter);
return new RawPage<SessionSummary>(sessions, nextRawToken);
}
private static string? BuildSearchScanFilter(string? tenantId, SessionSearchFilter filter)
{
var parts = new List<string>();
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<List<SessionSummary>> 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<string>();
await foreach (var entity in tableClient.QueryAsync<TableEntity>(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();
}
/// <summary>
/// Paged variant of <see cref="SearchSessionsByEventAsync"/> — drives
/// <c>QueryRawEventsFunction</c>'s cross-session walk. Returns up to
/// <paramref name="pageSize"/> distinct sessions plus the underlying
/// Azure-Tables continuation; caller follows the wire <c>nextLink</c>
/// to drain the full recall set.
/// </summary>
public async Task<RawPage<SessionSummary>> 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<AutopilotMonitor.Shared.Models.EventSeverity>(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<TableEntity>(
client: tableClient,
filter: oDataFilter,
pageSize: pageSize,
continuation: continuation);
var sessionIds = new List<string>();
var seen = new HashSet<string>(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<SessionSummary>(Array.Empty<SessionSummary>(), nextRawToken);
}
var sessions = await BatchGetSessionsAsync(tenantId, sessionIds);
return new RawPage<SessionSummary>(sessions, nextRawToken);
}
/// <summary>
/// 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.
@@ -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 =====
/// <summary>
/// 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.
/// </summary>
internal const string AuditLogRowKeyPrefix = "!";
/// <summary>
/// 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.
/// </summary>
internal static string BuildAuditLogRowKey(DateTime timestampUtc, Guid collisionSuffix)
{
var revTick = DateTime.MaxValue.Ticks - timestampUtc.Ticks;
return $"{AuditLogRowKeyPrefix}{revTick:D19}_{collisionSuffix:N}";
}
/// <summary>
/// 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).
/// </summary>
public async Task<bool> LogAuditEntryAsync(string tenantId, string action, string entityType, string entityId, string performedBy, Dictionary<string, string>? 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
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, int maxResults = 100)
public async Task<List<AuditLogEntry>> 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<TableEntity>(filter: $"PartitionKey eq '{tenantId}'");
var filter = BuildAuditLogFilter(tenantId, dateFrom, dateTo);
var query = tableClient.QueryAsync<TableEntity>(filter: filter);
var logs = new List<AuditLogEntry>();
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
}
/// <summary>
/// Gets audit log entries across all tenants (Global Admin Mode)
/// Gets audit log entries across all tenants (Global Admin Mode), optional UTC window.
/// </summary>
public async Task<List<AuditLogEntry>> GetAllAuditLogsAsync(int maxResults = 100)
public async Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null)
{
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.AuditLogs);
var query = tableClient.QueryAsync<TableEntity>();
var filter = BuildAuditLogFilter(tenantId: null, dateFrom, dateTo);
var query = string.IsNullOrEmpty(filter)
? tableClient.QueryAsync<TableEntity>()
: tableClient.QueryAsync<TableEntity>(filter: filter);
var logs = new List<AuditLogEntry>();
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<RawPage<AuditLogEntry>> 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<RawPage<AuditLogEntry>> 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<RawPage<AuditLogEntry>> 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<TableEntity>(
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<AuditLogEntry>(entities.Count);
foreach (var entity in entities) logs.Add(MapToAuditLogEntry(entity));
return new RawPage<AuditLogEntry>(logs, nextRawToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get audit logs page");
return RawPage<AuditLogEntry>.Empty;
}
}
private async Task<RawPage<AuditLogEntry>> 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<string>(StringComparer.OrdinalIgnoreCase)
{
Constants.AuditGlobalTenantId,
};
await foreach (var entity in configTableClient.QueryAsync<TableEntity>(
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<AuditLogEntry>(new List<AuditLogEntry>(), 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<TableEntity>(filter: tenantFilter, maxPerPage: pageSize))
{
fetched.Add((e.RowKey, MapToAuditLogEntry(e)));
if (fetched.Count >= pageSize) break;
}
return new AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge
.PartitionFetchResult<AuditLogEntry>(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<AuditLogEntry>(items, nextRawToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get all audit logs page");
return RawPage<AuditLogEntry>.Empty;
}
}
private static string BuildAuditLogFilterWithRowKeyBound(
string tenantId, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
{
var clauses = new List<string>
{
$"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<string>();
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 =====
/// <summary>
@@ -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
}
/// <summary>
/// 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
/// <see cref="GetSessionsAsync(string, int?)"/> (drain loop) and
/// <see cref="GetSessionsPageAsync(string, int?, int, string?)"/> (single page).
/// </summary>
public async Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null)
private async Task<(List<SessionSummary> 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<TableEntity>(
filter: filter,
maxPerPage: Math.Min(safetyCap, 1000)
maxPerPage: Math.Min(maxResults + 1, 1000)
);
var sessions = new List<SessionSummary>();
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<SessionSummary>(), false, null);
}
}
/// <summary>
/// Drains all sessions for a tenant — newest-first, no row cap. Internally
/// loops <see cref="GetSessionsPageAsync"/> until the next-token is null.
/// </summary>
public async Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
var all = new List<SessionSummary>();
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;
}
/// <summary>
/// Reads a single page of sessions. Builds on the internal RowKey-cursor
/// scan so callers don't see the cursor mechanics — only the
/// <see cref="RawPage{T}"/> envelope with the opaque <c>NextRawToken</c>.
/// </summary>
public async Task<RawPage<SessionSummary>> 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<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
}
/// <summary>
/// Cross-tenant paged variant of <see cref="GetAllSessionsAsync"/>.
/// <paramref name="tenantIdFilter"/> optionally restricts to one tenant.
/// </summary>
public async Task<RawPage<SessionSummary>> 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<SessionSummary>(page.Sessions, page.HasMore ? page.NextCursor : null);
}
/// <summary>
/// Drains all sessions across all tenants — newest-first, no row cap.
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant
/// (routed through the per-tenant index for efficiency).
/// </summary>
public async Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null)
{
if (!string.IsNullOrEmpty(tenantIdFilter))
{
return await GetSessionsAsync(tenantIdFilter!, days);
}
var all = new List<SessionSummary>();
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;
}
/// <summary>
/// Fallback: queries the Sessions table directly (pre-migration, before SessionsIndex is populated).
/// </summary>
private async Task<SessionPage> GetSessionsFromPrimaryTableAsync(string tenantId, int maxResults, int? days = null)
private async Task<(List<SessionSummary> 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);
}
/// <summary>
/// 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
/// <see cref="GetAllSessionsAsync(string?, int?)"/> (drain) and
/// <see cref="GetAllSessionsPageAsync"/> (single page).
/// </summary>
public async Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null)
private async Task<(List<SessionSummary> 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<SessionSummary>(), false, null);
}
}
/// <summary>
/// Fallback: queries the Sessions table directly for global admin (pre-migration).
/// </summary>
private async Task<SessionPage> GetAllSessionsFromPrimaryTableAsync(int maxResults, int? days = null)
private async Task<(List<SessionSummary> 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);
}
/// <summary>
@@ -1555,6 +1617,46 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Reads a single page of session events. Honours the supplied <paramref name="pageSize"/>
/// and resumes from <paramref name="continuation"/> when supplied. The returned page's
/// items are sorted by Sequence ascending; the next-page cursor is null when this was
/// the last page.
/// </summary>
public async Task<RawPage<EnrollmentEvent>> 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<TableEntity>(
client: tableClient,
filter: $"PartitionKey eq '{partitionKey}'",
pageSize: pageSize,
continuation: continuation);
var events = new List<EnrollmentEvent>(entities.Count);
foreach (var entity in entities)
{
events.Add(MapToEnrollmentEvent(entity));
}
events.Sort((a, b) => a.Sequence.CompareTo(b.Sequence));
return new RawPage<EnrollmentEvent>(events, nextRawToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get events page for session {SessionId}", sessionId);
return RawPage<EnrollmentEvent>.Empty;
}
}
/// <summary>
/// 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.
@@ -101,4 +101,28 @@ function buildQuery(params: Record<string, string | number | boolean | undefined
return s ? `?${s}` : '';
}
export { apiFetch, buildQuery };
/**
* If `continuationOrNextLink` is a full server-emitted nextLink (path starts with
* `/api/`), returns it verbatim — every query param the backend chose to echo
* (continuation, tenantId, days, filterTenantId, …) is preserved unchanged.
* Otherwise rebuilds the URL from `basePath` + `extraParams` + opaque
* `continuation` token, the legacy form.
*
* Why: paginated endpoints sometimes need to round-trip more than just the
* opaque continuation across pages — e.g. GA cross-tenant `/api/sessions/{id}/events`
* binds the resolved tenantId into the token's fingerprint, so page 2 must
* re-send `?tenantId=` to validate. Tools that strip everything except
* `continuation` would silently fail token validation on follow-up calls.
*/
function followNextLink(
basePath: string,
extraParams: Record<string, string | number | boolean | undefined | null>,
continuationOrNextLink?: string,
): string {
if (continuationOrNextLink && continuationOrNextLink.startsWith('/api/')) {
return continuationOrNextLink;
}
return `${basePath}${buildQuery({ ...extraParams, continuation: continuationOrNextLink })}`;
}
export { apiFetch, buildQuery, followNextLink };
@@ -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<string, string | number | undefined> = {};
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 () => {
@@ -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<string, string | number | boolean | undefined | null> = { ...rest };
const { deviceProperties, tenantId, pageSize, continuation, ...rest } = args;
const queryParams: Record<string, string | number | boolean | undefined | null> = {
...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<string, string | undefined>);
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) => {
@@ -10,4 +10,9 @@
<PackageReference Include="System.Text.Json" Version="9.0.1" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="AutopilotMonitor.Functions" />
<InternalsVisibleTo Include="AutopilotMonitor.Functions.Tests" />
</ItemGroup>
</Project>
@@ -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<bool> LogAuditEntryAsync(string tenantId, string action, string entityType,
string entityId, string performedBy, Dictionary<string, string>? details = null);
Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, int maxResults = 100);
Task<List<AuditLogEntry>> GetAllAuditLogsAsync(int maxResults = 100);
/// <summary>
/// Returns all audit log entries for <paramref name="tenantId"/> in the
/// given <paramref name="dateFrom"/>/<paramref name="dateTo"/> window
/// (UTC, inclusive). Either bound may be null. Sorted newest-first.
/// No row cap — callers passing very wide windows MUST consider
/// <see cref="GetAuditLogsPageAsync"/> to bound memory.
/// </summary>
Task<List<AuditLogEntry>> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null);
/// <summary>Cross-tenant variant of <see cref="GetAuditLogsAsync"/> (Global Admin only).</summary>
Task<List<AuditLogEntry>> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null);
/// <summary>
/// Reads a single page of audit log entries for <paramref name="tenantId"/>
/// in the given window. The returned <see cref="RawPage{T}"/> carries the
/// underlying store's opaque continuation token; <c>null</c> when this
/// page was the last. Items in each page are sorted newest-first.
/// </summary>
Task<RawPage<AuditLogEntry>> GetAuditLogsPageAsync(
string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation);
/// <summary>Cross-tenant variant of <see cref="GetAuditLogsPageAsync"/> (Global Admin only).</summary>
Task<RawPage<AuditLogEntry>> GetAllAuditLogsPageAsync(
DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation);
// --- Data Retention Queries ---
Task<List<SessionSummary>> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate);
@@ -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<bool> StoreSessionReportMetadataAsync(SessionReportMetadata metadata);
Task<List<SessionReportMetadata>> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50);
/// <summary>
/// Returns all session reports, newest-first. Optional <paramref name="tenantId"/>
/// filters server-side. No row cap on the full-fetch path; for installations
/// with very large report archives prefer <see cref="GetSessionReportsPageAsync"/>.
/// </summary>
Task<List<SessionReportMetadata>> GetSessionReportsAsync(string? tenantId = null);
/// <summary>
/// Reads a single page of session reports newest-first. Optional
/// <paramref name="tenantId"/> applies server-side. The returned
/// <see cref="RawPage{T}"/> carries the underlying store's opaque
/// continuation; <c>null</c> when this page was the last.
/// </summary>
Task<RawPage<SessionReportMetadata>> GetSessionReportsPageAsync(
string? tenantId, int pageSize, string? continuation);
Task<SessionReportMetadata?> GetSessionReportAsync(string reportId);
Task<bool> UpdateSessionReportAdminNoteAsync(string reportId, string adminNote);
}
@@ -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<List<OpsEventEntry>> GetOpsEventsAsync(int maxResults = 200);
Task<List<OpsEventEntry>> GetOpsEventsByCategoryAsync(string category, int maxResults = 100);
/// <summary>
/// Returns all matching ops events in the given UTC window. <paramref name="category"/>
/// 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 <see cref="GetOpsEventsPageAsync"/>.
/// </summary>
Task<List<OpsEventEntry>> GetOpsEventsAsync(
string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null);
/// <summary>
/// Reads a single page of ops events. The returned <see cref="RawPage{T}"/>
/// carries the underlying store's opaque continuation token; <c>null</c>
/// when this page was the last. Items in each page are sorted
/// newest-first.
/// </summary>
Task<RawPage<OpsEventEntry>> GetOpsEventsPageAsync(
string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation);
Task<int> DeleteOpsEventsOlderThanAsync(DateTime cutoff);
}
@@ -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<bool> StoreSessionAsync(SessionRegistration registration);
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
Task<string?> FindSessionTenantIdAsync(string sessionId);
Task<SessionPage> GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null);
Task<SessionPage> GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null);
/// <summary>
/// Returns all sessions for <paramref name="tenantId"/> newest-first.
/// Optional <paramref name="days"/> scopes to the last N days. No row cap —
/// drains the underlying paged scan; for large tenants prefer
/// <see cref="GetSessionsPageAsync"/>.
/// </summary>
Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null);
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
/// </summary>
Task<List<SessionSummary>> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null);
/// <summary>
/// Reads a single page of sessions for <paramref name="tenantId"/>, newest-first
/// (SessionsIndex inverted-tick RowKey order). The opaque <c>NextRawToken</c>
/// on the returned <see cref="RawPage{T}"/> is the Azure-Tables continuation
/// the function layer wraps with the wire envelope. Optional <paramref name="days"/>
/// scopes to the last N days via RowKey range; <c>null</c> means all time.
/// </summary>
Task<RawPage<SessionSummary>> GetSessionsPageAsync(
string tenantId, int? days, int pageSize, string? continuation);
/// <summary>
/// Cross-tenant variant of <see cref="GetSessionsPageAsync"/> (Global Admin).
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
/// </summary>
Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(
string? tenantIdFilter, int? days, int pageSize, string? continuation);
Task<bool> DeleteSessionAsync(string tenantId, string sessionId);
// --- Session Updates ---
@@ -77,12 +107,44 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000);
Task<List<EnrollmentEvent>> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200);
/// <summary>
/// Reads a single page of session events. The returned <see cref="RawPage{T}"/>
/// carries the underlying store's opaque continuation token for the next page,
/// or <c>null</c> when the page just read was the last. Items in each page are
/// sorted by <c>Sequence</c> 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 <c>Sequence</c> after merging.
/// </summary>
Task<RawPage<EnrollmentEvent>> GetSessionEventsPageAsync(
string tenantId, string sessionId, int pageSize, string? continuation);
// --- Search ---
Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10);
Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter);
/// <summary>
/// Paged variant of <see cref="SearchSessionsAsync"/>. Used by the
/// <c>/api/search/sessions</c> + <c>/api/global/search/sessions</c> endpoints.
/// The <see cref="SessionSearchFilter.Limit"/> field is ignored — pagination
/// is driven by <paramref name="pageSize"/> + <paramref name="continuation"/>.
/// </summary>
Task<RawPage<SessionSummary>> SearchSessionsPageAsync(
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation);
Task<List<SessionSummary>> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity,
string? phase, int limit = 50);
/// <summary>
/// Paged variant of <see cref="SearchSessionsByEventAsync"/>. Walks the
/// <c>EventTypeIndex</c> table page-by-page so callers can drain large
/// result sets via <c>nextLink</c>. Replaces the legacy hard-coded
/// <c>limit:20</c> session-index lookup that silently dropped recall on
/// large tenants (mcp-pagination-rollout PR-6, audit finding D4).
/// </summary>
Task<RawPage<SessionSummary>> SearchSessionsByEventPageAsync(
string? tenantId, string eventType, string? source, string? severity, string? phase,
int pageSize, string? continuation);
Task<List<EnrollmentEvent>> SearchEventsByTypesAsync(
string? tenantId, IEnumerable<string> eventTypes, string? source, string? severity,
int sessionLimit = 10, int eventLimit = 50);
@@ -312,17 +312,6 @@ namespace AutopilotMonitor.Shared.Models
public string FailureSnapshotJson { get; set; } = string.Empty;
}
/// <summary>
/// Paginated result of session listings.
/// Used by GetSessionsAsync and GetAllSessionsAsync to support cursor-based "Load More".
/// </summary>
public class SessionPage
{
public List<SessionSummary> Sessions { get; set; } = new();
public bool HasMore { get; set; }
public string? Cursor { get; set; }
}
/// <summary>
/// Lightweight result for the global quick-search typeahead.
/// </summary>
@@ -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
{
/// <summary>
/// Stateless codec for paginated-endpoint continuation tokens.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="TryDecode"/> to compare. The HMAC is verified against
/// the server's signing key.
/// </para>
/// <para>
/// Security properties:
/// <list type="bullet">
/// <item><description>Cross-tenant replay → caller's resolved tenantId
/// must match the token's; mismatch is rejected.</description></item>
/// <item><description>Tampered filter args → recomputed fingerprint will
/// not match the token's, request rejected.</description></item>
/// <item><description>Tampered payload (incl. <c>iat</c> bypass) → HMAC
/// verification fails, request rejected.</description></item>
/// <item><description>24h expiry — tokens older than
/// <see cref="DefaultTtl"/> are rejected (HMAC prevents bypass by
/// client-side iat tampering).</description></item>
/// </list>
/// </para>
/// <para>
/// Signing key sourced from the <c>PaginationTokenSigningKey</c>
/// environment variable (base64-encoded random bytes, ≥32 bytes
/// recommended). Tests may inject a key via
/// <see cref="SetSigningKeyForTesting"/>. Key rotation invalidates
/// in-flight tokens; callers receive a clean rejection and restart
/// pagination from page 1 — acceptable given the 24h TTL.
/// </para>
/// </remarks>
public static class ContinuationToken
{
/// <summary>Default 24h token expiry — see plan §"Open questions" 3.</summary>
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;
/// <summary>
/// Test-only hook to inject a fixed signing key. Production code reads
/// the key from the <c>PaginationTokenSigningKey</c> env var.
/// </summary>
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);
}
}
/// <summary>
/// Encodes a continuation token. <paramref name="azureToken"/> is the
/// raw <c>Azure.Data.Tables</c> 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 <c>iat</c> tampering) will fail decode-side verification.
/// </summary>
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);
}
/// <summary>
/// Validates and decodes a token. Returns <c>true</c> only if the
/// caller's tenantId, the recomputed fingerprint, and the token age
/// all match the policy. On failure <paramref name="rejectReason"/>
/// reports a stable, log-safe code.
/// </summary>
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<TokenPayload>(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;
}
/// <summary>
/// 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.
/// </summary>
public static string ComputeFingerprint(IEnumerable<KeyValuePair<string, string?>> 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<string, string>(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; }
}
}
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Pagination
{
/// <summary>
/// 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 <c>events</c>/<c>sessions</c>/
/// <c>logs</c>/<c>reports</c> etc. as the legacy collection key per the
/// pagination rollout plan, so this generic container deliberately does not
/// dictate the JSON property name.
/// </summary>
public sealed class PaginatedResult<T>
{
public IReadOnlyList<T> Items { get; }
public int Count { get; }
public string? NextLink { get; }
public PaginatedResult(IReadOnlyList<T> items, string? nextLink)
{
Items = items ?? throw new ArgumentNullException(nameof(items));
Count = items.Count;
NextLink = nextLink;
}
public static PaginatedResult<T> Empty { get; } = new PaginatedResult<T>(Array.Empty<T>(), null);
}
}
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Pagination
{
/// <summary>
/// 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
/// <see cref="ContinuationToken"/>.
/// </summary>
public sealed class RawPage<T>
{
public IReadOnlyList<T> Items { get; }
public string? NextRawToken { get; }
public RawPage(IReadOnlyList<T> items, string? nextRawToken)
{
Items = items ?? throw new ArgumentNullException(nameof(items));
NextRawToken = nextRawToken;
}
public static RawPage<T> Empty { get; } = new RawPage<T>(Array.Empty<T>(), null);
}
}
@@ -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<string, string> = {
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<OpsEvent | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>("");
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<string>(defaultIsoDateFrom());
const [dateToIso, setDateToIso] = useState<string>(defaultIsoDateTo());
const fetchEvents = async () => {
// Pattern B1 click-next replace state — backend pagination
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
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
</Link>
<button
onClick={fetchEvents}
onClick={handleRefresh}
disabled={loading}
className="px-3 py-1.5 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
@@ -256,6 +318,25 @@ export function OpsEventsSection({
</div>
</div>
{/* Date window */}
<div className="px-6 pt-4 pb-2 flex items-center gap-3 flex-wrap">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Window:</span>
<label className="text-xs text-gray-600 dark:text-gray-400">From</label>
<input
type="date"
value={isoToDateInputValue(dateFromIso)}
onChange={(e) => setDateFromIso(dateInputToIsoStart(e.target.value))}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
/>
<label className="text-xs text-gray-600 dark:text-gray-400">To</label>
<input
type="date"
value={isoToDateInputValue(dateToIso)}
onChange={(e) => setDateToIso(dateInputToIsoEnd(e.target.value))}
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-slate-500 focus:border-slate-500"
/>
</div>
{/* Search & Category Filter */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-wrap">
<div className="relative">
@@ -264,14 +345,14 @@ export function OpsEventsSection({
</svg>
<input
type="text"
placeholder="Search events..."
placeholder="Search this page..."
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(0); }}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-52 pl-8 pr-7 py-1 text-xs 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-slate-500 focus:border-slate-500"
/>
{searchQuery && (
<button
onClick={() => { setSearchQuery(""); setCurrentPage(0); }}
onClick={() => setSearchQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -337,7 +418,7 @@ export function OpsEventsSection({
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredEvents.slice(currentPage * pageSize, (currentPage + 1) * pageSize).map((evt, idx) => {
{filteredEvents.map((evt, idx) => {
const rowStyle = SEVERITY_STYLES[evt.severity]?.row ?? "";
return (
<tr
@@ -369,33 +450,28 @@ export function OpsEventsSection({
</tbody>
</table>
{/* Pagination */}
{filteredEvents.length > pageSize && (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage * pageSize + 1}&ndash;{Math.min((currentPage + 1) * pageSize, filteredEvents.length)} of {filteredEvents.length}{searchQuery && ` (filtered)`}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => p - 1)}
disabled={currentPage === 0}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage + 1} / {Math.ceil(filteredEvents.length / pageSize)}
</span>
<button
onClick={() => setCurrentPage(p => p + 1)}
disabled={(currentPage + 1) * pageSize >= filteredEvents.length}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{filteredEvents.length} on page {pageNumber}{searchQuery && ` (filtered)`}{nextLink ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
</div>
)}
</div>
@@ -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<SessionReport[]>([]);
const [loading, setLoading] = useState(false);
const [selectedReport, setSelectedReport] = useState<SessionReport | null>(null);
const [currentPage, setCurrentPage] = useState(0);
const pageSize = 5;
const [downloadingBlob, setDownloadingBlob] = useState<string | null>(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<string | undefined>(undefined);
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
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 (
<div className="bg-gradient-to-br from-indigo-50 to-purple-50 dark:from-gray-800 dark:to-gray-800 border-2 border-indigo-300 dark:border-indigo-700 rounded-lg shadow-lg">
{/* Section Header */}
@@ -178,8 +235,51 @@ export function SessionReportsSection({
</div>
</div>
{/* Tenant filter + refresh */}
<div className="px-6 pt-4 pb-2 flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Filter:</span>
<input
type="text"
placeholder="Tenant ID (GUID)"
value={tenantFilterInput}
onChange={(e) => 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"
/>
<button
onClick={handleApplyTenantFilter}
disabled={loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-indigo-300 dark:border-indigo-700 text-indigo-700 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/40 disabled:opacity-40 transition-colors"
>
Apply
</button>
{tenantFilterApplied && (
<button
onClick={handleClearTenantFilter}
disabled={loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
Clear
</button>
)}
{tenantFilterApplied && (
<span className="text-xs text-gray-500 dark:text-gray-400">
scoped to <span className="font-mono">{tenantFilterApplied.slice(0, 8)}…</span>
</span>
)}
<button
onClick={handleRefresh}
disabled={loading}
className="ml-auto px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 transition-colors"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{/* Reports Table */}
<div className="p-6">
<div className="p-6 pt-2">
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
@@ -206,7 +306,7 @@ export function SessionReportsSection({
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{reports.slice(currentPage * pageSize, (currentPage + 1) * pageSize).map(r => (
{reports.map(r => (
<tr
key={r.reportId}
onClick={() => setSelectedReport(r)}
@@ -244,33 +344,28 @@ export function SessionReportsSection({
</tbody>
</table>
{/* Pagination */}
{reports.length > pageSize && (
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage * pageSize + 1}–{Math.min((currentPage + 1) * pageSize, reports.length)} of {reports.length}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => p - 1)}
disabled={currentPage === 0}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
{currentPage + 1} / {Math.ceil(reports.length / pageSize)}
</span>
<button
onClick={() => setCurrentPage(p => p + 1)}
disabled={(currentPage + 1) * pageSize >= reports.length}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
{/* Pagination (Pattern B1 — backend-driven) */}
<div className="flex items-center justify-between border-t border-gray-200 dark:border-gray-700 px-4 py-3 bg-gray-50 dark:bg-gray-700/50 rounded-b-md">
<span className="text-xs text-gray-500 dark:text-gray-400">
{reports.length} on page {pageNumber}{nextLink ? "" : " (last)"}
</span>
<div className="flex items-center gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || loading}
className="px-2.5 py-1 text-xs font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
)}
</div>
</div>
)}
</div>
+136 -68
View File
@@ -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 {
// <input type="date"> 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<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
@@ -38,35 +69,45 @@ export default function AuditPage() {
const [actionFilter, setActionFilter] = useState<ActionFilter>('ALL');
const [entityTypeFilter, setEntityTypeFilter] = useState<EntityTypeFilter>('ALL');
const [expandedRow, setExpandedRow] = useState<string | null>(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<string>(defaultIsoDateFrom());
const [dateToIso, setDateToIso] = useState<string>(defaultIsoDateTo());
// Pattern B1 click-next replace state
const [continuation, setContinuation] = useState<string | null>(null);
const [nextLink, setNextLink] = useState<string | null>(null);
const [continuationStack, setContinuationStack] = useState<Array<string | null>>([]);
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() {
<div>
<h1 className="text-2xl font-normal text-gray-900">Audit Log</h1>
<p className="text-sm text-gray-600 mt-1">
{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})`}
</p>
</div>
</div>
<button
onClick={() => fetchAuditLogs(true)}
onClick={handleRefresh}
disabled={refreshing}
className="px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center space-x-2"
>
@@ -197,8 +248,26 @@ export default function AuditPage() {
</header>
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Filters */}
{/* Date window + filters */}
<div className="mb-4 flex flex-wrap items-center gap-3">
{/* Date pickers */}
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 dark:text-gray-400">From:</label>
<input
type="date"
value={isoToDateInputValue(dateFromIso)}
onChange={(e) => setDateFromIso(dateInputToIsoStart(e.target.value))}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="text-sm text-gray-600 dark:text-gray-400">To:</label>
<input
type="date"
value={isoToDateInputValue(dateToIso)}
onChange={(e) => setDateToIso(dateInputToIsoEnd(e.target.value))}
className="px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -206,7 +275,7 @@ export default function AuditPage() {
</svg>
<input
type="text"
placeholder="Search by user, entity, action or details..."
placeholder="Search by user, entity, action or details (this page only)..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
@@ -241,7 +310,7 @@ export default function AuditPage() {
{/* Audit Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
{paginatedLogs.length === 0 ? (
{filteredLogs.length === 0 ? (
<div className="p-12 text-center">
<svg className="h-16 w-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -277,7 +346,7 @@ export default function AuditPage() {
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{paginatedLogs.map((log) => {
{filteredLogs.map((log) => {
const details = parseDetails(log.details);
const isExpanded = expandedRow === log.id;
@@ -343,30 +412,29 @@ export default function AuditPage() {
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {currentPage} of {totalPages} ({filteredLogs.length} total entries)
</div>
<div className="flex gap-2">
<button
onClick={handlePreviousPage}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
&larr; Previous
</button>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next &rarr;
</button>
</div>
{/* Pagination Controls (Pattern B1) */}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {pageNumber}
{nextLink ? '' : ' (last)'}
</div>
)}
<div className="flex gap-2">
<button
onClick={handlePrevPage}
disabled={continuationStack.length === 0 || refreshing}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
&larr; Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextLink || refreshing}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next &rarr;
</button>
</div>
</div>
</div>
</div>
</ProtectedRoute>
@@ -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<string | null>(null);
const [continuation, setContinuation] = useState<string | null>(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
@@ -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) {
@@ -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<React.SetStateAction<EnrollmentEvent[]>>;
fetchEvents: () => Promise<void>;
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<EnrollmentEvent[]>([]);
const [isStreamingMore, setIsStreamingMore] = useState(false);
// Debounce real-time event refreshes to avoid burst reads in Table Storage.
const eventRefreshTimeoutRef = useRef<NodeJS.Timeout | null>(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 };
}
@@ -588,6 +588,19 @@ export default function SessionDetailPage() {
{/* Event Timeline (with severity filters, expand/collapse, WhiteGlove split) */}
<div id="section-event-timeline">
{eventsApi.isStreamingMore && (
<div
className="flex items-center gap-2 mb-2 px-3 py-1.5 rounded-md bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs"
role="status"
aria-live="polite"
>
<svg className="animate-spin w-3.5 h-3.5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span>Loading more events…</span>
</div>
)}
<EventTimeline
filteredEvents={filteredEvents}
events={events}
@@ -0,0 +1,133 @@
import { describe, it, expect } from "vitest";
import { extractContinuation, MAX_EAGER_PAGES } from "../paginationLink";
describe("extractContinuation", () => {
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<FakeResponse>,
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<FakeResponse> => ({
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<FakeResponse> => {
// 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<FakeResponse> => ({
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<FakeResponse> => ({
events: [{ sequence: 1 }],
nextLink: "",
});
const result = await eagerFetchLoop(fetcher);
expect(result.pages).toBe(1);
expect(result.truncated).toBe(false);
});
});
@@ -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<string | null> = [];
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"]);
});
});
+63 -11
View File
@@ -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 ──────────────────────────────────────────────────────────────
@@ -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<T> {
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;