311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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 type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||
|
|
import {
|
||
|
|
KEPT_AUTOMATICALLY,
|
||
|
|
KEPT_WHY,
|
||
|
|
LOST_WITHOUT_REPLAY,
|
||
|
|
MID_RUN_SAFETY,
|
||
|
|
REPLAY_COST,
|
||
|
|
ROLLBACK_DISK_COST,
|
||
|
|
ROLLBACK_SCOPE,
|
||
|
|
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 (
|
||
|
|
<section className="border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)] px-3.5 py-3">
|
||
|
|
{control ? (
|
||
|
|
<SwitchRow label={title} control={control} />
|
||
|
|
) : (
|
||
|
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">{title}</h3>
|
||
|
|
)}
|
||
|
|
<div className="mt-2 space-y-1.5">{children}</div>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function BulletList({ items, mono = false }: { items: string[]; mono?: boolean }) {
|
||
|
|
return (
|
||
|
|
<ul className="space-y-1 pl-4 list-disc marker:text-[var(--text-disabled)]">
|
||
|
|
{items.map((item) => (
|
||
|
|
<li
|
||
|
|
key={item}
|
||
|
|
className={`text-xs leading-snug text-[var(--text-secondary)] ${
|
||
|
|
mono ? "font-mono break-all" : ""
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{item}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
const { running, report, log, phaseMessage, busy } = migration;
|
||
|
|
const aptDelta = staleness?.apt_delta ?? [];
|
||
|
|
const npmDelta = staleness?.npm_global_delta ?? [];
|
||
|
|
const verbatim = staleness?.verbatim_paths ?? [];
|
||
|
|
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 = {
|
||
|
|
replay_packages: replayPackages,
|
||
|
|
copy_paths: copyPaths && verbatim.length > 0,
|
||
|
|
keep_rollback: keepRollback,
|
||
|
|
};
|
||
|
|
void migration.start(options);
|
||
|
|
};
|
||
|
|
|
||
|
|
// ---- Outcome ------------------------------------------------------------
|
||
|
|
if (report) {
|
||
|
|
return (
|
||
|
|
<Modal
|
||
|
|
title={`Update container base — ${projectName}`}
|
||
|
|
onClose={onClose}
|
||
|
|
widthClassName="w-[34rem]"
|
||
|
|
footer={
|
||
|
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||
|
|
Close
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<MigrationReportCard
|
||
|
|
report={report}
|
||
|
|
busy={busy}
|
||
|
|
onKeep={() => void migration.keep().then(onClose)}
|
||
|
|
onRollback={() => void migration.rollback().then(onClose)}
|
||
|
|
onDismiss={() => {
|
||
|
|
migration.dismiss();
|
||
|
|
onClose();
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Progress -----------------------------------------------------------
|
||
|
|
if (running) {
|
||
|
|
return (
|
||
|
|
<Modal
|
||
|
|
title={`Updating container base — ${projectName}`}
|
||
|
|
description="This keeps running if you close it. You can carry on using the app."
|
||
|
|
onClose={onClose}
|
||
|
|
widthClassName="w-[34rem]"
|
||
|
|
footer={
|
||
|
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||
|
|
Hide
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<div className="space-y-3">
|
||
|
|
<p
|
||
|
|
role="status"
|
||
|
|
aria-live="polite"
|
||
|
|
className="text-[13px] text-[var(--text-primary)]"
|
||
|
|
>
|
||
|
|
{phaseMessage ?? "Starting…"}
|
||
|
|
</p>
|
||
|
|
<div
|
||
|
|
ref={logRef}
|
||
|
|
data-testid="migration-log"
|
||
|
|
className="h-56 overflow-y-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] whitespace-pre-wrap break-all select-text"
|
||
|
|
>
|
||
|
|
{log.length === 0 ? "Waiting for the first step…" : log.join("\n")}
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
{MID_RUN_SAFETY}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Pre-flight ---------------------------------------------------------
|
||
|
|
return (
|
||
|
|
<Modal
|
||
|
|
title={`Update container base — ${projectName}`}
|
||
|
|
description={
|
||
|
|
snapshot
|
||
|
|
? `Rebuilds this container on the current base image. It is running on a saved image from ${snapshot}.`
|
||
|
|
: "Rebuilds this container on the current base image."
|
||
|
|
}
|
||
|
|
onClose={onClose}
|
||
|
|
widthClassName="w-[36rem]"
|
||
|
|
footer={
|
||
|
|
<>
|
||
|
|
<Button size="md" variant="ghost" onClick={onClose}>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button size="md" variant="primary" onClick={start}>
|
||
|
|
Update container base
|
||
|
|
</Button>
|
||
|
|
</>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<div className="space-y-3">
|
||
|
|
{/* 1. Reassurance first. Not a choice — a statement of fact. */}
|
||
|
|
<Section title="Kept automatically">
|
||
|
|
<BulletList items={KEPT_AUTOMATICALLY} />
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">{KEPT_WHY}</p>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
{LOST_WITHOUT_REPLAY}
|
||
|
|
</p>
|
||
|
|
</Section>
|
||
|
|
|
||
|
|
{/* 2. The apt replay. */}
|
||
|
|
<Section
|
||
|
|
title={`Reinstalled from the new base's repos (${aptDelta.length})`}
|
||
|
|
control={
|
||
|
|
<Toggle
|
||
|
|
label="Reinstall system packages from the new base's repositories"
|
||
|
|
checked={replayPackages}
|
||
|
|
onChange={setReplayPackages}
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{aptDelta.length === 0 ? (
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
||
|
|
No extra apt packages were found on this container.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<BulletList items={aptDelta} mono />
|
||
|
|
)}
|
||
|
|
{npmDelta.length > 0 && (
|
||
|
|
<>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] pt-1">
|
||
|
|
Global npm packages ({npmDelta.length}):
|
||
|
|
</p>
|
||
|
|
<BulletList items={npmDelta} mono />
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">{REPLAY_COST}</p>
|
||
|
|
</Section>
|
||
|
|
|
||
|
|
{/* 3. Verbatim copies — usually nothing, so usually not shown at all. */}
|
||
|
|
{verbatim.length > 0 && (
|
||
|
|
<Section
|
||
|
|
title={`Copied across as-is (${verbatim.length})`}
|
||
|
|
control={
|
||
|
|
<Toggle
|
||
|
|
label="Copy user-authored files across as-is"
|
||
|
|
checked={copyPaths}
|
||
|
|
onChange={setCopyPaths}
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)]">
|
||
|
|
Content under <code className="font-mono">/usr/local</code>,{" "}
|
||
|
|
<code className="font-mono">/opt</code>,{" "}
|
||
|
|
<code className="font-mono">/srv</code> and non-bind-mounted{" "}
|
||
|
|
<code className="font-mono">/workspace</code> that belongs to no
|
||
|
|
package, so it cannot be reinstalled from a repository.
|
||
|
|
</p>
|
||
|
|
<BulletList items={verbatim} mono />
|
||
|
|
</Section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 4. The rollback image, with its real disk cost stated. */}
|
||
|
|
<Section
|
||
|
|
title="Keep a rollback image until I confirm"
|
||
|
|
control={
|
||
|
|
<Toggle
|
||
|
|
label="Keep a rollback image until I confirm"
|
||
|
|
checked={keepRollback}
|
||
|
|
onChange={setKeepRollback}
|
||
|
|
tone="caution"
|
||
|
|
/>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
{ROLLBACK_DISK_COST}
|
||
|
|
</p>
|
||
|
|
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||
|
|
{ROLLBACK_SCOPE}
|
||
|
|
</p>
|
||
|
|
</Section>
|
||
|
|
|
||
|
|
{gains.length > 0 && (
|
||
|
|
<section className="border border-[var(--success)]/40 bg-[var(--success-muted)] rounded-[var(--radius-panel)] px-3.5 py-3">
|
||
|
|
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||
|
|
You will gain
|
||
|
|
</h3>
|
||
|
|
<ul className="mt-1.5 space-y-1">
|
||
|
|
{gains.map((feature) => (
|
||
|
|
<li
|
||
|
|
key={feature}
|
||
|
|
className="text-xs leading-snug text-[var(--text-secondary)]"
|
||
|
|
>
|
||
|
|
<span aria-hidden="true" className="text-[var(--success)]">
|
||
|
|
+{" "}
|
||
|
|
</span>
|
||
|
|
{feature}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
{/* "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 && (
|
||
|
|
<p className="mt-1.5 text-xs text-[var(--text-secondary)]">
|
||
|
|
Plus {staleness?.outdated_package_count} package
|
||
|
|
{staleness?.outdated_package_count === 1 ? "" : "s"} the current
|
||
|
|
base carries at a different version, security updates among them.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
);
|
||
|
|
}
|