diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs new file mode 100644 index 00000000..d6930bd5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AppsAnalyticsHelperPagingTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using AutopilotMonitor.Functions.Functions.Apps; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Covers the opt-in pagination surface on : +/// no pageSize → legacy full array; pageSize → offset-paginated envelope with a stable cursor. +/// +public class AppsAnalyticsHelperPagingTests +{ + private static List FiveApps() + { + var started = DateTime.UtcNow.AddDays(-1); + var list = new List(); + // Names intentionally out of order so the deterministic appName tiebreaker is exercised. + foreach (var name in new[] { "Echo", "Alpha", "Delta", "Bravo", "Charlie" }) + list.Add(new AppInstallSummary { AppName = name, Status = "Succeeded", StartedAt = started }); + return list; + } + + private static JsonElement Build(int? pageSize, int skip = 0) => + JsonSerializer.SerializeToElement(AppsAnalyticsHelper.BuildAppsListResponse( + FiveApps(), days: 30, pageSize: pageSize, skip: skip, + nextLinkForOffset: o => $"/api/apps/list?pageSize={pageSize}&skip={o}")); + + [Fact] + public void NoPageSize_ReturnsLegacyFullArray_WithoutPagingFields() + { + var root = Build(pageSize: null); + + Assert.Equal(5, root.GetProperty("totalApps").GetInt32()); + Assert.Equal(5, root.GetProperty("apps").GetArrayLength()); + Assert.False(root.TryGetProperty("nextLink", out _)); + Assert.False(root.TryGetProperty("offset", out _)); + } + + [Fact] + public void PageSize_FirstPage_SlicesDeterministicallyAndEmitsNextLink() + { + var root = Build(pageSize: 2, skip: 0); + + Assert.Equal(5, root.GetProperty("totalApps").GetInt32()); + Assert.Equal(2, root.GetProperty("count").GetInt32()); + Assert.Equal(0, root.GetProperty("offset").GetInt32()); + + var apps = root.GetProperty("apps"); + Assert.Equal("Alpha", apps[0].GetProperty("appName").GetString()); + Assert.Equal("Bravo", apps[1].GetProperty("appName").GetString()); + Assert.Equal("/api/apps/list?pageSize=2&skip=2", root.GetProperty("nextLink").GetString()); + } + + [Fact] + public void PageSize_LastPage_HasNullNextLink() + { + var root = Build(pageSize: 2, skip: 4); + + Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.Equal("Echo", root.GetProperty("apps")[0].GetProperty("appName").GetString()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("nextLink").ValueKind); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index d2f9c2fb..b1f87d6e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -1,3 +1,4 @@ +using System.Collections.Specialized; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; @@ -27,6 +28,42 @@ namespace AutopilotMonitor.Functions.Functions.Apps return Guid.TryParse(raw, out _); } + // ── Opt-in pagination ─────────────────────────────────────────────── + + /// Upper bound for a single apps/list page when the caller opts into pagination. + public const int MaxAppsPageSize = 1000; + + public readonly struct AppsPaging + { + /// Null when the caller did not pass pageSize (legacy full-array mode). + public int? PageSize { get; init; } + public int Skip { get; init; } + public string? Error { get; init; } + } + + /// + /// Parses the optional ?pageSize= / ?skip= pagination params. Absent + /// pageSize means legacy mode (return the full array). The apps list is aggregated + /// in-memory from a deterministic sort, so a plain integer offset is a stable cursor — no + /// HMAC continuation token is needed (unlike the Azure-Table-backed config/all surface). + /// + public static AppsPaging ParseAppsPaging(NameValueCollection query) + { + var pageSizeRaw = query["pageSize"]; + if (string.IsNullOrEmpty(pageSizeRaw)) + return new AppsPaging { PageSize = null, Skip = 0 }; + + if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize) + return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" }; + + var skip = 0; + var skipRaw = query["skip"]; + if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0)) + return new AppsPaging { Error = "skip must be a non-negative integer" }; + + return new AppsPaging { PageSize = pageSize, Skip = skip }; + } + // ── Data loaders ──────────────────────────────────────────────────── /// @@ -45,9 +82,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps // ── /apps/list ────────────────────────────────────────────────────── /// - /// Builds the list response body (everything except HTTP wrapping). + /// Builds the list response body (everything except HTTP wrapping). When + /// is null the legacy full-array body is returned (the web UI paginates client-side); when set, an + /// offset-paginated envelope is returned with count/offset/pageSize/nextLink + /// (so an MCP caller can page a large fleet's app list). builds the + /// route-specific nextLink for the next offset; it is only invoked when more pages remain. /// - public static object BuildAppsListResponse(List allSummaries, int days) + public static object BuildAppsListResponse( + List allSummaries, + int days, + int? pageSize = null, + int skip = 0, + Func? nextLinkForOffset = null) { var now = DateTime.UtcNow; var cutoff = now.AddDays(-days); @@ -98,15 +144,39 @@ namespace AutopilotMonitor.Functions.Functions.Apps }) .OrderByDescending(a => a.failed) .ThenByDescending(a => a.failureRate) + .ThenBy(a => a.appName, StringComparer.OrdinalIgnoreCase) // deterministic tiebreaker for stable paging cursors .ToList(); + // Legacy mode: caller did not opt into pagination → full array (web UI pages client-side). + if (pageSize == null) + { + return new + { + success = true, + totalApps = apps.Count, + totalInstalls = summaries.Count, + windowDays = days, + apps + }; + } + + // Opt-in pagination: offset-based slice over the deterministically sorted list. + var offset = skip < 0 ? 0 : skip; + var page = apps.Skip(offset).Take(pageSize.Value).ToList(); + var nextOffset = offset + page.Count; + var hasMore = nextOffset < apps.Count; + return new { success = true, totalApps = apps.Count, totalInstalls = summaries.Count, windowDays = days, - apps + count = page.Count, + offset, + pageSize = pageSize.Value, + apps = page, + nextLink = hasMore ? nextLinkForOffset?.Invoke(nextOffset) : null }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs index 300e27e4..a706dff4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs @@ -38,8 +38,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) days = parsedDays; + var paging = AppsAnalyticsHelper.ParseAppsPaging(query); + if (paging.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = paging.Error }); + return bad; + } + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId); - var body = AppsAnalyticsHelper.BuildAppsListResponse(summaries, days); + var body = AppsAnalyticsHelper.BuildAppsListResponse( + summaries, days, paging.PageSize, paging.Skip, + nextOffset => $"/api/apps/list?days={days}&pageSize={paging.PageSize}&skip={nextOffset}"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(body); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs index fc632d4c..dbd775af 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs @@ -49,8 +49,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps "Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})", userEmail, scopedTenantId ?? "", days); + var paging = AppsAnalyticsHelper.ParseAppsPaging(query); + if (paging.Error != null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = paging.Error }); + return bad; + } + var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId); - var body = AppsAnalyticsHelper.BuildAppsListResponse(summaries, days); + var tenantQs = string.IsNullOrEmpty(scopedTenantId) ? string.Empty : $"&tenantId={scopedTenantId}"; + var body = AppsAnalyticsHelper.BuildAppsListResponse( + summaries, days, paging.PageSize, paging.Skip, + nextOffset => $"/api/global/apps/list?days={days}&pageSize={paging.PageSize}&skip={nextOffset}{tenantQs}"); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(body); diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx index 6b3bbea2..b36eeda1 100644 --- a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -59,6 +59,8 @@ type SortKey = | "trend"; type SortDir = "asc" | "desc"; +const PAGE_SIZE = 20; + interface InstallsTabProps { scope: SoftwareTabScope; timeRange: TimeRange; @@ -77,6 +79,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { const [search, setSearch] = useState(""); const [sortKey, setSortKey] = useState("failureRate"); const [sortDir, setSortDir] = useState("desc"); + const [page, setPage] = useState(0); useEffect(() => { if (!scopeInitialized) return; @@ -144,6 +147,12 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { return rows; }, [data, search, sortKey, sortDir]); + // Reset to the first page whenever the filtered/sorted set or the loaded data changes. + useEffect(() => { setPage(0); }, [search, sortKey, sortDir, data]); + + const pageCount = Math.max(1, Math.ceil(filteredAndSorted.length / PAGE_SIZE)); + const pageRows = filteredAndSorted.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + const stats = useMemo(() => { if (!data) return { totalApps: 0, totalInstalls: 0, avgFailureRate: 0 }; const apps = data.apps ?? []; @@ -242,6 +251,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { {search ? "No apps match your search." : "No app install data in this window."} ) : ( + <> @@ -255,7 +265,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { - {filteredAndSorted.map((row) => ( + {pageRows.map((row) => ( openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
{row.appName} @@ -275,6 +285,18 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { ))}
+ {pageCount > 1 && ( +
+ {filteredAndSorted.length} apps · page {page + 1} of {pageCount} +
+ + +
+
+ )} + )}