import { useEffect, useRef, useState } from "react"; import type { ContainerStaleness, MigrationOptions } from "../../lib/types"; import Modal from "../ui/Modal"; import Button from "../ui/Button"; import Toggle from "../ui/Toggle"; import { SwitchRow } from "../ui/Field"; import MigrationReportCard from "./MigrationReportCard"; import MigrationInterruptedCard from "./MigrationInterruptedCard"; import type { ContainerMigration } from "../../hooks/useContainerMigration"; import { DATA_NOT_CARRIED, KEPT_AUTOMATICALLY, KEPT_WHY, LOST_WITHOUT_REPLAY, MID_RUN_SAFETY, REPLAY_COST, ROLLBACK_DISK_COST, ROLLBACK_SCOPE, formatDataSize, formatSnapshotDate, } from "./migrationCopy"; interface Props { projectName: string; staleness: ContainerStaleness | null; migration: ContainerMigration; onClose: () => void; } function Section({ title, children, control, }: { title: string; children: React.ReactNode; control?: React.ReactNode; }) { return (
{control ? ( ) : (

{title}

)}
{children}
); } function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) { return ( ); } /** * Pre-flight, progress and outcome for a base-image migration, in one dialog. * * Order matters here. The reassurance comes first — almost nothing painful is * at risk, because the two volumes re-attach untouched — and only then the * short list of things that genuinely have to be put back. Leading with the * options would read as "pick which of your data to lose". * * Once the run starts the dialog stays **dismissible**: this takes minutes, and * a modal that blocks the whole app for the duration is worse than no progress * UI at all. Closing it hides a view; the work and its log live in the hook. */ export default function MigrateContainerModal({ projectName, staleness, migration, onClose, }: Props) { const [replayPackages, setReplayPackages] = useState(true); const [copyPaths, setCopyPaths] = useState(true); const [keepRollback, setKeepRollback] = useState(true); const logRef = useRef(null); const { running, report, interrupted, log, phaseMessage, busy, probeSettled } = migration; const aptDelta = staleness?.apt_delta ?? []; const npmDelta = staleness?.npm_global_delta ?? []; const verbatim = staleness?.verbatim_paths ?? []; const atRisk = staleness?.unpreserved_data ?? []; const gains = staleness?.missing_features ?? []; const snapshot = formatSnapshotDate(staleness?.snapshot_created_at ?? null); // Follow the tail of the apt output, the way a terminal would. useEffect(() => { const el = logRef.current; if (el) el.scrollTop = el.scrollHeight; }, [log.length]); const start = () => { const options: MigrationOptions = { // Deliberately *not* `&& verbatim.length > 0`. That looked like a // harmless optimisation but read the toggle's meaning off a probe that // may not have landed, so a null `staleness` sent `copy_paths: false` // and the backend — which recomputes the real set but honours the flag — // skipped files that did exist. The backend already skips the step when // its own set comes out empty; that is the only place that knows. replay_packages: replayPackages, copy_paths: copyPaths, keep_rollback: keepRollback, }; void migration.start(options); }; // ---- Unfinished --------------------------------------------------------- // Ahead of the report, for the reason spelled out in MigrationInterruptedCard: // Keep is not a legitimate action on a container that is mid-swap. if (interrupted) { return ( Close } > void migration.resume()} onRollback={() => void migration.rollback().then(onClose)} /> ); } // ---- Outcome ------------------------------------------------------------ if (report) { return ( Close } > void migration.keep().then(onClose)} onRollback={() => void migration.rollback().then(onClose)} onDismiss={() => void migration.dismiss().then(onClose)} /> ); } // ---- Progress ----------------------------------------------------------- if (running) { return ( Hide } >

{phaseMessage ?? "Starting…"}

{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}

{MID_RUN_SAFETY}

); } // ---- Pre-flight --------------------------------------------------------- return ( } >
{/* 0. Until the probe lands, every list below is "not known" wearing "empty"'s clothes. Say which one it is, and do not let the run start on an unread delta. */} {!probeSettled && (

Still working out what this container has that the current base does not. The lists below are not complete until it finishes, so the update cannot start yet.

)} {/* 1. Reassurance first. Not a choice — a statement of fact. */}

{KEPT_WHY}

{LOST_WITHOUT_REPLAY}

{/* 1b. The one thing that is genuinely destroyed. Directly under the reassurance, because a user who reads only the top of this dialog must not come away thinking nothing is at stake. */}

{atRisk.length > 0 ? `Destroyed, and not restored by this update (${atRisk.length})` : "Not carried across"}

{DATA_NOT_CARRIED}

{probeSettled ? ( atRisk.length > 0 ? (
    {atRisk.map((d) => (
  • {d.path} {" "} — {formatDataSize(d.bytes)} in {d.file_count} file {d.file_count === 1 ? "" : "s"}
  • ))}
) : (

Nothing was found under /var{" "} on this container, so there is nothing here to lose.

) ) : (

Not checked yet.

)}
{/* 2. The apt replay. */}
} > {aptDelta.length === 0 ? (

{/* "None found" and "not looked yet" are different sentences. Printing the first while the probe is still running is how a user ends up believing a delta was empty when it was unread. */} {probeSettled ? "No extra apt packages were found on this container." : "Still checking which apt packages this container added."}

) : ( )} {npmDelta.length > 0 && ( <>

Global npm packages ({npmDelta.length}):

)}

{REPLAY_COST}

{/* 3. Verbatim copies — usually nothing once the probe has settled, so usually not shown at all. Shown while it has not, because a hidden section reads as "there is nothing here". */} {(verbatim.length > 0 || !probeSettled) && (
} >

Content under /usr/local,{" "} /opt,{" "} /srv and non-bind-mounted{" "} /workspace that belongs to no package, so it cannot be reinstalled from a repository.

{probeSettled ? ( ) : (

Still checking what is there.

)}
)} {/* 4. The rollback image, with its real disk cost stated. */}
} >

{ROLLBACK_DISK_COST}

{ROLLBACK_SCOPE}

{gains.length > 0 && (

You will gain

    {gains.map((feature) => (
  • {feature}
  • ))}
{/* "A different version", not "behind" — the count measures drift from the base, not a guarantee that each one is an upgrade. */} {(staleness?.outdated_package_count ?? 0) > 0 && (

Plus {staleness?.outdated_package_count} package {staleness?.outdated_package_count === 1 ? "" : "s"} the current base carries at a different version, security updates among them.

)}
)}
); }