mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+150
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -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);
|
||||
|
||||
+48
-14
@@ -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
|
||||
|
||||
+130
-17
@@ -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;
|
||||
|
||||
+22
-4
@@ -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)
|
||||
{
|
||||
|
||||
+69
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-5
@@ -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)
|
||||
{
|
||||
|
||||
+60
-33
@@ -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>()
|
||||
});
|
||||
|
||||
|
||||
+105
-13
@@ -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>()
|
||||
});
|
||||
|
||||
|
||||
+56
-7
@@ -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)
|
||||
|
||||
+95
-37
@@ -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.
|
||||
|
||||
+213
-40
@@ -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}–{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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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 →
|
||||
</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"
|
||||
>
|
||||
← 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 →
|
||||
</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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user