diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantDeletionManifestsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantDeletionManifestsFunction.cs new file mode 100644 index 00000000..37f25be2 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetTenantDeletionManifestsFunction.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +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/admin/tenants/{tenantId}/deletion-manifests — file-browser-style listing + /// of every persisted cascade-delete snapshot for a single tenant. Powers the Session Cleanup + /// admin page's "Restore Browser" tab so a Global Admin can pick a (session, manifest) pair + /// without already knowing the manifestId. + /// + /// Cross-tenant access is the design intent (Global Admins acting on cross-tenant recovery + /// tickets); GA-only enforcement comes from EndpointAccessPolicyCatalog. Sessions are + /// returned newest-first so a "just deleted" session surfaces immediately; manifests under a + /// session are also newest-first because re-runs after partial poison are rare and the most + /// recent is almost always the operator's target. + /// + /// + /// No pagination on first cut: the 33-day blob TTL caps the per-tenant scale, typical tenants + /// have a handful of deleted sessions per month. If a tenant ever exceeds a few hundred + /// manifests the cross-partition list response itself stays reasonable (Azure pages internally + /// at 5000 items), and a pagination follow-up can land before the UI starts paging on screen. + /// + /// + public class GetTenantDeletionManifestsFunction + { + private readonly ILogger _logger; + private readonly BlobStorageService _blob; + + public GetTenantDeletionManifestsFunction( + ILogger logger, + BlobStorageService blob) + { + _logger = logger; + _blob = blob; + } + + [Function("GetTenantDeletionManifests")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "admin/tenants/{tenantId}/deletion-manifests")] + HttpRequestData req, + string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId) || !Guid.TryParse(tenantId, out _)) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { success = false, message = "Path parameter 'tenantId' must be a GUID." }); + return bad; + } + + // sessionId-scoped sub-tree probe — handy when the operator opens the browser by + // following a "deletion_started" audit row (sessionId in hand, manifestId not). + var sessionFilter = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty)["sessionId"]; + + // Group entries by sessionId so the UI can render a two-level tree without extra + // bookkeeping. SortedDictionary on the outer level keeps the order deterministic; + // we then re-sort by recency before serializing. + var bySession = new Dictionary>(StringComparer.Ordinal); + try + { + await foreach (var entry in _blob.EnumerateDeletionManifestsByTenantAsync( + tenantId, req.FunctionContext.CancellationToken)) + { + if (!string.IsNullOrEmpty(sessionFilter) + && !string.Equals(entry.SessionId, sessionFilter, StringComparison.Ordinal)) + { + continue; + } + if (!bySession.TryGetValue(entry.SessionId, out var list)) + { + list = new List(); + bySession[entry.SessionId] = list; + } + list.Add(entry); + } + } + catch (Exception ex) + { + _logger.LogError(ex, + "GetTenantDeletionManifests: enumeration failed for tenant={TenantId}", + tenantId); + var err = req.CreateResponse(HttpStatusCode.InternalServerError); + await err.WriteAsJsonAsync(new { success = false, message = "Failed to enumerate manifest blobs." }); + return err; + } + + var sessions = bySession + .Select(kvp => + { + var manifests = kvp.Value + .OrderByDescending(e => e.LastModifiedUtc) + .Select(e => new + { + manifestId = e.ManifestId, + sizeBytes = e.SizeBytes, + lastModifiedUtc = e.LastModifiedUtc.ToString("o"), + }) + .ToList(); + return new + { + sessionId = kvp.Key, + manifestCount = manifests.Count, + // Tree-view sort: newest manifest under a session wins for the session-level + // recency, so "just deleted" sessions float up. + latestManifestUtc = kvp.Value.Max(e => e.LastModifiedUtc).ToString("o"), + manifests, + }; + }) + .OrderByDescending(s => s.latestManifestUtc) + .ToList(); + + return await req.OkAsync(new + { + success = true, + tenantId, + sessionFilter, + sessionCount = sessions.Count, + manifestCount = sessions.Sum(s => s.manifestCount), + sessions, + }); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index a8cabea3..1a163dcf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -216,6 +216,7 @@ public static class EndpointAccessPolicyCatalog new("GET", "admin/sessions/{sessionId}/delete/preview", EndpointPolicy.GlobalAdminOnly), new("POST", "admin/sessions/{sessionId}/restore", EndpointPolicy.GlobalAdminOnly), new("GET", "admin/sessions/{sessionId}/deletion-manifest", EndpointPolicy.GlobalAdminOnly), + new("GET", "admin/tenants/{tenantId}/deletion-manifests", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam), new("GET", "global/session-deletions", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly), new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs index 88e03a6a..d4ac1512 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlobStorageService.cs @@ -527,6 +527,54 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Enumerates every .snapshot.json.gz blob under a single tenant's prefix. Used by + /// the Restore Browser admin page so a Global Admin can pick a session + manifest from a + /// file-browser-style tree without knowing the blob path in advance. Tenant-scoped because + /// listing across the whole container would mix tenants in the UI and pay needless I/O on + /// the (otherwise paginated) container scan. + /// + /// Yields entries with the parsed (sessionId, manifestId) tuple, the byte size of + /// the snapshot, and the LastModified timestamp so the UI can rank by recency. Malformed + /// blob names are logged + skipped (same contract as the maintenance enumerator). + /// + /// + public virtual async IAsyncEnumerable EnumerateDeletionManifestsByTenantAsync( + string tenantId, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("tenantId is required", nameof(tenantId)); + + var containerClient = _blobServiceClient.GetBlobContainerClient(DeletionManifestsContainer); + if (!await containerClient.ExistsAsync(cancellationToken)) + yield break; + + // Prefix matches the BuildSnapshotBlobName convention. The trailing slash bounds the + // match so tenant "abc" doesn't also pull "abcde/...". + var prefix = tenantId + "/"; + await foreach (var item in containerClient.GetBlobsAsync( + BlobTraits.None, BlobStates.None, prefix: prefix, cancellationToken: cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Drive off snapshot blobs only — progress blobs are mutable companions to snapshots + // and the UI fetches them lazily on selection via DownloadDeletionProgressAsync. + if (!item.Name.EndsWith(".snapshot.json.gz", StringComparison.Ordinal)) continue; + if (!TryParseManifestBlobName(item.Name, out var parsedTenant, out var sessionId, out var manifestId)) + { + _logger.LogWarning("EnumerateDeletionManifestsByTenantAsync: skipping malformed blob name {Name}", item.Name); + continue; + } + // Defensive — the prefix already filters by tenant, but a corrupted path with the + // tenant prefix yet a different first segment shouldn't leak into the response. + if (!string.Equals(parsedTenant, tenantId, StringComparison.Ordinal)) continue; + + var lastModified = item.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow; + var sizeBytes = item.Properties.ContentLength ?? 0L; + yield return new TenantDeletionManifestEntry(sessionId, manifestId, sizeBytes, lastModified); + } + } + /// /// 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 @@ -592,6 +640,28 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// Yielded by . The + /// tenantId is implied by the caller's filter, so it isn't repeated on each entry. Size + + /// timestamp let the Restore Browser sort and group manifests without needing a follow-up + /// HEAD on each blob. + /// + public sealed class TenantDeletionManifestEntry + { + public string SessionId { get; } + public string ManifestId { get; } + public long SizeBytes { get; } + public DateTime LastModifiedUtc { get; } + + public TenantDeletionManifestEntry(string sessionId, string manifestId, long sizeBytes, DateTime lastModifiedUtc) + { + SessionId = sessionId; + ManifestId = manifestId; + SizeBytes = sizeBytes; + LastModifiedUtc = lastModifiedUtc; + } + } + /// /// Pointer record returned by . /// Producer (PR3) persists this in the audit log + uses the SHA to bind the snapshot to its diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx new file mode 100644 index 00000000..c8ad4da8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionManifestSummaryView.tsx @@ -0,0 +1,268 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; + +/** + * Reusable stored-manifest summary block. Used both inside the modal (DeletionPreviewModal, + * triggered from the In-Flight / Poisoned / Stranded tabs) and inline in the Restore Browser + * tab's right-hand preview panel. Fetches `mode=summary` on its own and renders the same + * counts + progress + last-failure breakdown either way. + */ +export interface DeletionManifestSummaryViewProps { + tenantId: string; + sessionId: string; + manifestId: string; + getAccessToken: (forceRefresh?: boolean) => Promise; + /** Renders an extra "Download snapshot" button below the summary when true. */ + showDownload?: boolean; +} + +interface ProgressView { + snapshotSha256?: string; + completedStepOrders?: number[]; + verificationDone?: boolean; + tombstoneStarted?: boolean; + completedAt?: string | null; + aggregateDecrementsApplied?: number; + restoreReIncrementsApplied?: number; + lastFailureType?: string | null; + lastFailureMessage?: string | null; + lastObservedResidualCount?: number | null; + lastResidualSampleJson?: string | null; +} + +interface StoredManifestSummary { + manifestId?: string; + schemaHash?: string; + snapshotSha256?: string; + estimatedRowCount?: number; + estimatedSnapshotBytes?: number; + preflightCounts?: Record; + sampleKeys?: Record>; + progress?: ProgressView | null; + source?: string; +} + +/** Matches CascadeVerificationService.MaxResidualSampleSize on the backend. */ +const VERIFIER_OBSERVATION_CAP = 50; + +export function DeletionManifestSummaryView({ + tenantId, + sessionId, + manifestId, + getAccessToken, + showDownload = true, +}: DeletionManifestSummaryViewProps) { + const [summary, setSummary] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [downloading, setDownloading] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setSummary(null); + (async () => { + try { + const resp = await authenticatedFetch( + api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "summary"), + getAccessToken, + ); + if (!resp.ok) { + const detail = await resp.text().catch(() => ""); + throw new Error(`Stored-manifest request failed: HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`); + } + const json = (await resp.json()) as StoredManifestSummary; + if (!cancelled) setSummary(json); + } catch (err) { + if (cancelled) return; + if (err instanceof TokenExpiredError) { + setError("Session expired; reload the page and try again."); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [tenantId, sessionId, manifestId, getAccessToken]); + + const handleDownload = async () => { + setDownloading(true); + setError(null); + try { + const resp = await authenticatedFetch( + api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "download"), + getAccessToken, + ); + if (!resp.ok) throw new Error(`Download failed: HTTP ${resp.status}`); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${tenantId}_${sessionId}_${manifestId}.snapshot.json.gz`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (err) { + if (err instanceof TokenExpiredError) { + setError("Session expired; reload the page and try again."); + } else { + setError(err instanceof Error ? err.message : String(err)); + } + } finally { + setDownloading(false); + } + }; + + const progress = summary?.progress ?? null; + const completedCount = progress?.completedStepOrders?.length ?? 0; + const failureSample = (() => { + if (!progress?.lastResidualSampleJson) return null; + try { + const arr = JSON.parse(progress.lastResidualSampleJson) as Array<{ table: string; pk: string; rk: string }>; + return Array.isArray(arr) ? arr : null; + } catch { + return null; + } + })(); + + if (loading) { + return

Loading stored manifest…

; + } + if (error) { + return ( +
+ {error} +
+ ); + } + if (!summary) return null; + + return ( +
+
+ + + + +
+ + {progress && ( +
+

Worker progress

+
+ + Completed steps: {completedCount} + + + Verification done: {progress.verificationDone ? "yes" : "no"} + + + Tombstone started: {progress.tombstoneStarted ? "yes" : "no"} + + + Aggregate decrements: {progress.aggregateDecrementsApplied ?? 0} + +
+ {progress.lastFailureType && ( +
+
+ Last failure: {progress.lastFailureType} +
+ {progress.lastFailureMessage &&
{progress.lastFailureMessage}
} + {failureSample && failureSample.length > 0 && (() => { + const sampleSize = failureSample.length; + const observed = progress.lastObservedResidualCount ?? sampleSize; + const capHit = observed >= VERIFIER_OBSERVATION_CAP; + return ( +
+ + Residual sample ({sampleSize} of {observed} + {capHit ? "+" : ""} observed row{observed === 1 ? "" : "s"}) + +
    + {failureSample.slice(0, 20).map((r, i) => ( +
  • + {r.table}: {r.pk} / {r.rk} +
  • + ))} +
+ {capHit && ( +

+ The verifier short-circuits at {VERIFIER_OBSERVATION_CAP} rows per table and after the first + failing table — the real residual count may be higher. Use the cascade-restore path to recover; + do not rely on this number as an exact blast-radius estimate. +

+ )} +
+ ); + })()} +
+ )} +
+ )} + + {summary.preflightCounts && ( +
+

Rows in snapshot (by table)

+
+ + + + + + + + + {Object.entries(summary.preflightCounts) + .sort(([, a], [, b]) => b - a) + .map(([table, count]) => ( + + + + + ))} + +
TableCount
{table}{count}
+
+
+ )} + + {showDownload && ( + + )} +
+ ); +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionPreviewModal.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionPreviewModal.tsx index 99c5f295..b760db22 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionPreviewModal.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/DeletionPreviewModal.tsx @@ -1,8 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; -import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { DeletionManifestSummaryView } from "./DeletionManifestSummaryView"; interface DeletionPreviewModalProps { tenantId: string; @@ -12,43 +10,12 @@ interface DeletionPreviewModalProps { getAccessToken: (forceRefresh?: boolean) => Promise; } -interface ProgressView { - snapshotSha256?: string; - completedStepOrders?: number[]; - verificationDone?: boolean; - tombstoneStarted?: boolean; - completedAt?: string | null; - aggregateDecrementsApplied?: number; - restoreReIncrementsApplied?: number; - lastFailureType?: string | null; - lastFailureMessage?: string | null; - /** - * Verifier's OBSERVED residual count — capped at 50 per table and short-circuited at the - * first failing table. When this equals 50 the real residual mountain is likely larger. - */ - lastObservedResidualCount?: number | null; - /** JSON array of {table, pk, rk}, capped at the residual sample size (50). */ - lastResidualSampleJson?: string | null; -} - /** - * Matches the verifier cap in `CascadeVerificationService.MaxResidualSampleSize`. When the - * observed count hits this value the operator should treat the number as a lower bound. + * Modal wrapper around {@link DeletionManifestSummaryView}. Invoked from the In-Flight / + * Poisoned / Stranded tabs when an operator clicks "Preview" on a row. Restore Browser tab + * embeds the same summary view inline in its right-hand panel — that's why the body is its + * own reusable component. */ -const VERIFIER_OBSERVATION_CAP = 50; - -interface StoredManifestSummary { - manifestId?: string; - schemaHash?: string; - snapshotSha256?: string; - estimatedRowCount?: number; - estimatedSnapshotBytes?: number; - preflightCounts?: Record; - sampleKeys?: Record>; - progress?: ProgressView | null; - source?: string; -} - export function DeletionPreviewModal({ tenantId, sessionId, @@ -56,86 +23,6 @@ export function DeletionPreviewModal({ onClose, getAccessToken, }: DeletionPreviewModalProps) { - const [summary, setSummary] = useState(null); - const [error, setError] = useState(null); - const [loading, setLoading] = useState(true); - const [downloading, setDownloading] = useState(false); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const resp = await authenticatedFetch( - api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "summary"), - getAccessToken, - ); - if (!resp.ok) { - const detail = await resp.text().catch(() => ""); - throw new Error(`Stored-manifest request failed: HTTP ${resp.status}${detail ? ` — ${detail.slice(0, 200)}` : ""}`); - } - const json = (await resp.json()) as StoredManifestSummary; - if (!cancelled) setSummary(json); - } catch (err) { - if (cancelled) return; - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { - cancelled = true; - }; - }, [tenantId, sessionId, manifestId, getAccessToken]); - - const handleDownload = async () => { - setDownloading(true); - setError(null); - try { - const resp = await authenticatedFetch( - api.sessionDeletions.storedManifest(sessionId, tenantId, manifestId, "download"), - getAccessToken, - ); - if (!resp.ok) { - throw new Error(`Download failed: HTTP ${resp.status}`); - } - const blob = await resp.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `${tenantId}_${sessionId}_${manifestId}.snapshot.json.gz`; - document.body.appendChild(a); - a.click(); - a.remove(); - // Browsers keep the Blob alive until the next gc once the object URL is revoked; - // a small timeout keeps Firefox happy on slower disks. - setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (err) { - if (err instanceof TokenExpiredError) { - setError("Session expired; reload the page and try again."); - } else { - setError(err instanceof Error ? err.message : String(err)); - } - } finally { - setDownloading(false); - } - }; - - const progress = summary?.progress ?? null; - const completedCount = progress?.completedStepOrders?.length ?? 0; - const failureSample = (() => { - if (!progress?.lastResidualSampleJson) return null; - try { - const arr = JSON.parse(progress.lastResidualSampleJson) as Array<{ table: string; pk: string; rk: string }>; - return Array.isArray(arr) ? arr : null; - } catch { - return null; - } - })(); - return (
-
- {loading &&

Loading stored manifest…

} - {error && ( -
- {error} -
- )} - {!loading && !error && summary && ( - <> -
- - - - -
- - {progress && ( -
-

Worker progress

-
- Completed steps: {completedCount} - Verification done: {progress.verificationDone ? "yes" : "no"} - Tombstone started: {progress.tombstoneStarted ? "yes" : "no"} - Aggregate decrements: {progress.aggregateDecrementsApplied ?? 0} -
- {progress.lastFailureType && ( -
-
Last failure: {progress.lastFailureType}
- {progress.lastFailureMessage && ( -
{progress.lastFailureMessage}
- )} - {failureSample && failureSample.length > 0 && (() => { - const sampleSize = failureSample.length; - const observed = progress.lastObservedResidualCount ?? sampleSize; - const capHit = observed >= VERIFIER_OBSERVATION_CAP; - return ( -
- - Residual sample ({sampleSize} of {observed}{capHit ? "+" : ""} observed row{observed === 1 ? "" : "s"}) - -
    - {failureSample.slice(0, 20).map((r, i) => ( -
  • {r.table}: {r.pk} / {r.rk}
  • - ))} -
- {capHit && ( -

- The verifier short-circuits at {VERIFIER_OBSERVATION_CAP} rows per - table and after the first failing table — the real residual count - may be higher. Use the cascade-restore path to recover; do not rely - on this number as an exact blast-radius estimate. -

- )} -
- ); - })()} -
- )} -
- )} - - {summary.preflightCounts && ( -
-

Rows in snapshot (by table)

-
- - - - - - - - - {Object.entries(summary.preflightCounts).map(([table, count]) => ( - - - - - ))} - -
TableCount
{table}{count}
-
-
- )} - - )} +
+
- +
+
+ + {selectedTenantId && ( +
+ {/* Left: session/manifest tree */} +
+
+

+ Sessions + {visibleSessions && ( + + ({visibleSessions.length} session{visibleSessions.length === 1 ? "" : "s"},{" "} + {visibleSessions.reduce((sum, s) => sum + s.manifestCount, 0)} manifest + {visibleSessions.reduce((sum, s) => sum + s.manifestCount, 0) === 1 ? "" : "s"}) + + )} +

+
+ + +
+
+
+ {loadingSessions && ( +

Loading manifests…

+ )} + {!loadingSessions && visibleSessions && visibleSessions.length === 0 && ( +

+ No stored manifests for this tenant. Either no cascades have run, or all blobs have aged out of the + 33-day retention window. +

+ )} + {!loadingSessions && visibleSessions && visibleSessions.length > 0 && ( +
    + {visibleSessions.map((s) => { + const expanded = expandedSessions.has(s.sessionId); + const sessionAgeMin = ageMinutes(s.latestManifestUtc); + return ( +
  • + + {expanded && ( +
      + {s.manifests.map((m) => { + const isSelected = + selection != null && + selection.tenantId === selectedTenantId && + selection.sessionId === s.sessionId && + selection.manifestId === m.manifestId; + return ( +
    • + +
    • + ); + })} +
    + )} +
  • + ); + })} +
+ )} +
+
+ + {/* Right: preview + restore action */} +
+
+

Preview

+ {selection && ( + + )} +
+
+ {!selection && ( +

+ Select a manifest on the left to inspect its preflight counts, worker progress, and + last failure (if any). The Restore… button opens the same dry-run + restore dialog used by the Poisoned tab. +

+ )} + {selection && ( + + )} +
+
+
+ )} + + + {restoring && ( + setRestoring(null)} + onRestored={() => { + setSuccessMessage(`Session ${restoring.sessionId} restore submitted.`); + setRefreshKey((k) => k + 1); + }} + getAccessToken={getAccessToken} + /> + )} + + ); +} + +function ageMinutes(iso: string): number { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return 0; + return Math.max(0, Math.floor((Date.now() - then) / 60_000)); +} + +function formatAge(minutes: number): string { + if (minutes < 60) return `${minutes}m ago`; + if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h ago`; + const days = Math.floor(minutes / (60 * 24)); + return `${days}d ago`; +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx index 4f578113..1c21c4e1 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/components/RestoreConfirmDialog.tsx @@ -13,6 +13,19 @@ interface RestoreConfirmDialogProps { getAccessToken: (forceRefresh?: boolean) => Promise; } +interface RestoreResultView { + outcome: string; + mode: string | null; + message: string | null; + currentState: string | null; + pendingManifestId: string | null; + rowsRestoredByTable: Record; + rowsSkippedByTable: Record; + wouldRestoreByTable: Record; + inventoryReIncrements: number; + durationMs: number; +} + export function RestoreConfirmDialog({ tenantId, sessionId, @@ -22,15 +35,16 @@ export function RestoreConfirmDialog({ getAccessToken, }: RestoreConfirmDialogProps) { const [reason, setReason] = useState(""); - const [dryRun, setDryRun] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const [outcome, setOutcome] = useState<{ outcome: string; mode: string | null; dryRun: boolean } | null>(null); + // Two-slot result store so the operator can compare predicted (dry-run) vs actual (real-run) + // in the same modal. Clearing both via "Start over" lets them re-run from scratch. + const [dryRunResult, setDryRunResult] = useState(null); + const [realRunResult, setRealRunResult] = useState(null); - const handleSubmit = async () => { + const submit = async (dryRun: boolean) => { setSubmitting(true); setError(null); - setOutcome(null); try { const resp = await authenticatedFetch( api.sessionDeletions.restore(sessionId), @@ -38,11 +52,6 @@ export function RestoreConfirmDialog({ { method: "POST", headers: { "Content-Type": "application/json" }, - // Codex F1: explicit tenantId is mandatory for cross-tenant GA restores. - // Codex follow-up: backend selects the restore mode automatically based on the - // cascade state (Full / Partial); operators cannot — and must not — force a Full - // restore against a Poisoned session or vice versa. The reason is persisted into - // the deletion_restored audit row as `details.reason`. body: JSON.stringify({ tenantId, manifestId, @@ -55,13 +64,11 @@ export function RestoreConfirmDialog({ if (!resp.ok) { throw new Error(body?.message ?? `Restore failed: HTTP ${resp.status}`); } - setOutcome({ - outcome: body?.outcome ?? "Completed", - mode: typeof body?.mode === "string" ? body.mode : null, - dryRun, - }); - if (!dryRun) { - // Real restore done — let the parent refresh its list. + const view = parseRestoreResult(body); + if (dryRun) { + setDryRunResult(view); + } else { + setRealRunResult(view); onRestored(); } } catch (err) { @@ -75,24 +82,32 @@ export function RestoreConfirmDialog({ } }; + const handleReset = () => { + setDryRunResult(null); + setRealRunResult(null); + setError(null); + }; + + const hasResults = dryRunResult != null || realRunResult != null; + return (
e.stopPropagation()} > -
-
+
+
-
+

Restore session from snapshot

-

{tenantId} / {sessionId}

+

{tenantId} / {sessionId}

@@ -106,7 +121,7 @@ export function RestoreConfirmDialog({
  • Full — Completed cascade (Sessions row gone): inserts every row from the snapshot and resets DeletionState=None.
  • - Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with dry-run first to confirm the predicted mode. + Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with dry-run first to confirm the predicted mode + row counts.

    @@ -120,35 +135,17 @@ export function RestoreConfirmDialog({ onChange={(e) => setReason(e.target.value)} placeholder="e.g. customer support — accidental delete" maxLength={200} - 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-blue-500" + disabled={submitting} + 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-blue-500 disabled:opacity-60" />

    - Stored on the deletion_restored audit row. + Stored on the deletion_restored audit row when the real restore runs. Dry-runs are intentionally audit-free.

    - + {dryRunResult && } + {realRunResult && } - {outcome && ( -
    - Outcome: {outcome.outcome} - {outcome.mode && ( - <> · Mode (auto-selected): {outcome.mode} - )} - {outcome.dryRun ? " · dry-run — no rows written" : ""} -
    - )} {error && (
    {error} @@ -156,7 +153,16 @@ export function RestoreConfirmDialog({ )}
    -
    +
    + {hasResults && ( + + )} - + {realRunResult == null && ( + + )} + {realRunResult == null && ( + + )}
    ); } + +function ResultSection({ + title, + tone, + result, + kind, +}: { + title: string; + tone: "blue" | "green"; + result: RestoreResultView; + kind: "dryRun" | "real"; +}) { + const toneClasses = tone === "blue" + ? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700" + : "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700"; + const titleColor = tone === "blue" + ? "text-blue-900 dark:text-blue-100" + : "text-green-900 dark:text-green-100"; + + const tableMap = kind === "dryRun" ? result.wouldRestoreByTable : result.rowsRestoredByTable; + const tableLabel = kind === "dryRun" ? "Would restore by table" : "Rows restored by table"; + const total = Object.values(tableMap).reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0); + const skippedTotal = Object.values(result.rowsSkippedByTable).reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0); + + return ( +
    +
    +

    {title}

    + + Outcome: {result.outcome} + {result.mode && <> · Mode: {result.mode}} + <> · {result.durationMs} ms + +
    + + {result.message && ( +

    {result.message}

    + )} + + {(result.currentState || result.pendingManifestId) && ( +
    + {result.currentState && ( + Current state: {result.currentState} + )} + {result.pendingManifestId && ( + Pending: {result.pendingManifestId} + )} +
    + )} + +
    +

    + {tableLabel} ({total} {total === 1 ? "row" : "rows"} total{skippedTotal > 0 ? `, ${skippedTotal} skipped` : ""}) +

    + {Object.keys(tableMap).length > 0 ? ( + + + {Object.entries(tableMap) + .sort(([, a], [, b]) => b - a) + .map(([table, count]) => ( + + + + + ))} + +
    {table}{count}
    + ) : ( +

    — no tables touched

    + )} +
    + + {result.inventoryReIncrements > 0 && ( +

    + {kind === "dryRun" ? "Would re-increment" : "Re-incremented"} {result.inventoryReIncrements} software-inventory counter{result.inventoryReIncrements === 1 ? "" : "s"}. +

    + )} +
    + ); +} + +function parseRestoreResult(body: Record): RestoreResultView { + return { + outcome: typeof body.outcome === "string" ? body.outcome : "Unknown", + mode: typeof body.mode === "string" ? body.mode : null, + message: typeof body.message === "string" ? body.message : null, + currentState: typeof body.currentState === "string" ? body.currentState : null, + pendingManifestId: typeof body.pendingManifestId === "string" ? body.pendingManifestId : null, + rowsRestoredByTable: isStringNumberMap(body.rowsRestoredByTable) ? body.rowsRestoredByTable : {}, + rowsSkippedByTable: isStringNumberMap(body.rowsSkippedByTable) ? body.rowsSkippedByTable : {}, + wouldRestoreByTable: isStringNumberMap(body.wouldRestoreByTable) ? body.wouldRestoreByTable : {}, + inventoryReIncrements: typeof body.inventoryReIncrements === "number" ? body.inventoryReIncrements : 0, + durationMs: typeof body.durationMs === "number" ? body.durationMs : 0, + }; +} + +function isStringNumberMap(value: unknown): value is Record { + return ( + typeof value === "object" + && value !== null + && !Array.isArray(value) + && Object.values(value).every((v) => typeof v === "number") + ); +} diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx index 03d84f2f..bb54348d 100644 --- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx @@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch" import { useAdminConfig } from "../../AdminConfigContext"; import { AdminNotifications } from "../../AdminNotifications"; import { DeletionPreviewModal } from "./components/DeletionPreviewModal"; +import { RestoreBrowserTab } from "./components/RestoreBrowserTab"; import { RestoreConfirmDialog } from "./components/RestoreConfirmDialog"; type CascadeState = "Preparing" | "Queued" | "Running" | "Poisoned"; @@ -28,7 +29,7 @@ interface SessionDeletionListResponse { sessions: SessionDeletionRow[]; } -type Tab = "in-flight" | "poisoned" | "stranded" | "activity"; +type Tab = "in-flight" | "poisoned" | "stranded" | "restore-browser" | "activity"; const STRANDED_THRESHOLD_MINUTES = 30; @@ -64,6 +65,9 @@ export default function SessionCleanupPage() { setActiveTab("stranded")}> Stranded Queued + setActiveTab("restore-browser")}> + Restore Browser + setActiveTab("activity")}> Activity @@ -87,6 +91,13 @@ export default function SessionCleanupPage() { {activeTab === "stranded" && ( )} + {activeTab === "restore-browser" && ( + + )} {activeTab === "activity" && }
    @@ -285,7 +296,10 @@ function PoisonedTab({ manifestId={restoring.manifestId} onClose={() => setRestoring(null)} onRestored={() => { - setRestoring(null); + // Codex P3: do NOT close the dialog here — the operator wants to see the real-run + // result counts beside the dry-run preview before dismissing. The user closes the + // dialog explicitly via Close / Start over. Background refresh of the Poisoned list + // happens immediately so the row drops out when they look behind the modal. setSuccessMessage(`Session ${restoring.sessionId} restore submitted.`); setRefreshKey((k) => k + 1); }} diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx index 3a35d36f..a2e76002 100644 --- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx +++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx @@ -516,10 +516,22 @@ export function GlobalSidebar({ children }: { children: ReactNode }) { - {isExpanded && ( + {isExpanded && (() => { + // Pick the longest sub.href that matches the current pathname so a + // shorter sibling whose href is a strict prefix of the active route + // doesn't also light up. Example: with /admin/ops (Maintenance) and + // /admin/ops/session-cleanup, navigating to the latter previously + // marked BOTH active. Computed once per render and applied per sub. + const winningSubHref = expandItem.items + .filter((s) => pathname === s.href || pathname.startsWith(s.href + "/")) + .reduce( + (longest, s) => longest == null || s.href.length > longest.length ? s.href : longest, + null, + ); + return (
      {expandItem.items.map((sub) => { - const subActive = pathname === sub.href || pathname.startsWith(sub.href + "/"); + const subActive = sub.href === winningSubHref; return (
    • - )} + ); + })()}
    • ); })} diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 4960e42a..327a9d05 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -337,6 +337,11 @@ export const api = { `${API_BASE_URL}/api/admin/sessions/${encodeURIComponent(sessionId)}/delete/preview${qs({ tenantId, mode })}`, restore: (sessionId: string) => `${API_BASE_URL}/api/admin/sessions/${encodeURIComponent(sessionId)}/restore`, + // File-browser endpoint: enumerates every persisted manifest blob for one tenant, grouped + // by sessionId, sorted by recency. Powers the Restore Browser tab so an operator can pick + // a (session, manifest) without knowing the manifestId in advance. + tenantManifests: (tenantId: string, sessionFilter?: string) => + `${API_BASE_URL}/api/admin/tenants/${encodeURIComponent(tenantId)}/deletion-manifests${qs({ sessionId: sessionFilter })}`, }, // ── Hardware Rejection Insights (tenant-scoped, from distress data) ──────