webui: add rebuild-from-scratch and delete actions

This commit is contained in:
2026-07-28 02:52:43 -07:00
parent c8c3d941ca
commit 92948b2ea7
2 changed files with 107 additions and 6 deletions
+61 -6
View File
@@ -758,6 +758,7 @@ async function refreshJobs() {
renderJobs(jobs);
renderNowBar(jobs);
if (selectedId) refreshDetail();
else clearDetail();
schedulePoll(jobs);
}
@@ -874,6 +875,21 @@ function renderOutputPath(job) {
outputPathDiv.replaceChildren(wrap);
}
function clearDetail() {
reviewTitle.replaceChildren();
reviewError.hidden = true;
reviewError.textContent = "";
document.getElementById("warnings").replaceChildren(
emptyNote("no songs queued yet — nothing to review"));
outputPathDiv.replaceChildren();
reviewActions.replaceChildren();
delete reviewActions.dataset.jobId;
reviewLineage.replaceChildren();
reviewLineage.hidden = true;
delete reviewLineage.dataset.jobId;
detail.textContent = "no songs queued yet — nothing to log";
}
function setFormMode(inp) {
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
@@ -920,21 +936,60 @@ async function rerunJob(id) {
refreshJobs();
}
async function rebuildFullJob(id) {
formError.textContent = "";
const resp = await fetch(`/api/jobs/${id}/rebuild-full`, { method: "POST" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return;
}
selectedId = (await resp.json()).id;
refreshJobs();
}
async function deleteJob(id) {
if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;
formError.textContent = "";
const resp = await fetch(`/api/jobs/${id}`, { method: "DELETE" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
formError.textContent = body.detail || `error: ${resp.status}`;
return;
}
if (selectedId === id) selectedId = null;
refreshJobs();
}
const ACTION_LABELS = {
rerun: "RERUN",
edit: "EDIT & REBUILD",
"rebuild-full": "REBUILD FROM SCRATCH",
delete: "DELETE",
};
const ACTION_HANDLERS = {
rerun: rerunJob,
edit: startRebuild,
"rebuild-full": rebuildFullJob,
delete: deleteJob,
};
function actionButton(action, jobId) {
const button = document.createElement("button");
button.type = "button";
button.className = "action";
button.dataset.action = action;
button.textContent = action === "rerun" ? "RERUN" : "EDIT & REBUILD";
button.addEventListener("click",
() => action === "rerun" ? rerunJob(jobId) : startRebuild(jobId));
button.textContent = ACTION_LABELS[action];
button.addEventListener("click", () => ACTION_HANDLERS[action](jobId));
return button;
}
function renderActions(job) {
// no controls mid-run: both endpoints answer 409 until the job settles
const wanted = job.status === "failed" ? ["rerun", "edit"]
: job.status === "done" ? ["edit"] : [];
// no controls mid-run: every endpoint answers 409 until the job settles
const doneActions = ["edit", "rebuild-full", "delete"];
const wanted = job.status === "failed" ? ["rerun", ...doneActions]
: job.status === "done" ? doneActions : [];
const shown = [...reviewActions.children].map(b => b.dataset.action);
// rebuilding an unchanged row would detach a focused action on every poll
if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;
+46
View File
@@ -532,6 +532,44 @@ def _rebuild_flaws(html):
return flaws
def _rebuild_full_and_delete_flaws(html):
flaws = []
actions = _fn_body(html, "renderActions")
for action in ('"rebuild-full"', '"delete"'):
if action not in actions:
flaws.append(f"{action} is not offered from renderActions")
rf = _fn_body(html, "rebuildFullJob")
if "/rebuild-full" not in rf or 'method: "POST"' not in rf:
flaws.append("rebuildFullJob does not POST to /rebuild-full")
d = _fn_body(html, "deleteJob")
confirm_idx = d.find("confirm(")
fetch_idx = d.find("fetch(")
if confirm_idx == -1:
flaws.append("deleteJob does not confirm before deleting")
elif fetch_idx != -1 and confirm_idx > fetch_idx:
flaws.append("deleteJob fetches before confirming")
if 'method: "DELETE"' not in d:
flaws.append("deleteJob does not send a DELETE request")
return flaws
def test_rebuild_full_and_delete_controls_are_wired_up():
assert _rebuild_full_and_delete_flaws(_index_html()) == []
def _empty_state_flaws(html):
refresh = _fn_body(html, "refreshJobs")
if "clearDetail()" not in refresh:
return ["refreshJobs never resets the panel when no job is selected"]
return []
def test_review_panel_resets_when_no_job_is_selected():
assert _empty_state_flaws(_index_html()) == []
# each mutation is a plausible regression; the checker above must reject all of them
MUTATIONS = [
(_focus_flaws, "queue: card moved unconditionally",
@@ -560,6 +598,14 @@ MUTATIONS = [
(_rebuild_flaws, "rebuild: action row rebuilt on every poll",
"if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;",
""),
(_rebuild_full_and_delete_flaws, "delete: confirmation dialog removed",
'if (!confirm("Delete this song? This removes all its files and cannot be undone.")) return;',
""),
(_rebuild_full_and_delete_flaws, "rebuild-full: no longer offered on a done job",
'["edit", "rebuild-full", "delete"]', '["edit", "delete"]'),
(_empty_state_flaws, "empty state: clearDetail no longer wired into refreshJobs",
"if (selectedId) refreshDetail();\n else clearDetail();",
"if (selectedId) refreshDetail();"),
]