Make a rollback pin outliving its project visible and deletable

`survey_rollback_pins` walks images, not projects, and deliberately
tolerates an absent project by falling back to the raw id as the display
name. Two things then dropped it on the floor: `destroy` called
`find_project` before the confirmation check, so it refused such a pin
every time, and the per-project table joins destructive items to rows by
project_id, where rows come only from projects in the store. The result
was a multi-GB `pre-migration-*` image that the scan measured, the panel
never rendered, and nothing could remove — in the one screen built to
find exactly that.

`destroy` takes the same early return `OrphanVolume` already takes, and
still validates the tag: `latest` names the project's live snapshot, so
the ownerless path must not be a way around that check.

The UI grows a bucket for destructive items matching no row, rather than
filtering them away. The typed gate already compared against the id via
`project_name`; the dialog now says "project id" instead of asking for a
project name that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 12:18:04 -07:00
co-authored by Claude Opus 5
parent 6b8d43414d
commit 4f6c012071
4 changed files with 244 additions and 2 deletions
@@ -492,6 +492,68 @@ describe("DiskSettings", () => {
expect(await screen.findByTestId("disk-orphan-bucket")).toBeInTheDocument();
});
it("shows a rollback pin whose project is gone, instead of dropping it", async () => {
// `survey_rollback_pins` walks images, not projects, and falls back to the
// raw id when the project is absent. The per-project table joins
// destructive items to rows by `project_id`, and rows come only from
// projects in the store — so before the unmatched bucket, such a pin was
// measured by the scan and rendered nowhere at all. A multi-GB image the
// panel knew about and offered no way to remove.
const ownerlessPin: DestructiveItem = {
target: {
kind: "rollback_pin",
project_id: "dead0000-0000-0000-0000-000000000000",
tag: "pre-migration-20260101-101500",
},
project_id: "dead0000-0000-0000-0000-000000000000",
// The Rust falls back to the id, and it is what `destroy` compares
// against — so this is the string the user has to type.
project_name: "dead0000-0000-0000-0000-000000000000",
label: "Rollback pin pre-migration-20260101-101500",
loses: "The only copy of that migration's rollback target.",
bytes: 5_400_000_000,
blocked: null,
};
listReclaimable.mockResolvedValue(plan({ destructive: [ownerlessPin] }));
await renderAndScan();
const bucket = await screen.findByTestId("disk-unmatched-bucket");
expect(within(bucket).getByText(/Rollback pin pre-migration-20260101-101500/)).toBeInTheDocument();
// And it is not silently folded into the project table.
const table = screen.queryByTestId("disk-project-table");
if (table) {
expect(table.textContent).not.toMatch(/pre-migration-20260101-101500/);
}
});
it("asks for the project id, not a project name, when there is no project", async () => {
// The gate compares against `project_name`, which is the raw id here. That
// works — but a dialog captioned "type the project name" for a project that
// no longer exists asks for something the user cannot supply.
const ownerlessPin: DestructiveItem = {
target: {
kind: "rollback_pin",
project_id: "dead0000-0000-0000-0000-000000000000",
tag: "pre-migration-20260101-101500",
},
project_id: "dead0000-0000-0000-0000-000000000000",
project_name: "dead0000-0000-0000-0000-000000000000",
label: "Rollback pin pre-migration-20260101-101500",
loses: "The only copy of that migration's rollback target.",
bytes: 5_400_000_000,
blocked: null,
};
listReclaimable.mockResolvedValue(plan({ destructive: [ownerlessPin] }));
await renderAndScan();
const bucket = await screen.findByTestId("disk-unmatched-bucket");
fireEvent.click(within(bucket).getByRole("button", { name: /Delete/ }));
const dialog = await screen.findByRole("dialog");
expect(dialog.textContent).toMatch(/project id/i);
expect(dialog.textContent).not.toMatch(/type the project name/i);
});
it("keeps orphaned volumes out of the per-project table", async () => {
// The table keys off `project_id`, and an orphan's id matches no row by
// definition. Passing them in anyway is how one would leak into the wrong
+71 -2
View File
@@ -105,7 +105,18 @@ export default function DiskSettings() {
// moved from the tick list to the destructive list can vanish from the UI
// entirely rather than reappear behind a confirmation.
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
const projectDestructive = plan?.destructive.filter((d) => !isOrphanVolume(d)) ?? [];
// A destructive item is rendered inside its project's row, so one whose
// project id matches no row would be measured and shown nowhere. That is not
// hypothetical: `survey_rollback_pins` walks *images*, not projects, and
// deliberately tolerates an absent project by falling back to the raw id as
// the display name — so a pin left behind by a deleted project is exactly
// this case, and it is the multi-GB kind. Anything unmatched gets its own
// section rather than being silently dropped.
const rowIds = new Set((report?.projects ?? []).map((r) => r.project_id));
const projectDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && rowIds.has(d.project_id)) ?? [];
const unmatchedDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && !rowIds.has(d.project_id)) ?? [];
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
@@ -495,6 +506,58 @@ export default function DiskSettings() {
</section>
)}
{/* --- Destructive leftovers with no project row ------------------- */}
{unmatchedDestructive.length > 0 && (
<section className="space-y-2" data-testid="disk-unmatched-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Leftovers from projects no longer in Triple-C
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
These belong to a project id that is not in your project list, so there
is no row above to show them under. The same caveat as the volumes below
applies: &ldquo;not in your project list&rdquo; is the only thing this
means, and an idle live project is indistinguishable from a deleted one
from Docker&rsquo;s side. Because there is no project name to type, each
one is confirmed against its project <em>id</em>.
</p>
<ul className="space-y-1.5">
{unmatchedDestructive.map((item) => (
<li
key={destructiveKey(item)}
className="flex items-start justify-between gap-3"
data-testid={`disk-unmatched-${destructiveKey(item)}`}
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)] font-mono break-all">
{item.label}
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.loses}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-secondary)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{formatBytes(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openDestroying(item)}
>
Delete&hellip;
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
{orphanVolumes.length > 0 && (
<section className="space-y-2" data-testid="disk-orphan-bucket">
@@ -704,6 +767,12 @@ export default function DiskSettings() {
// case-sensitive and a mangled name in the heading is a name the user
// cannot type.
const orphan = isOrphanVolume(destroying);
// A leftover whose project is gone has no name either. `project_name`
// is the raw id in that case — which is deliberate on the Rust side and
// is exactly what `destroy` compares against — so the gate works, but
// the label has to say "id" or it asks for something that does not
// exist.
const ownerless = !orphan && !rowIds.has(destroying.project_id);
return (
<TypedConfirmModal
title={
@@ -712,7 +781,7 @@ export default function DiskSettings() {
: `Delete ${destroying.label.toLowerCase()}`
}
expected={destroying.project_name}
subject={orphan ? "volume name" : "project name"}
subject={orphan ? "volume name" : ownerless ? "project id" : "project name"}
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
busy={working}
// A failure here has to land inside the dialog. The panel's own