mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
+131
@@ -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
|
||||
|
||||
+268
@@ -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`;
|
||||
}
|
||||
+13
-237
@@ -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`;
|
||||
}
|
||||
|
||||
+385
@@ -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`;
|
||||
}
|
||||
+175
-55
@@ -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 Queued
|
||||
</TabButton>
|
||||
<TabButton active={activeTab === "restore-browser"} onClick={() => setActiveTab("restore-browser")}>
|
||||
Restore 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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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) ──────
|
||||
|
||||
Reference in New Issue
Block a user