mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
+63
@@ -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
|
||||
|
||||
+95
-5
@@ -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 && (
|
||||
|
||||
@@ -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) ──────
|
||||
|
||||
Reference in New Issue
Block a user