feat(apps): opt-in pagination on apps/list + client-side paging in Installs tab

The apps/list endpoints returned the full per-app list in one shot, so there was
no way to page a large fleet's apps (e.g. for a future MCP consumer).

Backend (apps/list + global/apps/list):
- AppsAnalyticsHelper.BuildAppsListResponse gains opt-in pagination: no pageSize
  keeps the legacy full-array body (the web UI pages client-side); pageSize
  returns an offset-paginated envelope with count/offset/pageSize/nextLink.
- The list is aggregated in-memory from a deterministic sort, so a plain integer
  offset is a stable cursor — no HMAC continuation token needed (unlike the
  Azure-Table-backed config/all surface). Added an appName tiebreaker so the sort
  is fully deterministic across pages.
- New ParseAppsPaging validates pageSize (1..1000) / skip; both functions 400 on
  bad input and build a route-specific nextLink (global echoes tenantId).

Web (Installs tab): client-side pagination (pageSize 20) over the filtered/sorted
apps, matching the Inventory tab — sort/search stay across the full set.

Tests: AppsAnalyticsHelperPagingTests (legacy vs paginated, deterministic order,
last-page nextLink null). Backend 2206/2206, web 446, tsc clean, next build OK.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-07 18:25:10 +02:00
parent 0dfef290e3
commit dce5d7b14e
5 changed files with 185 additions and 6 deletions
@@ -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;
/// <summary>
/// Covers the opt-in pagination surface on <see cref="AppsAnalyticsHelper.BuildAppsListResponse"/>:
/// no pageSize → legacy full array; pageSize → offset-paginated envelope with a stable cursor.
/// </summary>
public class AppsAnalyticsHelperPagingTests
{
private static List<AppInstallSummary> FiveApps()
{
var started = DateTime.UtcNow.AddDays(-1);
var list = new List<AppInstallSummary>();
// 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);
}
}
@@ -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 ───────────────────────────────────────────────
/// <summary>Upper bound for a single <c>apps/list</c> page when the caller opts into pagination.</summary>
public const int MaxAppsPageSize = 1000;
public readonly struct AppsPaging
{
/// <summary>Null when the caller did not pass <c>pageSize</c> (legacy full-array mode).</summary>
public int? PageSize { get; init; }
public int Skip { get; init; }
public string? Error { get; init; }
}
/// <summary>
/// Parses the optional <c>?pageSize=</c> / <c>?skip=</c> pagination params. Absent
/// <c>pageSize</c> 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).
/// </summary>
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 ────────────────────────────────────────────────────
/// <summary>
@@ -45,9 +82,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
// ── /apps/list ──────────────────────────────────────────────────────
/// <summary>
/// Builds the list response body (everything except HTTP wrapping).
/// Builds the list response body (everything except HTTP wrapping). When <paramref name="pageSize"/>
/// is null the legacy full-array body is returned (the web UI paginates client-side); when set, an
/// offset-paginated envelope is returned with <c>count</c>/<c>offset</c>/<c>pageSize</c>/<c>nextLink</c>
/// (so an MCP caller can page a large fleet's app list). <paramref name="nextLinkForOffset"/> builds the
/// route-specific nextLink for the next offset; it is only invoked when more pages remain.
/// </summary>
public static object BuildAppsListResponse(List<AppInstallSummary> allSummaries, int days)
public static object BuildAppsListResponse(
List<AppInstallSummary> allSummaries,
int days,
int? pageSize = null,
int skip = 0,
Func<int, string>? 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
};
}
@@ -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);
@@ -49,8 +49,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps
"Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})",
userEmail, scopedTenantId ?? "<all>", 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);
@@ -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<SortKey>("failureRate");
const [sortDir, setSortDir] = useState<SortDir>("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."}
</div>
) : (
<>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
@@ -255,7 +265,7 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredAndSorted.map((row) => (
{pageRows.map((row) => (
<tr key={row.appName} onClick={() => openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
<td className="px-4 py-3 text-gray-900">
<span className="font-medium">{row.appName}</span>
@@ -275,6 +285,18 @@ export default function InstallsTab({ scope, timeRange }: InstallsTabProps) {
))}
</tbody>
</table>
{pageCount > 1 && (
<div className="flex items-center justify-between px-4 py-3 text-sm text-gray-600 border-t border-gray-200">
<span>{filteredAndSorted.length} apps · page {page + 1} of {pageCount}</span>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
className="px-3 py-1 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed">Previous</button>
<button onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} disabled={page >= pageCount - 1}
className="px-3 py-1 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
</div>
</div>
)}
</>
)}
</div>
</>