e.stopPropagation()}
>
-
-
+
+
-
+
Restore session from snapshot
-
{tenantId} / {sessionId}
+
{tenantId} / {sessionId}
@@ -106,7 +121,7 @@ export function RestoreConfirmDialog({
Full — Completed cascade (Sessions row gone): inserts every row from the snapshot and resets DeletionState=None.
- Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with dry-run first to confirm the predicted mode.
+ Forcing the wrong mode would corrupt state, so the choice is not exposed. Run with dry-run first to confirm the predicted mode + row counts.
@@ -120,35 +135,17 @@ export function RestoreConfirmDialog({
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. customer support — accidental delete"
maxLength={200}
- className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
+ disabled={submitting}
+ className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded text-sm text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
/>
- Stored on the deletion_restored audit row.
+ Stored on the deletion_restored audit row when the real restore runs. Dry-runs are intentionally audit-free.
-
- setDryRun(e.target.checked)}
- className="w-4 h-4"
- />
-
- Dry run — verify the snapshot can be replayed without writing anything.
- Recommended for the first attempt.
-
-
+ {dryRunResult &&
}
+ {realRunResult &&
}
- {outcome && (
-
- Outcome: {outcome.outcome}
- {outcome.mode && (
- <> · Mode (auto-selected): {outcome.mode} >
- )}
- {outcome.dryRun ? " · dry-run — no rows written" : ""}
-
- )}
{error && (
{error}
@@ -156,7 +153,16 @@ export function RestoreConfirmDialog({
)}
-
+
+ {hasResults && (
+
+ Start over
+
+ )}
Close
-
- {submitting ? "Running…" : dryRun ? "Run dry-run" : "Restore"}
-
+ {realRunResult == null && (
+ 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"}
+
+ )}
+ {realRunResult == null && (
+ 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"}
+
+ )}
);
}
+
+function ResultSection({
+ title,
+ tone,
+ result,
+ kind,
+}: {
+ title: string;
+ tone: "blue" | "green";
+ result: RestoreResultView;
+ kind: "dryRun" | "real";
+}) {
+ const toneClasses = tone === "blue"
+ ? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700"
+ : "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700";
+ const titleColor = tone === "blue"
+ ? "text-blue-900 dark:text-blue-100"
+ : "text-green-900 dark:text-green-100";
+
+ const tableMap = kind === "dryRun" ? result.wouldRestoreByTable : result.rowsRestoredByTable;
+ const tableLabel = kind === "dryRun" ? "Would restore by table" : "Rows restored by table";
+ const total = Object.values(tableMap).reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0);
+ const skippedTotal = Object.values(result.rowsSkippedByTable).reduce((sum, n) => sum + (Number.isFinite(n) ? n : 0), 0);
+
+ return (
+
+
+
{title}
+
+ Outcome: {result.outcome}
+ {result.mode && <> · Mode: {result.mode} >}
+ <> · {result.durationMs} ms>
+
+
+
+ {result.message && (
+
{result.message}
+ )}
+
+ {(result.currentState || result.pendingManifestId) && (
+
+ {result.currentState && (
+ Current state: {result.currentState}
+ )}
+ {result.pendingManifestId && (
+ Pending: {result.pendingManifestId}
+ )}
+
+ )}
+
+
+
+ {tableLabel} ({total} {total === 1 ? "row" : "rows"} total{skippedTotal > 0 ? `, ${skippedTotal} skipped` : ""})
+
+ {Object.keys(tableMap).length > 0 ? (
+
+
+ {Object.entries(tableMap)
+ .sort(([, a], [, b]) => b - a)
+ .map(([table, count]) => (
+
+ {table}
+ {count}
+
+ ))}
+
+
+ ) : (
+
— no tables touched
+ )}
+
+
+ {result.inventoryReIncrements > 0 && (
+
+ {kind === "dryRun" ? "Would re-increment" : "Re-incremented"} {result.inventoryReIncrements} software-inventory counter{result.inventoryReIncrements === 1 ? "" : "s"}.
+
+ )}
+
+ );
+}
+
+function parseRestoreResult(body: Record
): RestoreResultView {
+ return {
+ outcome: typeof body.outcome === "string" ? body.outcome : "Unknown",
+ mode: typeof body.mode === "string" ? body.mode : null,
+ message: typeof body.message === "string" ? body.message : null,
+ currentState: typeof body.currentState === "string" ? body.currentState : null,
+ pendingManifestId: typeof body.pendingManifestId === "string" ? body.pendingManifestId : null,
+ rowsRestoredByTable: isStringNumberMap(body.rowsRestoredByTable) ? body.rowsRestoredByTable : {},
+ rowsSkippedByTable: isStringNumberMap(body.rowsSkippedByTable) ? body.rowsSkippedByTable : {},
+ wouldRestoreByTable: isStringNumberMap(body.wouldRestoreByTable) ? body.wouldRestoreByTable : {},
+ inventoryReIncrements: typeof body.inventoryReIncrements === "number" ? body.inventoryReIncrements : 0,
+ durationMs: typeof body.durationMs === "number" ? body.durationMs : 0,
+ };
+}
+
+function isStringNumberMap(value: unknown): value is Record {
+ return (
+ typeof value === "object"
+ && value !== null
+ && !Array.isArray(value)
+ && Object.values(value).every((v) => typeof v === "number")
+ );
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx
index 03d84f2f..bb54348d 100644
--- a/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/ops/session-cleanup/page.tsx
@@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { useAdminConfig } from "../../AdminConfigContext";
import { AdminNotifications } from "../../AdminNotifications";
import { DeletionPreviewModal } from "./components/DeletionPreviewModal";
+import { RestoreBrowserTab } from "./components/RestoreBrowserTab";
import { RestoreConfirmDialog } from "./components/RestoreConfirmDialog";
type CascadeState = "Preparing" | "Queued" | "Running" | "Poisoned";
@@ -28,7 +29,7 @@ interface SessionDeletionListResponse {
sessions: SessionDeletionRow[];
}
-type Tab = "in-flight" | "poisoned" | "stranded" | "activity";
+type Tab = "in-flight" | "poisoned" | "stranded" | "restore-browser" | "activity";
const STRANDED_THRESHOLD_MINUTES = 30;
@@ -64,6 +65,9 @@ export default function SessionCleanupPage() {
setActiveTab("stranded")}>
Stranded Queued
+ setActiveTab("restore-browser")}>
+ Restore Browser
+
setActiveTab("activity")}>
Activity
@@ -87,6 +91,13 @@ export default function SessionCleanupPage() {
{activeTab === "stranded" && (
)}
+ {activeTab === "restore-browser" && (
+
+ )}
{activeTab === "activity" && }
@@ -285,7 +296,10 @@ function PoisonedTab({
manifestId={restoring.manifestId}
onClose={() => setRestoring(null)}
onRestored={() => {
- setRestoring(null);
+ // Codex P3: do NOT close the dialog here — the operator wants to see the real-run
+ // result counts beside the dry-run preview before dismissing. The user closes the
+ // dialog explicitly via Close / Start over. Background refresh of the Poisoned list
+ // happens immediately so the row drops out when they look behind the modal.
setSuccessMessage(`Session ${restoring.sessionId} restore submitted.`);
setRefreshKey((k) => k + 1);
}}
diff --git a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
index 3a35d36f..a2e76002 100644
--- a/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
+++ b/src/Web/autopilot-monitor-web/components/GlobalSidebar.tsx
@@ -516,10 +516,22 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
- {isExpanded && (
+ {isExpanded && (() => {
+ // Pick the longest sub.href that matches the current pathname so a
+ // shorter sibling whose href is a strict prefix of the active route
+ // doesn't also light up. Example: with /admin/ops (Maintenance) and
+ // /admin/ops/session-cleanup, navigating to the latter previously
+ // marked BOTH active. Computed once per render and applied per sub.
+ const winningSubHref = expandItem.items
+ .filter((s) => pathname === s.href || pathname.startsWith(s.href + "/"))
+ .reduce