feat(backend,web): restore-browser tenant filter — only tenants with snapshot blobs

Adds a "Only tenants with restore data" checkbox to the Restore Browser
tenant dropdown so a Global Admin can hide tenants that have nothing to
recover. Default off (full AdminConfig list); flipping it on fetches a
cheap hierarchy listing once (cached for page lifetime) and intersects
the dropdown with the tenants that own ≥1 snapshot blob.

Backend: new GlobalAdminOnly endpoint
GET /api/global/tenants-with-deletion-manifests backed by
BlobStorageService.ListTenantsWithDeletionManifestsAsync, which uses
GetBlobsByHierarchyAsync with delimiter="/" so Azure returns just the
top-level prefixes (= tenant IDs) without enumerating blobs underneath.
Cost scales with tenant count, not manifest count.

Web: checkbox under the tenant dropdown shows a "(X of Y)" counter when
active, dropdown placeholder reflects the loading state, and the
currently-selected tenant clears if the filter excludes it. Fetch
failures snap the checkbox back to off so the operator never gets
stranded with an empty list.

Build: 0 warnings/errors. Backend 1526/1526 pass. tsc clean.
Vitest 346/346 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-15 15:39:57 +02:00
parent c99ca0e461
commit 1041d44831
5 changed files with 202 additions and 5 deletions
@@ -0,0 +1,63 @@
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
/// <summary>
/// <c>GET /api/global/tenants-with-deletion-manifests</c> — returns the set of tenant IDs
/// that currently have at least one persisted cascade-delete snapshot blob. Powers the
/// Restore Browser's "only tenants with restore data" filter so the dropdown can hide
/// tenants that have nothing to restore.
/// <para>
/// Cheap: backed by a single hierarchy listing (<c>delimiter="/"</c>) on the manifest
/// container, so cost is bounded by the number of distinct tenants, not the total manifest
/// count. GA-only access (cross-tenant by design).
/// </para>
/// </summary>
public class GetTenantsWithDeletionManifestsFunction
{
private readonly ILogger<GetTenantsWithDeletionManifestsFunction> _logger;
private readonly BlobStorageService _blob;
public GetTenantsWithDeletionManifestsFunction(
ILogger<GetTenantsWithDeletionManifestsFunction> logger,
BlobStorageService blob)
{
_logger = logger;
_blob = blob;
}
[Function("GetTenantsWithDeletionManifests")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/tenants-with-deletion-manifests")]
HttpRequestData req)
{
try
{
var tenantIds = await _blob.ListTenantsWithDeletionManifestsAsync(
req.FunctionContext.CancellationToken);
var sorted = tenantIds.OrderBy(t => t, StringComparer.Ordinal).ToList();
return await req.OkAsync(new
{
success = true,
count = sorted.Count,
tenantIds = sorted,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "GetTenantsWithDeletionManifests: hierarchy listing failed");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Failed to enumerate tenant prefixes." });
return err;
}
}
}
}
@@ -248,6 +248,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/stats/sessions", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/audit/logs", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/tenants-with-deletion-manifests", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/platform", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/metrics/app", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/apps/list", EndpointPolicy.GlobalAdminOnly),
@@ -575,6 +575,43 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Returns the set of tenant IDs that currently have at least one persisted snapshot
/// blob. Implemented via a single hierarchy listing (<c>delimiter="/"</c>) on the
/// container — Azure returns only the first-level "virtual folder" prefixes
/// (<c>{tenantId}/</c>) without enumerating the blobs underneath, so cost is bounded by
/// the number of distinct tenants, not the total manifest count.
/// <para>
/// Powers the Restore Browser "only tenants with restore data" filter: the dropdown
/// can intersect the AdminConfig tenant list with this set to hide tenants that have
/// nothing to restore. A snapshot blob may still exist for a tenant that has since been
/// offboarded (33-day TTL) — in that case the intersection drops it because the
/// AdminConfig list is the authoritative tenant universe; offboarded-tenant recovery
/// goes through the manifestId-direct restore route.
/// </para>
/// </summary>
public virtual async Task<HashSet<string>> ListTenantsWithDeletionManifestsAsync(
CancellationToken cancellationToken = default)
{
var result = new HashSet<string>(StringComparer.Ordinal);
var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer);
if (!await containerClient.ExistsAsync(cancellationToken))
return result;
await foreach (var item in containerClient.GetBlobsByHierarchyAsync(
traits: BlobTraits.None, states: BlobStates.None, delimiter: "/", prefix: null, cancellationToken: cancellationToken))
{
if (!item.IsPrefix || string.IsNullOrEmpty(item.Prefix)) continue;
// Prefix is "{tenantId}/"; strip the trailing slash.
var raw = item.Prefix;
var tenantId = raw.EndsWith("/", StringComparison.Ordinal)
? raw.Substring(0, raw.Length - 1)
: raw;
if (tenantId.Length > 0) result.Add(tenantId);
}
return result;
}
/// <summary>
/// Deletes both the snapshot blob and the progress blob for a (tenant, session, manifest)
/// triple. 404 on either is treated as success — the maintenance sweep is idempotent and a
@@ -40,6 +40,12 @@ interface Selection {
manifestId: string;
}
interface TenantsWithManifestsResponse {
success: boolean;
count: number;
tenantIds: string[];
}
/**
* File-browser-style picker for stored cascade-delete snapshots. Lets a Global Admin recover a
* cleanly-completed cascade where the Sessions row is already gone — these sessions wouldn't
@@ -65,15 +71,68 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
const [selection, setSelection] = useState<Selection | null>(null);
const [restoring, setRestoring] = useState<Selection | null>(null);
// "Only tenants with restore data" filter — off by default so the full AdminConfig tenant
// list shows up immediately on first paint. Flipping it on fetches the cheap hierarchy
// listing once and intersects the dropdown with the set of tenants that have ≥1 snapshot
// blob. The fetched set is cached for the page lifetime; toggling off + on doesn't refetch.
const [onlyWithRestore, setOnlyWithRestore] = useState(false);
const [tenantsWithRestore, setTenantsWithRestore] = useState<Set<string> | null>(null);
const [loadingRestoreFilter, setLoadingRestoreFilter] = useState(false);
useEffect(() => {
if (!onlyWithRestore || tenantsWithRestore !== null) return;
const controller = new AbortController();
let cancelled = false;
setLoadingRestoreFilter(true);
(async () => {
try {
const resp = await authenticatedFetch(
api.sessionDeletions.tenantsWithManifests(),
getAccessToken,
{ signal: controller.signal },
);
if (cancelled) return;
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
throw new Error(`HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
}
const json = (await resp.json()) as TenantsWithManifestsResponse;
if (cancelled) return;
setTenantsWithRestore(new Set(json.tenantIds ?? []));
} catch (err) {
if (cancelled) return;
if (err instanceof DOMException && err.name === "AbortError") return;
if (err instanceof TokenExpiredError) {
setError("Session expired; reload the page and try again.");
} else {
setError(err instanceof Error ? err.message : String(err));
}
// On fetch failure leave the filter unchecked so the operator isn't stranded with an
// empty dropdown — the toggle won't visually persist as "on" without data backing it.
setOnlyWithRestore(false);
} finally {
if (!cancelled) setLoadingRestoreFilter(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [onlyWithRestore, tenantsWithRestore, getAccessToken, setError]);
// Sort tenants alphabetically by display name (domain fallback to id) so the dropdown is
// operator-friendly.
// operator-friendly. When the "only with restore" filter is active and the set has loaded,
// intersect against it before sorting.
const sortedTenants = useMemo(() => {
return [...tenants].sort((a, b) => {
const filtered = onlyWithRestore && tenantsWithRestore !== null
? tenants.filter((t) => tenantsWithRestore.has(t.tenantId))
: tenants;
return [...filtered].sort((a, b) => {
const aLabel = (a.domainName || a.tenantId).toLowerCase();
const bLabel = (b.domainName || b.tenantId).toLowerCase();
return aLabel.localeCompare(bLabel);
});
}, [tenants]);
}, [tenants, onlyWithRestore, tenantsWithRestore]);
// Effect-scoped fetch (no useCallback) so each render captures the exact tenantId+refreshKey
// snapshot — the AbortController + the loadedTenantId guard together prevent any out-of-order
@@ -179,10 +238,12 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
disabled={loadingTenants}
disabled={loadingTenants || loadingRestoreFilter}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-60"
>
<option value="">— select a tenant —</option>
<option value="">
{loadingRestoreFilter ? "— loading tenants with restore data… —" : "— select a tenant —"}
</option>
{sortedTenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName ? `${t.domainName} — ${t.tenantId}` : t.tenantId}
@@ -198,6 +259,35 @@ export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage
{loadingSessions ? "Loading…" : "Refresh"}
</button>
</div>
<div className="mt-3 flex items-center gap-2">
<input
id="restore-browser-only-with-data"
type="checkbox"
checked={onlyWithRestore}
onChange={(e) => {
const next = e.target.checked;
setOnlyWithRestore(next);
// If the filter excludes the currently-selected tenant, clear the selection so
// the dropdown doesn't display a value that's no longer in the option list.
if (next && tenantsWithRestore !== null && selectedTenantId
&& !tenantsWithRestore.has(selectedTenantId)) {
setSelectedTenantId("");
}
}}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-purple-600 focus:ring-purple-500"
/>
<label
htmlFor="restore-browser-only-with-data"
className="text-sm text-gray-700 dark:text-gray-200 select-none cursor-pointer"
>
Only tenants with restore data
{onlyWithRestore && tenantsWithRestore !== null && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({sortedTenants.length} of {tenants.length})
</span>
)}
</label>
</div>
</div>
{selectedTenantId && (
+6
View File
@@ -345,6 +345,12 @@ export const api = {
// a (session, manifest) without knowing the manifestId in advance.
tenantManifests: (tenantId: string, sessionFilter?: string) =>
`${API_BASE_URL}/api/global/tenants/${encodeURIComponent(tenantId)}/deletion-manifests${qs({ sessionId: sessionFilter })}`,
// Cheap hierarchy-listing endpoint: returns the set of tenant IDs that currently have at
// least one persisted snapshot blob. Powers the Restore Browser "only tenants with restore
// data" checkbox so the dropdown can hide tenants that have nothing to restore.
tenantsWithManifests: () =>
`${API_BASE_URL}/api/global/tenants-with-deletion-manifests`,
},
// ── Hardware Rejection Insights (tenant-scoped, from distress data) ──────