Migrate a project onto a new base image without losing its volumes
Projects were pinned to the image they were first created from. Both create paths preferred triple-c-snapshot-<id>:latest whenever it existed, and container_needs_recreation compared the container's live image against the triple-c.image label — which create_container wrote from the same image it created from. A tautology that could never fire. The only escape was Reset, which calls remove_project_volumes and destroys the login, skills and transcripts. Measured consequences on this host: real projects are missing socat (so the auth bridge cannot tunnel) and bubblewrap (so sandbox mode does not work), plus Mission Control and triple-c-sso-refresh, and sit 61 packages behind the base including ca-certificates, openssl and curl. Detection. create_container now writes triple-c.base-image-id (the image ID, not RepoDigests, which local-built and custom images do not have) and triple-c.create-image. container_needs_recreation takes the expected create-image and compares against the latter, so the check means something. base-image-id is deliberately NOT compared: a base bump would otherwise silently recreate from the snapshot, consuming the "you should migrate" signal without migrating. Staleness is surfaced, never acted on automatically. Migration keeps the volumes. /home/claude and ~/.claude are volumes and the image's copy is seed-only — permanently masked after first mount — so the login, ~/.claude.json, skills, transcripts, scheduler tasks, SSH keys, cargo, uv, ruff and Claude Code itself re-attach untouched. Only root-level state is rebuilt: apt packages are replayed against the new base rather than copied, so no stale libc is dragged forward, and /usr/local, /opt and the non-bind-mounted parts of /workspace are copied verbatim with tar --skip-old-files so they can never clobber a newer base binary. docker diff is not used: on a snapshot-derived container it reports only changes since the last commit. Raw image-vs-image diffing is filtered through dpkg ownership because it otherwise lies — 8,677 raw path differences on a real project reduced to 2 genuinely user-authored files, both loose /workspace-root files. Crash safety. snapshot:latest keeps pointing at the old image until the final commit, so any crash before it self-heals on next start. Later crashes are caught by reconcile_project_statuses. The rollback pin is a docker tag: 0.057s and 0 bytes. Rollback restores the system layer only — volumes are never touched — and the UI says so rather than implying a time machine. Fixes an infinite recreation loop shipped with the MCP removal. docker commit propagates labels to the image, so a container created from a snapshot inherited its non-empty triple-c.mcp-fingerprint and the one-shot shim recreated it again on every start, forever. Lineage labels are now always written explicitly. Documents the second, separate bug this uncovered: Dockerfile changes under /home/claude never reach an existing project, migration or not, because the volume masks them. Anything that must stay upgradable belongs in /usr/local/bin or /opt, or must be seeded by entrypoint.sh. 145 Rust tests, 227 frontend tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import MigrateContainerModal from "./MigrateContainerModal";
|
||||
import type { ContainerMigration } from "../../hooks/useContainerMigration";
|
||||
import type { ContainerStaleness } from "../../lib/types";
|
||||
|
||||
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
|
||||
async function flushFocus() {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
}
|
||||
|
||||
const STALE: ContainerStaleness = {
|
||||
stale: true,
|
||||
known: true,
|
||||
base_image_id: "sha256:aaa",
|
||||
current_base_image_id: "sha256:bbb",
|
||||
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||
missing_paths: ["/usr/bin/socat"],
|
||||
missing_features: ["Auth bridge tunnel (socat)", "Mission Control"],
|
||||
apt_delta: ["socat", "bubblewrap"],
|
||||
npm_global_delta: [],
|
||||
verbatim_paths: [],
|
||||
outdated_package_count: 61,
|
||||
probe_error: null,
|
||||
};
|
||||
|
||||
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||
return {
|
||||
staleness: STALE,
|
||||
probing: false,
|
||||
running: false,
|
||||
recovered: false,
|
||||
interrupted: null,
|
||||
report: null,
|
||||
log: [],
|
||||
phaseMessage: null,
|
||||
busy: false,
|
||||
start: vi.fn(async () => {}),
|
||||
resume: vi.fn(async () => {}),
|
||||
keep: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
dismiss: vi.fn(),
|
||||
refresh: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderModal(
|
||||
staleness: ContainerStaleness | null = STALE,
|
||||
overrides: Partial<ContainerMigration> = {},
|
||||
) {
|
||||
const m = migration({ staleness, ...overrides });
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<MigrateContainerModal
|
||||
projectName="api-server"
|
||||
staleness={staleness}
|
||||
migration={m}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await flushFocus();
|
||||
return { m, onClose };
|
||||
}
|
||||
|
||||
describe("MigrateContainerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("pre-flight", () => {
|
||||
it("leads with what is kept, as a statement rather than a choice", async () => {
|
||||
await renderModal();
|
||||
const kept = screen.getByText("Kept automatically");
|
||||
expect(kept).toBeInTheDocument();
|
||||
expect(screen.getByText(/no signing in again/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/every saved session transcript/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/are Docker volumes/i)).toBeInTheDocument();
|
||||
|
||||
// Reassurance comes first: it is above the replay section in the DOM.
|
||||
const replay = screen.getByText(/Reinstalled from the new base's repos/);
|
||||
expect(kept.compareDocumentPosition(replay)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
);
|
||||
|
||||
// And it is a statement — there is no switch attached to it.
|
||||
const keptSection = kept.closest("section");
|
||||
expect(keptSection?.querySelector('[role="switch"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the verbatim-copy section when nothing user-authored was found", async () => {
|
||||
await renderModal({ ...STALE, verbatim_paths: [] });
|
||||
expect(screen.queryByText(/Copied across as-is/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the verbatim-copy section with its paths when there are some", async () => {
|
||||
await renderModal({
|
||||
...STALE,
|
||||
verbatim_paths: ["/usr/local/bin/deploy.sh", "/etc/pki/corp.crt"],
|
||||
});
|
||||
expect(screen.getByText("Copied across as-is (2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("/usr/local/bin/deploy.sh")).toBeInTheDocument();
|
||||
expect(screen.getByText("/etc/pki/corp.crt")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("counts the apt packages and states the rollback's disk cost", async () => {
|
||||
await renderModal();
|
||||
expect(
|
||||
screen.getByText("Reinstalled from the new base's repos (2)"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("socat")).toBeInTheDocument();
|
||||
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||
expect(screen.getByText(/3.8–12.3 GB/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Rollback restores the system layer only/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the gains as the inverse of the missing features", async () => {
|
||||
await renderModal();
|
||||
expect(screen.getByText("You will gain")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Auth bridge tunnel \(socat\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Mission Control/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/61 packages the current base carries at a different version/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes the three options through when the run is started", async () => {
|
||||
const { m } = await renderModal({
|
||||
...STALE,
|
||||
verbatim_paths: ["/usr/local/bin/deploy.sh"],
|
||||
});
|
||||
fireEvent.click(
|
||||
screen.getByRole("switch", {
|
||||
name: /Keep a rollback image until I confirm/i,
|
||||
}),
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Update container base" }),
|
||||
);
|
||||
expect(m.start).toHaveBeenCalledWith({
|
||||
replay_packages: true,
|
||||
copy_paths: true,
|
||||
keep_rollback: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not ask to copy paths when there are none to copy", async () => {
|
||||
const { m } = await renderModal({ ...STALE, verbatim_paths: [] });
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Update container base" }),
|
||||
);
|
||||
expect(m.start).toHaveBeenCalledWith({
|
||||
replay_packages: true,
|
||||
copy_paths: false,
|
||||
keep_rollback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not start anything on cancel", async () => {
|
||||
const { m, onClose } = await renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(m.start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mid-run", () => {
|
||||
const RUNNING: Partial<ContainerMigration> = {
|
||||
running: true,
|
||||
log: ["Snapshotting container…", "Creating container on the new base…"],
|
||||
phaseMessage: "Creating container on the new base…",
|
||||
};
|
||||
|
||||
it("streams the phase message and the output", async () => {
|
||||
await renderModal(STALE, RUNNING);
|
||||
expect(screen.getByRole("status").textContent).toBe(
|
||||
"Creating container on the new base…",
|
||||
);
|
||||
const log = screen.getByTestId("migration-log");
|
||||
expect(log.textContent).toContain("Snapshotting container…");
|
||||
expect(log.textContent).toContain("Creating container on the new base…");
|
||||
});
|
||||
|
||||
it("can be dismissed without cancelling the run", async () => {
|
||||
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||
// A run takes minutes; blocking the app for it would be wrong, so the
|
||||
// dialog closes and the work carries on.
|
||||
expect(
|
||||
screen.getByText(/keeps running if you close it/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Hide" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Nothing on the migration was touched — closing is not cancelling.
|
||||
expect(m.start).not.toHaveBeenCalled();
|
||||
expect(m.rollback).not.toHaveBeenCalled();
|
||||
expect(m.dismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still closes on Escape and on the header ✕ while running", async () => {
|
||||
const { m, onClose } = await renderModal(STALE, RUNNING);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
expect(m.dismiss).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("outcome", () => {
|
||||
it("shows the report in place of the pre-flight once it lands", async () => {
|
||||
await renderModal(STALE, {
|
||||
report: {
|
||||
phase: "partial",
|
||||
packages_requested: ["socat", "bubblewrap"],
|
||||
packages_installed: ["socat"],
|
||||
packages_failed: [
|
||||
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||
],
|
||||
paths_copied: [],
|
||||
features_restored: ["Auth bridge tunnel (socat)"],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Kept automatically")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import type { MigrationReport } from "../../lib/types";
|
||||
import Button from "../ui/Button";
|
||||
import StatusIndicator from "../ui/StatusIndicator";
|
||||
import {
|
||||
ROLLBACK_SCOPE,
|
||||
aptRetryCommand,
|
||||
failureReportText,
|
||||
} from "./migrationCopy";
|
||||
|
||||
interface Props {
|
||||
report: MigrationReport;
|
||||
/** Disables the action row while confirm/rollback is in flight. */
|
||||
busy?: boolean;
|
||||
onKeep: () => void;
|
||||
onRollback: () => void;
|
||||
/** Only offered when there is nothing to keep or roll back. */
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of a migration, rendered identically in the Overview banner and
|
||||
* in the modal so a user who closed the modal is not shown a different story.
|
||||
*
|
||||
* A **partial** is the case this component exists for. The user arrived here
|
||||
* because containers degrade silently — a run that quietly dropped `socat` and
|
||||
* called itself a success would be exactly the same bug in a new place. So a
|
||||
* partial is painted as a warning, names every package and the reason it
|
||||
* failed, and hands over the literal `apt-get` line to finish the job.
|
||||
*/
|
||||
export default function MigrationReportCard({
|
||||
report,
|
||||
busy = false,
|
||||
onKeep,
|
||||
onRollback,
|
||||
onDismiss,
|
||||
}: Props) {
|
||||
const [copied, setCopied] = useState<"command" | "detail" | null>(null);
|
||||
const partial = report.phase === "partial";
|
||||
const failed = report.phase === "failed";
|
||||
const rolledBack = report.phase === "rolled_back";
|
||||
|
||||
const copy = async (what: "command" | "detail", text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(what);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
// Clipboard can be denied; the text is selectable on screen either way.
|
||||
}
|
||||
};
|
||||
|
||||
// Partial and failed are painted as failures. A partial that reads as a
|
||||
// success is precisely how a container ends up silently degraded.
|
||||
const tone = partial || failed ? "error" : rolledBack ? "off" : "ok";
|
||||
const heading = partial
|
||||
? "Updated, but not completely"
|
||||
: failed
|
||||
? "Update failed"
|
||||
: rolledBack
|
||||
? "Rolled back"
|
||||
: "Container base updated";
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<StatusIndicator tone={tone} label={heading} className="text-[13px] font-semibold" />
|
||||
</div>
|
||||
|
||||
{report.phase === "succeeded" && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.packages_installed.length} package
|
||||
{report.packages_installed.length === 1 ? "" : "s"} reinstalled,{" "}
|
||||
{report.features_restored.length} feature
|
||||
{report.features_restored.length === 1 ? "" : "s"} restored.
|
||||
{report.paths_copied.length > 0
|
||||
? ` ${report.paths_copied.length} path${report.paths_copied.length === 1 ? "" : "s"} copied across.`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{failed && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.message ||
|
||||
"Update failed. Your container has been restored to its previous state."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rolledBack && (
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
{report.message || "The previous system layer has been put back."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{partial && (
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-[13px] text-[var(--text-primary)]">
|
||||
{report.packages_installed.length} of{" "}
|
||||
{report.packages_requested.length} packages went back on.{" "}
|
||||
<strong>
|
||||
{report.packages_failed.length} did not
|
||||
</strong>
|
||||
, so this container is still missing something it had before.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="rounded-[var(--radius-control)] border border-[var(--error)]/40 bg-[var(--error-muted)] px-3 py-2 select-text"
|
||||
data-testid="migration-failures"
|
||||
>
|
||||
<ul className="space-y-1.5">
|
||||
{report.packages_failed.map((failure) => (
|
||||
<li key={failure.name} className="text-xs leading-snug">
|
||||
<span className="font-mono font-semibold text-[var(--text-primary)]">
|
||||
{failure.name}
|
||||
</span>
|
||||
<span className="text-[var(--text-secondary)]"> — {failure.reason}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{report.packages_failed.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Finish by hand in a shell inside the container:
|
||||
</p>
|
||||
<code className="block px-2.5 py-1.5 font-mono text-xs text-[var(--text-primary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] overflow-x-auto whitespace-pre select-text">
|
||||
{aptRetryCommand(report.packages_failed)}
|
||||
</code>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy("command", aptRetryCommand(report.packages_failed))
|
||||
}
|
||||
>
|
||||
{copied === "command" ? "Copied ✓" : "Copy apt-get line"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
copy("detail", failureReportText(report.packages_failed))
|
||||
}
|
||||
>
|
||||
{copied === "detail" ? "Copied ✓" : "Copy failure details"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.features_restored.length > 0 && !failed && (
|
||||
<div>
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
|
||||
Restored
|
||||
</h4>
|
||||
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
|
||||
{report.features_restored.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report.message && !failed && !rolledBack && (
|
||||
<p className="text-xs text-[var(--text-secondary)] select-text">{report.message}</p>
|
||||
)}
|
||||
|
||||
{report.rollback_available && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_SCOPE}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{report.rollback_available ? (
|
||||
<>
|
||||
<Button size="md" variant="primary" disabled={busy} onClick={onKeep}>
|
||||
Keep
|
||||
</Button>
|
||||
<Button size="md" variant="danger" disabled={busy} onClick={onRollback}>
|
||||
Roll back
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="md" disabled={busy} onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import type {
|
||||
ContainerStaleness,
|
||||
MigrationReport,
|
||||
} from "../../../lib/types";
|
||||
|
||||
const FRESH: ContainerStaleness = {
|
||||
stale: false,
|
||||
known: true,
|
||||
base_image_id: "sha256:aaa",
|
||||
current_base_image_id: "sha256:aaa",
|
||||
snapshot_created_at: "2026-03-01T09:00:00Z",
|
||||
missing_paths: [],
|
||||
missing_features: [],
|
||||
apt_delta: [],
|
||||
npm_global_delta: [],
|
||||
verbatim_paths: [],
|
||||
outdated_package_count: 0,
|
||||
probe_error: null,
|
||||
};
|
||||
|
||||
const STALE: ContainerStaleness = {
|
||||
...FRESH,
|
||||
stale: true,
|
||||
current_base_image_id: "sha256:bbb",
|
||||
missing_paths: ["/usr/bin/socat", "/usr/bin/bwrap"],
|
||||
missing_features: [
|
||||
"Host-browser opening",
|
||||
"Auth bridge tunnel (socat)",
|
||||
"Mission Control",
|
||||
],
|
||||
apt_delta: ["socat", "bubblewrap"],
|
||||
outdated_package_count: 61,
|
||||
};
|
||||
|
||||
function migration(overrides: Partial<ContainerMigration> = {}): ContainerMigration {
|
||||
return {
|
||||
staleness: null,
|
||||
probing: false,
|
||||
running: false,
|
||||
recovered: false,
|
||||
interrupted: null,
|
||||
report: null,
|
||||
log: [],
|
||||
phaseMessage: null,
|
||||
busy: false,
|
||||
start: vi.fn(async () => {}),
|
||||
resume: vi.fn(async () => {}),
|
||||
keep: vi.fn(async () => {}),
|
||||
rollback: vi.fn(async () => {}),
|
||||
dismiss: vi.fn(),
|
||||
refresh: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBanner(m: ContainerMigration, canMigrate = true) {
|
||||
const onOpen = vi.fn();
|
||||
const { container } = render(
|
||||
<ContainerMigrationBanner migration={m} canMigrate={canMigrate} onOpen={onOpen} />,
|
||||
);
|
||||
return { onOpen, container };
|
||||
}
|
||||
|
||||
describe("ContainerMigrationBanner", () => {
|
||||
it("renders nothing when the container is on the current base", () => {
|
||||
const { container } = renderBanner(migration({ staleness: FRESH }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing before the probe has returned", () => {
|
||||
const { container } = renderBanner(migration({ staleness: null }));
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("leads with the missing features rather than image digests", () => {
|
||||
renderBanner(migration({ staleness: STALE }));
|
||||
expect(screen.getByText(/Container base is out of date/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Host-browser opening, Auth bridge tunnel \(socat\) and Mission Control/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/61 packages differ from the versions on the current base/i),
|
||||
).toBeInTheDocument();
|
||||
// Digests are evidence, not the message.
|
||||
expect(screen.queryByText(/sha256/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not claim the packages are behind, only that they differ", () => {
|
||||
renderBanner(migration({ staleness: STALE }));
|
||||
// `outdated_package_count` is a drift measure; the backend explicitly does
|
||||
// not promise every one of them is newer.
|
||||
expect(screen.queryByText(/behind on security updates/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the container was probed when there is no base-image label", () => {
|
||||
// `stale` is always false when `known` is false — an unknown lineage is not
|
||||
// a claim of staleness — but the probe's own findings still have to show.
|
||||
renderBanner(
|
||||
migration({ staleness: { ...STALE, known: false, stale: false } }),
|
||||
);
|
||||
expect(screen.getByText(/probed directly/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/The probe found these missing/i)).toBeInTheDocument();
|
||||
// No version comparison happened, so none is implied.
|
||||
expect(screen.queryByText(/Running on a saved image/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/out of date/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet for an unlabelled container the probe found nothing wrong with", () => {
|
||||
const { container } = renderBanner(
|
||||
migration({
|
||||
staleness: {
|
||||
...FRESH,
|
||||
known: false,
|
||||
stale: false,
|
||||
outdated_package_count: 3,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("disables the action and explains why while the container is running", () => {
|
||||
renderBanner(migration({ staleness: STALE }), false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Update container base/i }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(/Stop the container to update its base/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps reporting an in-flight run after the modal is closed", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
running: true,
|
||||
phaseMessage: "Reinstalling socat…",
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/Updating container base/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Reinstalling socat…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Show progress/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a run recovered from a crash", () => {
|
||||
renderBanner(migration({ staleness: STALE, running: true, recovered: true }));
|
||||
expect(
|
||||
screen.getByText(/A container base update was already running/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/still in progress when the app last closed/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not let an interrupted migration hide behind a plain staleness notice", () => {
|
||||
const m = migration({
|
||||
staleness: STALE,
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: "triple-c-snapshot-p1:pre-migration-1754733600",
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: false, keep_rollback: true },
|
||||
plan: null,
|
||||
},
|
||||
});
|
||||
renderBanner(m);
|
||||
expect(
|
||||
screen.getByText(/A container base update was interrupted/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/part-way onto the new base/i)).toBeInTheDocument();
|
||||
// The plain "Update container base…" call to action must not be what is
|
||||
// offered here — the container is mid-swap, so it is resume or roll back.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Update container base/i }),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume update" }));
|
||||
expect(m.resume).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers no rollback for an interrupted run that kept no rollback image", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
interrupted: {
|
||||
phase: "interrupted",
|
||||
from_image_id: "sha256:aaa",
|
||||
to_base_id: "sha256:bbb",
|
||||
started_at: "2026-08-09T10:00:00Z",
|
||||
report: null,
|
||||
rollback_image: null,
|
||||
staging_path: null,
|
||||
options: { replay_packages: true, copy_paths: false, keep_rollback: false },
|
||||
plan: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Resume update" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("the report", () => {
|
||||
const CLEAN: MigrationReport = {
|
||||
phase: "succeeded",
|
||||
packages_requested: ["socat", "bubblewrap"],
|
||||
packages_installed: [
|
||||
"socat",
|
||||
"bubblewrap",
|
||||
"ca-certificates",
|
||||
"openssl",
|
||||
"curl",
|
||||
"jq",
|
||||
"ripgrep",
|
||||
"unzip",
|
||||
],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [
|
||||
"Host-browser opening",
|
||||
"Auth bridge tunnel (socat)",
|
||||
"Sandbox mode (bubblewrap)",
|
||||
"Mission Control",
|
||||
],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
};
|
||||
|
||||
const PARTIAL: MigrationReport = {
|
||||
phase: "partial",
|
||||
packages_requested: ["socat", "bubblewrap", "libfoo-dev"],
|
||||
packages_installed: ["socat"],
|
||||
packages_failed: [
|
||||
{ name: "bubblewrap", reason: "held back by apt-mark" },
|
||||
{ name: "libfoo-dev", reason: "no installation candidate in noble" },
|
||||
],
|
||||
paths_copied: [],
|
||||
features_restored: ["Auth bridge tunnel (socat)"],
|
||||
rollback_available: true,
|
||||
message: "",
|
||||
};
|
||||
|
||||
it("reports a clean run with counts and both choices", () => {
|
||||
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||
expect(screen.getByText(/8 packages reinstalled/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 features restored/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Keep" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Roll back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names every failed package and why, and does not read as a success", () => {
|
||||
renderBanner(migration({ staleness: STALE, report: PARTIAL }));
|
||||
expect(screen.getByText(/Updated, but not completely/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("bubblewrap")).toBeInTheDocument();
|
||||
expect(screen.getByText(/held back by apt-mark/)).toBeInTheDocument();
|
||||
expect(screen.getByText("libfoo-dev")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/no installation candidate in noble/),
|
||||
).toBeInTheDocument();
|
||||
// And the exact line that finishes the job by hand.
|
||||
expect(
|
||||
screen.getByText("sudo apt-get install -y bubblewrap libfoo-dev"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy apt-get line/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says a failed run has already been restored, and offers no rollback", () => {
|
||||
renderBanner(
|
||||
migration({
|
||||
staleness: STALE,
|
||||
report: {
|
||||
phase: "failed",
|
||||
packages_requested: [],
|
||||
packages_installed: [],
|
||||
packages_failed: [],
|
||||
paths_copied: [],
|
||||
features_restored: [],
|
||||
rollback_available: false,
|
||||
message:
|
||||
"Update failed at replay. Your container has been restored to its previous state.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText(/Update failed at replay/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Roll back" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not describe rollback as a time machine", () => {
|
||||
renderBanner(migration({ staleness: FRESH, report: CLEAN }));
|
||||
expect(
|
||||
screen.getByText(/Rollback restores the system layer only/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Volumes are never touched/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import Button from "../../ui/Button";
|
||||
import StatusIndicator from "../../ui/StatusIndicator";
|
||||
import MigrationReportCard from "../MigrationReportCard";
|
||||
import { ROLLBACK_SCOPE, formatSnapshotDate, joinFeatures } from "../migrationCopy";
|
||||
|
||||
interface Props {
|
||||
migration: ContainerMigration;
|
||||
/** Migration mirrors Reset's gate: the container has to be stopped. */
|
||||
canMigrate: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
const SHELL =
|
||||
"border rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2";
|
||||
|
||||
/**
|
||||
* The Overview answer to "why is this container behaving oddly?".
|
||||
*
|
||||
* It leads with the *features* that are missing, not image digests: a user does
|
||||
* not know or care that `sha256:abc…` differs from `sha256:def…`, they care
|
||||
* that host-browser opening and the auth bridge do not work. Digests are the
|
||||
* evidence, not the message.
|
||||
*
|
||||
* It also has to survive the run: an in-flight migration, an interrupted one,
|
||||
* and the report are all shown here, because the modal is dismissable and the
|
||||
* outcome must not vanish with it.
|
||||
*/
|
||||
export default function ContainerMigrationBanner({
|
||||
migration,
|
||||
canMigrate,
|
||||
onOpen,
|
||||
}: Props) {
|
||||
const { staleness, running, recovered, interrupted, report, phaseMessage, busy } =
|
||||
migration;
|
||||
|
||||
// The report outranks staleness: after a run, the outcome is the news.
|
||||
if (report) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} ${
|
||||
report.phase === "partial" || report.phase === "failed"
|
||||
? "border-[var(--error)]/40 bg-[var(--error-muted)]"
|
||||
: "border-[var(--border-color)] bg-[var(--bg-secondary)]"
|
||||
}`}
|
||||
aria-label="Container base update result"
|
||||
>
|
||||
<MigrationReportCard
|
||||
report={report}
|
||||
busy={busy}
|
||||
onKeep={() => void migration.keep()}
|
||||
onRollback={() => void migration.rollback()}
|
||||
onDismiss={migration.dismiss}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (running) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||
aria-label="Container base update in progress"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<StatusIndicator
|
||||
tone="busy"
|
||||
label={
|
||||
recovered
|
||||
? "A container base update was already running"
|
||||
: "Updating container base"
|
||||
}
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)] truncate">
|
||||
{phaseMessage ?? "Starting…"}
|
||||
</p>
|
||||
{recovered && (
|
||||
<p className="mt-1 text-xs text-[var(--text-secondary)]">
|
||||
It was still in progress when the app last closed. Picking it back up.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button size="md" onClick={onOpen}>
|
||||
Show progress
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing is driving this one. It outranks staleness because the container is
|
||||
// sitting mid-swap, and the one thing it must never do is look like a normal
|
||||
// out-of-date container that the user can take or leave.
|
||||
if (interrupted) {
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--error)]/40 bg-[var(--error-muted)]`}
|
||||
aria-label="Container base update was interrupted"
|
||||
>
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label="A container base update was interrupted"
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
It started{" "}
|
||||
{formatSnapshotDate(interrupted.started_at) ?? "earlier"} and the app
|
||||
closed before it finished, so this container is part-way onto the new
|
||||
base. Resuming replays the same plan it was given.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{ROLLBACK_SCOPE}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onClick={() => void migration.resume()}
|
||||
>
|
||||
Resume update
|
||||
</Button>
|
||||
{interrupted.rollback_image && (
|
||||
<Button
|
||||
size="md"
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
onClick={() => void migration.rollback()}
|
||||
>
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!staleness) return null;
|
||||
|
||||
// `stale` is deliberately false whenever `known` is false — an unestablished
|
||||
// lineage is not a claim of staleness. But a container with no base-image
|
||||
// label is exactly the old container most likely to be missing things, and
|
||||
// the probe says so directly. So the probe's own findings are grounds to
|
||||
// speak up even though the version comparison never happened.
|
||||
const probeFoundGaps =
|
||||
!staleness.known &&
|
||||
(staleness.missing_features.length > 0 || staleness.missing_paths.length > 0);
|
||||
if (!staleness.stale && !probeFoundGaps) return null;
|
||||
|
||||
const snapshot = formatSnapshotDate(staleness.snapshot_created_at);
|
||||
const features = joinFeatures(staleness.missing_features);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={`${SHELL} border-[var(--warning)]/40 bg-[var(--warning-muted)]`}
|
||||
aria-label="Container base is out of date"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<StatusIndicator
|
||||
tone="error"
|
||||
label={
|
||||
staleness.known
|
||||
? "Container base is out of date"
|
||||
: "Container is missing things the current base ships"
|
||||
}
|
||||
className="text-[13px] font-semibold"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{staleness.known
|
||||
? snapshot
|
||||
? `Running on a saved image from ${snapshot}.`
|
||||
: "Running on a saved image older than the current base."
|
||||
: "This container predates base-image tracking, so it was probed directly."}
|
||||
</p>
|
||||
|
||||
{staleness.missing_features.length > 0 && (
|
||||
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||
<span className="text-[var(--text-secondary)]">{features}.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{staleness.missing_features.length === 0 &&
|
||||
staleness.missing_paths.length > 0 && (
|
||||
<p className="text-xs leading-snug text-[var(--text-primary)]">
|
||||
{staleness.known ? "Missing: " : "The probe found these missing: "}
|
||||
<span className="font-mono text-[var(--text-secondary)]">
|
||||
{staleness.missing_paths.join(", ")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Deliberately "differ" rather than "behind": the count is a drift
|
||||
measure, not a promise that every one of them is newer. */}
|
||||
{staleness.outdated_package_count > 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{staleness.outdated_package_count} package
|
||||
{staleness.outdated_package_count === 1 ? "" : "s"} differ from the
|
||||
versions on the current base, where security updates land.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{staleness.probe_error && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Some checks did not complete: {staleness.probe_error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!canMigrate && (
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-snug">
|
||||
Stop the container to update its base.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="primary"
|
||||
disabled={!canMigrate}
|
||||
onClick={onOpen}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
Update container base…
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import PermissionModeControl, {
|
||||
permissionModePatch,
|
||||
} from "../PermissionModeControl";
|
||||
import CapabilityTiles from "./CapabilityTiles";
|
||||
import ContainerMigrationBanner from "./ContainerMigrationBanner";
|
||||
import type { ContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import SaveIndicator from "../../ui/SaveIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatAge } from "./format";
|
||||
@@ -31,6 +33,11 @@ interface Props {
|
||||
saveState: SaveState;
|
||||
actions: ReturnType<typeof useProjectActions>;
|
||||
onOpenTab: (tab: ProjectHomeTabId) => void;
|
||||
/** Base-image staleness, run state and report. Owned by `ProjectHome`. */
|
||||
migration: ContainerMigration;
|
||||
/** Migration mirrors Reset's gate: only offered on a stopped container. */
|
||||
canMigrate: boolean;
|
||||
onOpenMigration: () => void;
|
||||
}
|
||||
|
||||
export default function OverviewTab({
|
||||
@@ -39,6 +46,9 @@ export default function OverviewTab({
|
||||
saveState,
|
||||
actions,
|
||||
onOpenTab,
|
||||
migration,
|
||||
canMigrate,
|
||||
onOpenMigration,
|
||||
}: Props) {
|
||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
@@ -120,6 +130,14 @@ export default function OverviewTab({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* A container missing socat and bwrap is a capability statement, so the
|
||||
out-of-date warning sits directly above the capability inventory. */}
|
||||
<ContainerMigrationBanner
|
||||
migration={migration}
|
||||
canMigrate={canMigrate}
|
||||
onOpen={onOpenMigration}
|
||||
/>
|
||||
|
||||
<CapabilityTiles
|
||||
project={project}
|
||||
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { useAppState } from "../../../store/appState";
|
||||
import { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useProjects } from "../../../hooks/useProjects";
|
||||
import { useProjectSave } from "../../../hooks/useSaveState";
|
||||
import { useContainerMigration } from "../../../hooks/useContainerMigration";
|
||||
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
|
||||
import Button from "../../ui/Button";
|
||||
import OverflowMenu from "../../ui/OverflowMenu";
|
||||
import ConfirmRemoveModal from "../ConfirmRemoveModal";
|
||||
import ConfirmResetModal from "../ConfirmResetModal";
|
||||
import MigrateContainerModal from "../MigrateContainerModal";
|
||||
import OverviewTab from "./OverviewTab";
|
||||
import SessionsTab from "./SessionsTab";
|
||||
import AutomationTab from "./AutomationTab";
|
||||
@@ -43,6 +45,7 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [showMigration, setShowMigration] = useState(false);
|
||||
const { runningSince, progress } = useAppState(
|
||||
useShallow((s) => ({
|
||||
runningSince: s.runningSince[projectId],
|
||||
@@ -64,6 +67,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const { save, saveState } = useProjectSave(
|
||||
project ?? ({ id: projectId, name: "" } as never),
|
||||
);
|
||||
// Owned here, not in the modal: the run outlives the dialog, and the Overview
|
||||
// banner has to keep showing progress and the report after it is dismissed.
|
||||
const migration = useContainerMigration(
|
||||
project ?? ({ id: projectId, name: "", container_id: null } as never),
|
||||
);
|
||||
|
||||
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
|
||||
|
||||
@@ -81,6 +89,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
const isTransitioning =
|
||||
project.status === "starting" || project.status === "stopping";
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
// Rebuilding on a new base swaps the container out, so it gates exactly like
|
||||
// Reset does — with the extra condition that there is a container to migrate.
|
||||
// An interrupted migration is excluded too: its action is Resume, on the
|
||||
// Overview banner, not a fresh pre-flight.
|
||||
const canMigrate =
|
||||
isStopped &&
|
||||
!actions.busy &&
|
||||
!migration.running &&
|
||||
!migration.interrupted &&
|
||||
!!project.container_id;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
|
||||
@@ -147,6 +165,11 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
onSelect: actions.handleBackup,
|
||||
disabled: actions.backingUp || !project.container_id,
|
||||
},
|
||||
{
|
||||
label: "Update container base…",
|
||||
onSelect: () => setShowMigration(true),
|
||||
disabled: !canMigrate,
|
||||
},
|
||||
{
|
||||
label: "Reset container…",
|
||||
onSelect: () => setConfirmReset(true),
|
||||
@@ -200,6 +223,9 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
saveState={saveState}
|
||||
actions={actions}
|
||||
onOpenTab={setTab}
|
||||
migration={migration}
|
||||
canMigrate={canMigrate}
|
||||
onOpenMigration={() => setShowMigration(true)}
|
||||
/>
|
||||
)}
|
||||
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
|
||||
@@ -213,6 +239,16 @@ export default function ProjectHome({ projectId, active }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showMigration && (
|
||||
<MigrateContainerModal
|
||||
projectName={project.name}
|
||||
staleness={migration.staleness}
|
||||
migration={migration}
|
||||
// Closing is not cancelling — the run keeps going and the Overview
|
||||
// banner keeps reporting it.
|
||||
onClose={() => setShowMigration(false)}
|
||||
/>
|
||||
)}
|
||||
{confirmReset && (
|
||||
<ConfirmResetModal
|
||||
projectName={project.name}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Shared wording for container base-image migration.
|
||||
*
|
||||
* The banner, the pre-flight modal and the report all have to make the same
|
||||
* promise about what survives, or the feature reads as another Reset. It is
|
||||
* written once here so the three surfaces cannot drift apart.
|
||||
*/
|
||||
|
||||
import type { PackageFailure } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
|
||||
* — they live on the two Docker volumes, which the new container mounts as-is.
|
||||
*/
|
||||
export const KEPT_AUTOMATICALLY = [
|
||||
"Your claude login and ~/.claude.json — no signing in again",
|
||||
"Skills, agents, commands, hooks, plugins and MCP config",
|
||||
"Every saved session transcript, so past sessions still resume",
|
||||
"Scheduler tasks and their logs",
|
||||
"SSH keys, git config and shell history",
|
||||
"Claude Code itself, plus Rust/cargo, uv and ruff in your home directory",
|
||||
];
|
||||
|
||||
export const KEPT_WHY =
|
||||
"/home/claude and ~/.claude are Docker volumes. They detach from the old container and re-attach to the new one unchanged.";
|
||||
|
||||
export const LOST_WITHOUT_REPLAY =
|
||||
"What a new base does not carry over is the root-level system packages you installed with apt. Those are the only thing this update has to put back.";
|
||||
|
||||
/**
|
||||
* Said plainly everywhere rollback is offered. Rollback is not a time machine:
|
||||
* it swaps the system layer back and leaves both volumes exactly where the
|
||||
* migrated session left them.
|
||||
*/
|
||||
export const ROLLBACK_SCOPE =
|
||||
"Rollback restores the system layer only. Your volumes are never touched, so anything Claude wrote to your home directory or a mounted workspace during the migrated session stays as it is.";
|
||||
|
||||
export const ROLLBACK_DISK_COST =
|
||||
"A rollback image is close to a full second copy of the container — snapshots here run 3.8–12.3 GB and share almost nothing with the new base, so it costs nearly its full size on disk. It is deleted the moment you press Keep.";
|
||||
|
||||
/** Shown mid-run, where rollback is not a button but is still the safety net. */
|
||||
export const MID_RUN_SAFETY =
|
||||
"If this fails, the container is put back on its previous system layer automatically. Your volumes are not touched at any point.";
|
||||
|
||||
export const REPLAY_COST =
|
||||
"Needs network access and usually takes 1–2 minutes.";
|
||||
|
||||
/** `1 Mar` — short enough to sit inline in the banner sentence. */
|
||||
export function formatSnapshotDate(iso: string | null): string | null {
|
||||
if (!iso) return null;
|
||||
const ms = Date.parse(iso);
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return new Date(ms).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Join a list into prose: "a, b and c". Used for the missing-features line. */
|
||||
export function joinFeatures(features: string[]): string {
|
||||
if (features.length === 0) return "";
|
||||
if (features.length === 1) return features[0];
|
||||
return `${features.slice(0, -1).join(", ")} and ${features[features.length - 1]}`;
|
||||
}
|
||||
|
||||
/** The exact line to paste into a shell to finish a partial migration by hand. */
|
||||
export function aptRetryCommand(failures: PackageFailure[]): string {
|
||||
return `sudo apt-get install -y ${failures.map((f) => f.name).join(" ")}`;
|
||||
}
|
||||
|
||||
/** Plain-text form of a partial report, for the copy button. */
|
||||
export function failureReportText(failures: PackageFailure[]): string {
|
||||
const lines = failures.map((f) => `${f.name}: ${f.reason}`);
|
||||
return [
|
||||
"Packages that could not be reinstalled:",
|
||||
...lines,
|
||||
"",
|
||||
aptRetryCommand(failures),
|
||||
].join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user