Changesets: staged deletes of gone records count as done, name stale ops

A web changeset with a staged record delete could not be applied after
the record was deleted elsewhere (e.g. with the CLI), and the error only
said "record N does not exist", so it was hard to find the operation to
remove. A delete of a record that is already gone is now a no-op with a
warning. Other operations that no longer apply fail with their number and
summary (op_summary in the API), and the Changes page marks that row and
offers to remove it from the error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 22:59:19 +01:00
1 parent c37de31f4c
commit 49b1c872c9
7 files changed
+113 -17

No files matched your search

+23 -8
View File
@@ -44,18 +44,33 @@ async function draftPanel(d) {
const applyBtn = h("button", { class: "primary", disabled: true }, "Apply changes");
const forceBox = h("label", { class: "check small hidden" }, h("input", { type: "checkbox" }), "Overwrite files changed outside Jarl");
const ops = d.ops || [];
const removeOp = async (seq) => {
try {
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + seq);
useDraft(cs);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const opsTable = table([
{ label: "#", render: (o) => String(o.seq) },
{ label: "Change", render: (o) => o.summary || o.op, tdClass: "mono small" },
{ label: "Added", render: (o) => time(o.added_at) },
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: async () => {
try {
const cs = await api("DELETE", "/changesets/" + d.id + "/ops/" + o.seq);
useDraft(cs);
rerender();
} catch (e) { toast(e.message, "err", 8000); }
} }, "Remove"), tdClass: "actions-cell" },
{ label: "", render: (o) => h("button", { class: "small", title: "Remove this operation from the changeset", onclick: () => removeOp(o.seq) }, "Remove"), tdClass: "actions-cell" },
], ops);
// failedOp marks the operation that no longer applies and says how to
// get past it.
const failedOp = (err) => {
const i = ops.findIndex((o) => o.seq === err.op);
const rows = opsTable.querySelectorAll("tbody tr");
if (i < 0 || !rows[i]) return errorBox({ message: err.message, details: err });
rows[i].classList.add("op-failed");
return h("div", { class: "msg err" },
h("div", null, h("strong", null, "Operation " + err.op + " can't be applied: "), err.message),
h("div", { class: "mono small" }, ops[i].summary || ops[i].op),
h("div", { style: { marginTop: "8px" } },
h("button", { class: "small", onclick: () => removeOp(err.op) }, "Remove operation " + err.op),
" ", h("span", { class: "muted small" }, "It is marked in the list above. Then check the rest again.")));
};
const runPreview = async () => {
try {
const p = await get("/changesets/" + d.id + "/preview");
@@ -64,7 +79,7 @@ async function draftPanel(d) {
const drift = (p.drift || []);
forceBox.classList.toggle("hidden", drift.length === 0);
mount(preview,
p.error ? errorBox({ message: p.error.message, details: p.error }) : null,
p.error ? (p.error.op ? failedOp(p.error) : errorBox({ message: p.error.message, details: p.error })) : null,
valid ? h("div", { class: "msg ok" }, "Valid. named-checkconf accepts the new configuration.", (p.serials || []).length ? " Serials: " + p.serials.map((s) => s.zone.replace(/\.$/, "") + " " + s.old + "→" + s.new).join(", ") : "") : null,
!p.error && !valid ? h("div", { class: "msg err" }, "This changeset can't be applied yet:") : null,
problemsList(p.problems),
+2
View File
@@ -154,6 +154,8 @@ td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
tr.editing td { background: var(--accent-soft); }
tr.pending-add td { background: var(--add); }
tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); }
tr.op-failed td { background: var(--err-soft); }
tr.op-failed td:first-child { box-shadow: inset 3px 0 0 var(--err); }
tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); }
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
/* Disabled records: hatched, so they stand out from live and pending rows. */