feat(backend,web): restore-browser tab + detailed dry-run/real-run dialog

Follow-ups on the Session Cleanup admin page after first hands-on review:
an entry point for restoring already-completed cascades (Sessions row gone,
manifest still in the 33-day TTL window), a proper outcome panel that surfaces
what the backend already returned, and a sidebar fix.

== New Restore Browser tab ==
- Backend: GET /api/admin/tenants/{tenantId}/deletion-manifests enumerates
  every persisted snapshot blob under <tenantId>/, parses (sessionId,
  manifestId, sizeBytes, lastModifiedUtc) from the blob name, groups by
  sessionId and sorts both levels by recency. GA-only + TenantScoping.RouteParam
  in EndpointAccessPolicyCatalog (pinned by EndpointPolicyCatalogCompletenessTests).
- BlobStorageService.EnumerateDeletionManifestsByTenantAsync — sibling to
  the existing maintenance-sweep enumerator but tenant-prefixed and yielding
  size + timestamp so the UI can rank without an extra HEAD per blob.
- Web: new tab "Restore Browser" between Stranded and Activity. Tenant
  dropdown (sourced from AdminConfigContext.tenants) → collapsible
  session/manifest tree on the left, manifest summary on the right with a
  Restore… button that opens the dialog with IDs pre-filled. Useful when
  the cascade completed cleanly and the row no longer appears on the
  In-Flight / Poisoned / Stranded tabs.

== Reusable summary view ==
DeletionManifestSummaryView extracted from the modal so the same fetch +
field block + worker-progress + preflight-counts + last-failure panel renders
both inside the modal (Preview button on the other tabs) and inline on the
Restore Browser's right-hand panel. Modal becomes a thin wrapper.

== Dry-run / real-run side-by-side ==
RestoreConfirmDialog now keeps both outcomes visible:
- Two slots (dryRunResult, realRunResult), each rendered as a full result
  section with predicted/actual rows-by-table, inventory re-increments,
  duration, backend message, current state, pending manifest id.
- "Start over" resets both slots so the operator can re-run cleanly.
- Submit buttons gate the workflow: pre-submit shows "Run dry-run" + an
  explicit "Skip dry-run · Restore now"; after dry-run the second button
  becomes "Apply restore" (clear-intent); after real-run all submit
  buttons disappear so accidental double-applies aren't possible.
Previously the dialog discarded everything but Outcome + Mode + dryRun
flag, hiding the actual point of dry-run (the wouldRestoreByTable map).

== Codex review follow-ups (this round) ==
- P2: Restore Browser cleared the displayed sessions only after the new
  fetch returned, so a fast A→B switch (or out-of-order responses) could
  leave the operator looking at tenant A's manifests with the dropdown
  saying B. Fix: clear sessions/expandedSessions/selection synchronously
  on tenant change, abort the in-flight request via AbortController,
  pair the response with a loadedTenantId state, and gate rendering on
  loadedTenantId === selectedTenantId. Three-layer protection so a late
  response that sneaks past the abort can't paint stale data.
- P3: PoisonedTab's onRestored callback closed the dialog immediately
  after a real restore, defeating the new dry-run vs real-run side-by-side
  display. Fix: drop setRestoring(null) from the callback so the dialog
  stays open until the operator dismisses it via Close / Start over.
  Background refresh of the Poisoned list still happens via setRefreshKey.
  Now consistent with the Restore Browser tab.

== Sidebar highlight fix ==
GlobalSidebar marked BOTH ga-ops sub-items active when on the longer
nested route, because each sub used pathname.startsWith(sub.href + "/")
independently. Visible bug: navigating to /admin/ops/session-cleanup lit
up both "Maintenance" (/admin/ops) and "Session Cleanup" in the sidebar.
Fix: compute the longest-matching sub.href once per expanded group, mark
only that one active.

Tests: backend xUnit 1538/1538 pass (one new endpoint registered in the
catalog, picked up by the existing completeness test). Web vitest 347/347
pass. TypeScript strict + dotnet build clean.

Deliberately deferred:
- xUnit for GetTenantDeletionManifestsFunction (same HttpRequestData
  mock-harness gap as the other read endpoints).
- vitest for RestoreBrowserTab + the new dialog flow (defer until a
  real field issue).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-13 21:48:30 +02:00
parent fa8ad38ed1
commit 44076686b6
10 changed files with 1080 additions and 297 deletions
@@ -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
{
/// <summary>
/// <c>GET /api/admin/tenants/{tenantId}/deletion-manifests</c> — 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.
/// <para>
/// Cross-tenant access is the design intent (Global Admins acting on cross-tenant recovery
/// tickets); GA-only enforcement comes from <c>EndpointAccessPolicyCatalog</c>. 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public class GetTenantDeletionManifestsFunction
{
private readonly ILogger<GetTenantDeletionManifestsFunction> _logger;
private readonly BlobStorageService _blob;
public GetTenantDeletionManifestsFunction(
ILogger<GetTenantDeletionManifestsFunction> logger,
BlobStorageService blob)
{
_logger = logger;
_blob = blob;
}
[Function("GetTenantDeletionManifests")]
public async Task<HttpResponseData> 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<string, List<TenantDeletionManifestEntry>>(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<TenantDeletionManifestEntry>();
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,
});
}
}
}
@@ -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),
@@ -527,6 +527,54 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Enumerates every <c>.snapshot.json.gz</c> 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.
/// <para>
/// Yields entries with the parsed <c>(sessionId, manifestId)</c> 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).
/// </para>
/// </summary>
public virtual async IAsyncEnumerable<TenantDeletionManifestEntry> 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);
}
}
/// <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
@@ -592,6 +640,28 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Yielded by <see cref="BlobStorageService.EnumerateDeletionManifestsByTenantAsync"/>. 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.
/// </summary>
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;
}
}
/// <summary>
/// Pointer record returned by <see cref="BlobStorageService.UploadDeletionManifestAsync"/>.
/// Producer (PR3) persists this in the audit log + uses the SHA to bind the snapshot to its
@@ -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<string | null>;
/** 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<string, number>;
sampleKeys?: Record<string, Array<{ pk: string; rk: string }>>;
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<StoredManifestSummary | null>(null);
const [error, setError] = useState<string | null>(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 <p className="text-sm text-gray-600 dark:text-gray-400">Loading stored manifest…</p>;
}
if (error) {
return (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-sm text-red-700 dark:text-red-200">
{error}
</div>
);
}
if (!summary) return null;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label="Manifest" value={summary.manifestId ?? manifestId} mono />
<Field label="Schema hash" value={summary.schemaHash ?? "—"} mono />
<Field label="Snapshot SHA-256" value={summary.snapshotSha256 ?? "—"} mono />
<Field
label="Snapshot size"
value={summary.estimatedSnapshotBytes != null ? formatBytes(summary.estimatedSnapshotBytes) : "—"}
/>
</div>
{progress && (
<div className="border border-purple-200 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded p-3 text-sm">
<h3 className="text-sm font-semibold text-purple-900 dark:text-purple-100 mb-2">Worker progress</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<span className="text-gray-700 dark:text-gray-300">
Completed steps: <strong>{completedCount}</strong>
</span>
<span className="text-gray-700 dark:text-gray-300">
Verification done: <strong>{progress.verificationDone ? "yes" : "no"}</strong>
</span>
<span className="text-gray-700 dark:text-gray-300">
Tombstone started: <strong>{progress.tombstoneStarted ? "yes" : "no"}</strong>
</span>
<span className="text-gray-700 dark:text-gray-300">
Aggregate decrements: <strong>{progress.aggregateDecrementsApplied ?? 0}</strong>
</span>
</div>
{progress.lastFailureType && (
<div className="mt-3 p-2 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-800 dark:text-red-100">
<div>
<strong>Last failure:</strong> <code>{progress.lastFailureType}</code>
</div>
{progress.lastFailureMessage && <div className="mt-1 break-words">{progress.lastFailureMessage}</div>}
{failureSample && failureSample.length > 0 && (() => {
const sampleSize = failureSample.length;
const observed = progress.lastObservedResidualCount ?? sampleSize;
const capHit = observed >= VERIFIER_OBSERVATION_CAP;
return (
<details className="mt-2">
<summary className="cursor-pointer text-red-700 dark:text-red-200">
Residual sample ({sampleSize} of {observed}
{capHit ? "+" : ""} observed row{observed === 1 ? "" : "s"})
</summary>
<ul className="mt-1 pl-4 list-disc font-mono break-all">
{failureSample.slice(0, 20).map((r, i) => (
<li key={i}>
{r.table}: {r.pk} / {r.rk}
</li>
))}
</ul>
{capHit && (
<p className="mt-2 text-xs italic text-red-700 dark:text-red-200">
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.
</p>
)}
</details>
);
})()}
</div>
)}
</div>
)}
{summary.preflightCounts && (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Rows in snapshot (by table)</h3>
<div className="border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="text-left px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Table</th>
<th className="text-right px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Count</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{Object.entries(summary.preflightCounts)
.sort(([, a], [, b]) => b - a)
.map(([table, count]) => (
<tr key={table}>
<td className="px-3 py-2 font-mono text-xs text-gray-800 dark:text-gray-200">{table}</td>
<td className="px-3 py-2 text-right text-gray-800 dark:text-gray-200">{count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{showDownload && (
<button
onClick={handleDownload}
disabled={downloading}
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
{downloading ? "Downloading…" : "Download snapshot"}
</button>
)}
</div>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">{label}</p>
<p className={`text-sm text-gray-900 dark:text-white ${mono ? "font-mono break-all" : ""}`}>{value}</p>
</div>
);
}
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`;
}
@@ -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<string | null>;
}
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<string, number>;
sampleKeys?: Record<string, Array<{ pk: string; rk: string }>>;
progress?: ProgressView | null;
source?: string;
}
export function DeletionPreviewModal({
tenantId,
sessionId,
@@ -56,86 +23,6 @@ export function DeletionPreviewModal({
onClose,
getAccessToken,
}: DeletionPreviewModalProps) {
const [summary, setSummary] = useState<StoredManifestSummary | null>(null);
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
@@ -167,113 +54,17 @@ export function DeletionPreviewModal({
</button>
</div>
<div className="px-6 py-4 space-y-4">
{loading && <p className="text-sm text-gray-600 dark:text-gray-400">Loading stored manifest…</p>}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-sm text-red-700 dark:text-red-200">
{error}
</div>
)}
{!loading && !error && summary && (
<>
<div className="grid grid-cols-2 gap-3 text-sm">
<Field label="Manifest" value={summary.manifestId ?? manifestId} mono />
<Field label="Schema hash" value={summary.schemaHash ?? "—"} mono />
<Field
label="Snapshot SHA-256"
value={summary.snapshotSha256 ?? "—"}
mono
/>
<Field
label="Snapshot size"
value={
summary.estimatedSnapshotBytes != null
? formatBytes(summary.estimatedSnapshotBytes)
: "—"
}
/>
</div>
{progress && (
<div className="border border-purple-200 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded p-3 text-sm">
<h3 className="text-sm font-semibold text-purple-900 dark:text-purple-100 mb-2">Worker progress</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<span className="text-gray-700 dark:text-gray-300">Completed steps: <strong>{completedCount}</strong></span>
<span className="text-gray-700 dark:text-gray-300">Verification done: <strong>{progress.verificationDone ? "yes" : "no"}</strong></span>
<span className="text-gray-700 dark:text-gray-300">Tombstone started: <strong>{progress.tombstoneStarted ? "yes" : "no"}</strong></span>
<span className="text-gray-700 dark:text-gray-300">Aggregate decrements: <strong>{progress.aggregateDecrementsApplied ?? 0}</strong></span>
</div>
{progress.lastFailureType && (
<div className="mt-3 p-2 bg-red-100 dark:bg-red-900/40 rounded text-xs text-red-800 dark:text-red-100">
<div><strong>Last failure:</strong> <code>{progress.lastFailureType}</code></div>
{progress.lastFailureMessage && (
<div className="mt-1 break-words">{progress.lastFailureMessage}</div>
)}
{failureSample && failureSample.length > 0 && (() => {
const sampleSize = failureSample.length;
const observed = progress.lastObservedResidualCount ?? sampleSize;
const capHit = observed >= VERIFIER_OBSERVATION_CAP;
return (
<details className="mt-2">
<summary className="cursor-pointer text-red-700 dark:text-red-200">
Residual sample ({sampleSize} of {observed}{capHit ? "+" : ""} observed row{observed === 1 ? "" : "s"})
</summary>
<ul className="mt-1 pl-4 list-disc font-mono break-all">
{failureSample.slice(0, 20).map((r, i) => (
<li key={i}>{r.table}: {r.pk} / {r.rk}</li>
))}
</ul>
{capHit && (
<p className="mt-2 text-xs italic text-red-700 dark:text-red-200">
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.
</p>
)}
</details>
);
})()}
</div>
)}
</div>
)}
{summary.preflightCounts && (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-200 mb-2">Rows in snapshot (by table)</h3>
<div className="border border-gray-200 dark:border-gray-700 rounded overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900">
<tr>
<th className="text-left px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Table</th>
<th className="text-right px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Count</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{Object.entries(summary.preflightCounts).map(([table, count]) => (
<tr key={table}>
<td className="px-3 py-2 font-mono text-xs text-gray-800 dark:text-gray-200">{table}</td>
<td className="px-3 py-2 text-right text-gray-800 dark:text-gray-200">{count}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
<div className="px-6 py-4">
<DeletionManifestSummaryView
tenantId={tenantId}
sessionId={sessionId}
manifestId={manifestId}
getAccessToken={getAccessToken}
showDownload
/>
</div>
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 px-6 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2 rounded-b-lg">
<button
onClick={handleDownload}
disabled={loading || downloading || !!error}
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
{downloading ? "Downloading…" : "Download snapshot"}
</button>
<button
onClick={onClose}
className="px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
@@ -285,18 +76,3 @@ export function DeletionPreviewModal({
</div>
);
}
function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">{label}</p>
<p className={`text-sm text-gray-900 dark:text-white ${mono ? "font-mono break-all" : ""}`}>{value}</p>
</div>
);
}
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`;
}
@@ -0,0 +1,385 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch";
import { useAdminConfig } from "../../../AdminConfigContext";
import { DeletionManifestSummaryView } from "./DeletionManifestSummaryView";
import { RestoreConfirmDialog } from "./RestoreConfirmDialog";
interface RestoreBrowserTabProps {
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
setError: (error: string | null) => void;
setSuccessMessage: (message: string | null) => void;
}
interface ManifestEntry {
manifestId: string;
sizeBytes: number;
lastModifiedUtc: string;
}
interface SessionGroup {
sessionId: string;
manifestCount: number;
latestManifestUtc: string;
manifests: ManifestEntry[];
}
interface TenantManifestsResponse {
success: boolean;
tenantId: string;
sessionCount: number;
manifestCount: number;
sessions: SessionGroup[];
}
interface Selection {
tenantId: string;
sessionId: string;
manifestId: 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
* appear on the In-Flight / Poisoned / Stranded tabs, so without this entry point the operator
* would need the manifestId in hand from the audit log.
*
* Layout: tenant dropdown up top → two columns: left = collapsible session/manifest tree;
* right = the same DeletionManifestSummaryView the modal renders, plus a "Restore…" button
* that opens the dialog with the selection pre-filled.
*/
export function RestoreBrowserTab({ getAccessToken, setError, setSuccessMessage }: RestoreBrowserTabProps) {
const { tenants, loadingTenants } = useAdminConfig();
const [selectedTenantId, setSelectedTenantId] = useState<string>("");
// loadedTenantId pairs the displayed sessions with the tenant they belong to. The dropdown's
// selectedTenantId can race ahead of an in-flight fetch; rendering only when the two match
// prevents the operator from clicking a manifest that belongs to a different tenant than the
// one currently selected. Codex P2.
const [loadedTenantId, setLoadedTenantId] = useState<string>("");
const [sessions, setSessions] = useState<SessionGroup[] | null>(null);
const [loadingSessions, setLoadingSessions] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [expandedSessions, setExpandedSessions] = useState<Set<string>>(new Set());
const [selection, setSelection] = useState<Selection | null>(null);
const [restoring, setRestoring] = useState<Selection | null>(null);
// Sort tenants alphabetically by display name (domain fallback to id) so the dropdown is
// operator-friendly.
const sortedTenants = useMemo(() => {
return [...tenants].sort((a, b) => {
const aLabel = (a.domainName || a.tenantId).toLowerCase();
const bLabel = (b.domainName || b.tenantId).toLowerCase();
return aLabel.localeCompare(bLabel);
});
}, [tenants]);
// 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
// response from clobbering newer data, and the immediate state-clear on tenant change means
// the operator never sees stale manifests / preview / restore-action that belong to the
// previous tenant. Codex P2.
useEffect(() => {
// Immediate clear: dropping the displayed data on switch means the operator never has a
// "this manifest from tenant A is selected while the dropdown says tenant B" moment.
setSessions(null);
setExpandedSessions(new Set());
setSelection(null);
setLoadedTenantId("");
if (!selectedTenantId) {
setLoadingSessions(false);
return;
}
const controller = new AbortController();
let cancelled = false;
setLoadingSessions(true);
(async () => {
try {
const resp = await authenticatedFetch(
api.sessionDeletions.tenantManifests(selectedTenantId),
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 TenantManifestsResponse;
if (cancelled) return;
// Pin the response to the tenantId it was fetched for. The render path keys off
// loadedTenantId === selectedTenantId, so a late response that no longer matches the
// current dropdown is invisible to the operator even if it sneaks through.
setLoadedTenantId(selectedTenantId);
setSessions(json.sessions ?? []);
} catch (err) {
if (cancelled) return;
// AbortError is the expected outcome when the effect re-runs before the previous fetch
// resolved (tenant switched, refresh hit, component unmounted) — swallow silently.
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));
}
setSessions([]);
setLoadedTenantId(selectedTenantId);
} finally {
if (!cancelled) setLoadingSessions(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [selectedTenantId, refreshKey, getAccessToken, setError]);
// When the displayed data is for a different tenant than the dropdown shows (in-flight fetch),
// treat sessions as not-yet-loaded for rendering purposes. Avoids briefly showing the previous
// tenant's tree while the new one loads.
const tenantMatches = loadedTenantId === selectedTenantId && selectedTenantId !== "";
const visibleSessions = tenantMatches ? sessions : null;
const toggleSession = (sessionId: string) => {
setExpandedSessions((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) next.delete(sessionId);
else next.add(sessionId);
return next;
});
};
const expandAll = () => {
if (visibleSessions == null) return;
setExpandedSessions(new Set(visibleSessions.map((s) => s.sessionId)));
};
const collapseAll = () => setExpandedSessions(new Set());
return (
<>
<div className="space-y-4">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-1">Restore Browser</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Pick a tenant to enumerate every persisted cascade-delete snapshot (33-day retention).
Useful when the cascade already completed and the session no longer shows up on the
In-Flight / Poisoned / Stranded tabs but the operator needs to recover it from the
snapshot. Click a manifest on the left to preview, then <strong>Restore…</strong> to
open the dry-run dialog with the IDs pre-filled.
</p>
<div className="flex items-end gap-3">
<div className="flex-grow">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Tenant</label>
<select
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
disabled={loadingTenants}
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>
{sortedTenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.domainName ? `${t.domainName} — ${t.tenantId}` : t.tenantId}
</option>
))}
</select>
</div>
<button
onClick={() => setRefreshKey((k) => k + 1)}
disabled={!selectedTenantId || loadingSessions}
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 whitespace-nowrap"
>
{loadingSessions ? "Loading…" : "Refresh"}
</button>
</div>
</div>
{selectedTenantId && (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
{/* Left: session/manifest tree */}
<div className="lg:col-span-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">
Sessions
{visibleSessions && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400 font-normal">
({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"})
</span>
)}
</h3>
<div className="flex gap-1">
<button
onClick={expandAll}
disabled={!visibleSessions || visibleSessions.length === 0}
className="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
Expand all
</button>
<button
onClick={collapseAll}
disabled={!visibleSessions || visibleSessions.length === 0}
className="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
Collapse all
</button>
</div>
</div>
<div className="max-h-[60vh] overflow-y-auto">
{loadingSessions && (
<p className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">Loading manifests…</p>
)}
{!loadingSessions && visibleSessions && visibleSessions.length === 0 && (
<p className="px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
No stored manifests for this tenant. Either no cascades have run, or all blobs have aged out of the
33-day retention window.
</p>
)}
{!loadingSessions && visibleSessions && visibleSessions.length > 0 && (
<ul className="divide-y divide-gray-100 dark:divide-gray-700 text-sm">
{visibleSessions.map((s) => {
const expanded = expandedSessions.has(s.sessionId);
const sessionAgeMin = ageMinutes(s.latestManifestUtc);
return (
<li key={s.sessionId}>
<button
onClick={() => toggleSession(s.sessionId)}
className="w-full flex items-center justify-between px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
<span className="flex items-center gap-2 min-w-0">
<svg
className={`w-3 h-3 text-gray-400 transition-transform shrink-0 ${expanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="font-mono text-xs text-gray-800 dark:text-gray-200 truncate">{s.sessionId}</span>
<span className="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap">
· {s.manifestCount} manifest{s.manifestCount === 1 ? "" : "s"}
</span>
</span>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-2">
{formatAge(sessionAgeMin)}
</span>
</button>
{expanded && (
<ul className="bg-gray-50 dark:bg-gray-900/50">
{s.manifests.map((m) => {
const isSelected =
selection != null &&
selection.tenantId === selectedTenantId &&
selection.sessionId === s.sessionId &&
selection.manifestId === m.manifestId;
return (
<li key={m.manifestId}>
<button
onClick={() =>
setSelection({
tenantId: selectedTenantId,
sessionId: s.sessionId,
manifestId: m.manifestId,
})
}
className={`w-full flex items-center justify-between gap-2 pl-10 pr-4 py-2 text-left text-xs hover:bg-purple-50 dark:hover:bg-purple-900/30 ${
isSelected
? "bg-purple-100 dark:bg-purple-900/50 text-purple-900 dark:text-purple-100 font-medium"
: "text-gray-700 dark:text-gray-300"
}`}
>
<span className="font-mono truncate">{m.manifestId}</span>
<span className="text-gray-500 dark:text-gray-400 whitespace-nowrap">
{formatBytes(m.sizeBytes)} · {formatAge(ageMinutes(m.lastModifiedUtc))}
</span>
</button>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
{/* Right: preview + restore action */}
<div className="lg:col-span-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">Preview</h3>
{selection && (
<button
onClick={() => setRestoring(selection)}
className="px-3 py-1 text-xs bg-amber-600 text-white rounded hover:bg-amber-700"
>
Restore…
</button>
)}
</div>
<div className="px-4 py-4">
{!selection && (
<p className="text-sm text-gray-500 dark:text-gray-400">
Select a manifest on the left to inspect its preflight counts, worker progress, and
last failure (if any). The <strong>Restore…</strong> button opens the same dry-run + restore dialog used by the Poisoned tab.
</p>
)}
{selection && (
<DeletionManifestSummaryView
tenantId={selection.tenantId}
sessionId={selection.sessionId}
manifestId={selection.manifestId}
getAccessToken={getAccessToken}
showDownload
/>
)}
</div>
</div>
</div>
)}
</div>
{restoring && (
<RestoreConfirmDialog
tenantId={restoring.tenantId}
sessionId={restoring.sessionId}
manifestId={restoring.manifestId}
onClose={() => 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`;
}
@@ -13,6 +13,19 @@ interface RestoreConfirmDialogProps {
getAccessToken: (forceRefresh?: boolean) => Promise<string | null>;
}
interface RestoreResultView {
outcome: string;
mode: string | null;
message: string | null;
currentState: string | null;
pendingManifestId: string | null;
rowsRestoredByTable: Record<string, number>;
rowsSkippedByTable: Record<string, number>;
wouldRestoreByTable: Record<string, number>;
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<string | null>(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<RestoreResultView | null>(null);
const [realRunResult, setRealRunResult] = useState<RestoreResultView | null>(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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
onClick={onClose}
>
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full"
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[85vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center">
<div className="sticky top-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center gap-3 z-10">
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/30 rounded-full flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-amber-600 dark:text-amber-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v6h6M20 20v-6h-6M3 11a8 8 0 0114-5l3 3M21 13a8 8 0 01-14 5l-3-3" />
</svg>
</div>
<div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Restore session from snapshot</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono">{tenantId} / {sessionId}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono truncate">{tenantId} / {sessionId}</p>
</div>
</div>
@@ -106,7 +121,7 @@ export function RestoreConfirmDialog({
<li><strong>Full</strong> — Completed cascade (Sessions row gone): inserts every row from the snapshot and resets <code className="bg-gray-200 dark:bg-gray-700 px-1 rounded">DeletionState=None</code>.</li>
</ul>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with <strong>dry-run</strong> first to confirm the predicted mode.
Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with <strong>dry-run</strong> first to confirm the predicted mode + row counts.
</p>
</div>
@@ -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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row.
Stored on the <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">deletion_restored</code> audit row when the real restore runs. Dry-runs are intentionally audit-free.
</p>
</div>
<label className="flex items-center gap-2 cursor-pointer p-2 border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 rounded">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
className="w-4 h-4"
/>
<span className="text-sm text-amber-900 dark:text-amber-100">
<strong>Dry run</strong> — verify the snapshot can be replayed without writing anything.
Recommended for the first attempt.
</span>
</label>
{dryRunResult && <ResultSection title="Dry-run preview" tone="blue" result={dryRunResult} kind="dryRun" />}
{realRunResult && <ResultSection title="Real restore" tone="green" result={realRunResult} kind="real" />}
{outcome && (
<div className="p-3 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded text-sm text-green-800 dark:text-green-200">
Outcome: <strong>{outcome.outcome}</strong>
{outcome.mode && (
<> · Mode (auto-selected): <strong className="capitalize">{outcome.mode}</strong></>
)}
{outcome.dryRun ? " · dry-run — no rows written" : ""}
</div>
)}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded text-sm text-red-700 dark:text-red-200">
{error}
@@ -156,7 +153,16 @@ export function RestoreConfirmDialog({
)}
</div>
<div className="bg-gray-50 dark:bg-gray-900 px-6 py-3 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-2 rounded-b-lg">
<div className="sticky bottom-0 bg-gray-50 dark:bg-gray-900 px-6 py-3 border-t border-gray-200 dark:border-gray-700 flex flex-wrap justify-end gap-2 rounded-b-lg">
{hasResults && (
<button
onClick={handleReset}
disabled={submitting}
className="px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50"
>
Start over
</button>
)}
<button
onClick={onClose}
disabled={submitting}
@@ -164,17 +170,131 @@ export function RestoreConfirmDialog({
>
Close
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className={`px-3 py-2 text-sm text-white rounded disabled:opacity-50 ${
dryRun ? "bg-blue-600 hover:bg-blue-700" : "bg-amber-600 hover:bg-amber-700"
}`}
>
{submitting ? "Running…" : dryRun ? "Run dry-run" : "Restore"}
</button>
{realRunResult == null && (
<button
onClick={() => submit(true)}
disabled={submitting}
className="px-3 py-2 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? "Running…" : dryRunResult ? "Run dry-run again" : "Run dry-run"}
</button>
)}
{realRunResult == null && (
<button
onClick={() => submit(false)}
disabled={submitting}
className="px-3 py-2 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
title={dryRunResult ? "Apply the restore (writes rows)" : "Skip dry-run and restore (not recommended)"}
>
{submitting ? "Running…" : dryRunResult ? "Apply restore" : "Skip dry-run · Restore now"}
</button>
)}
</div>
</div>
</div>
);
}
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 (
<div className={`border rounded p-3 text-sm space-y-2 ${toneClasses}`}>
<div className="flex items-baseline justify-between gap-2 flex-wrap">
<h3 className={`text-sm font-semibold ${titleColor}`}>{title}</h3>
<span className="text-xs font-mono text-gray-600 dark:text-gray-300">
Outcome: <strong>{result.outcome}</strong>
{result.mode && <> · Mode: <strong className="capitalize">{result.mode}</strong></>}
<> · {result.durationMs} ms</>
</span>
</div>
{result.message && (
<p className="text-xs text-gray-700 dark:text-gray-300 italic">{result.message}</p>
)}
{(result.currentState || result.pendingManifestId) && (
<div className="grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-400">
{result.currentState && (
<span>Current state: <code className="bg-white dark:bg-gray-900 px-1 rounded">{result.currentState}</code></span>
)}
{result.pendingManifestId && (
<span className="truncate">Pending: <code className="bg-white dark:bg-gray-900 px-1 rounded font-mono">{result.pendingManifestId}</code></span>
)}
</div>
)}
<div>
<p className="text-xs font-medium text-gray-700 dark:text-gray-200">
{tableLabel} ({total} {total === 1 ? "row" : "rows"} total{skippedTotal > 0 ? `, ${skippedTotal} skipped` : ""})
</p>
{Object.keys(tableMap).length > 0 ? (
<table className="w-full mt-1 text-xs">
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{Object.entries(tableMap)
.sort(([, a], [, b]) => b - a)
.map(([table, count]) => (
<tr key={table}>
<td className="py-0.5 font-mono text-gray-700 dark:text-gray-300">{table}</td>
<td className="py-0.5 text-right text-gray-800 dark:text-gray-200">{count}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400 italic">— no tables touched</p>
)}
</div>
{result.inventoryReIncrements > 0 && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{kind === "dryRun" ? "Would re-increment" : "Re-incremented"} <strong>{result.inventoryReIncrements}</strong> software-inventory counter{result.inventoryReIncrements === 1 ? "" : "s"}.
</p>
)}
</div>
);
}
function parseRestoreResult(body: Record<string, unknown>): 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<string, number> {
return (
typeof value === "object"
&& value !== null
&& !Array.isArray(value)
&& Object.values(value).every((v) => typeof v === "number")
);
}
@@ -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() {
<TabButton active={activeTab === "stranded"} onClick={() => setActiveTab("stranded")}>
Stranded&nbsp;Queued
</TabButton>
<TabButton active={activeTab === "restore-browser"} onClick={() => setActiveTab("restore-browser")}>
Restore&nbsp;Browser
</TabButton>
<TabButton active={activeTab === "activity"} onClick={() => setActiveTab("activity")}>
Activity
</TabButton>
@@ -87,6 +91,13 @@ export default function SessionCleanupPage() {
{activeTab === "stranded" && (
<StrandedTab getAccessToken={getAccessToken} setError={setError} />
)}
{activeTab === "restore-browser" && (
<RestoreBrowserTab
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
/>
)}
{activeTab === "activity" && <ActivityTab />}
</main>
</div>
@@ -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);
}}
@@ -516,10 +516,22 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
{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<string | null>(
(longest, s) => longest == null || s.href.length > longest.length ? s.href : longest,
null,
);
return (
<ul className="mt-0.5 space-y-px">
{expandItem.items.map((sub) => {
const subActive = pathname === sub.href || pathname.startsWith(sub.href + "/");
const subActive = sub.href === winningSubHref;
return (
<li key={sub.id}>
<Link
@@ -539,7 +551,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
);
})}
</ul>
)}
);
})()}
</li>
);
})}
+5
View File
@@ -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) ──────