diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs
new file mode 100644
index 00000000..5cdb294f
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantsWithDeletionManifestsFunction.cs
@@ -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
+{
+ ///
+ /// GET /api/global/tenants-with-deletion-manifests — 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.
+ ///
+ /// Cheap: backed by a single hierarchy listing (delimiter="/") 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).
+ ///
+ ///
+ public class GetTenantsWithDeletionManifestsFunction
+ {
+ private readonly ILogger _logger;
+ private readonly BlobStorageService _blob;
+
+ public GetTenantsWithDeletionManifestsFunction(
+ ILogger logger,
+ BlobStorageService blob)
+ {
+ _logger = logger;
+ _blob = blob;
+ }
+
+ [Function("GetTenantsWithDeletionManifests")]
+ public async Task 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;
+ }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
index fbf94bd0..dea05f26 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs
@@ -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),
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
index d4ac1512..91768695 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs
@@ -575,6 +575,43 @@ namespace AutopilotMonitor.Functions.Services
}
}
+ ///
+ /// Returns the set of tenant IDs that currently have at least one persisted snapshot
+ /// blob. Implemented via a single hierarchy listing (delimiter="/") on the
+ /// container — Azure returns only the first-level "virtual folder" prefixes
+ /// ({tenantId}/) without enumerating the blobs underneath, so cost is bounded by
+ /// the number of distinct tenants, not the total manifest count.
+ ///
+ /// 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.
+ ///
+ ///
+ public virtual async Task> ListTenantsWithDeletionManifestsAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var result = new HashSet(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;
+ }
+
///
/// 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
diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx
index f043fe74..94b538be 100644
--- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreBrowserTab.tsx
@@ -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(null);
const [restoring, setRestoring] = useState(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 | 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