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 { + 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" + /> + + {selectedTenantId && ( diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 94f62e38..05e00819 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -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) ──────