mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user