feat: rerun and edit-and-rebuild controls in the web ui
This commit is contained in:
@@ -343,7 +343,7 @@ button { cursor: pointer; }
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.output-path code { flex: 1; overflow-wrap: anywhere; font-family: var(--font-mono); font-size: 0.82rem; }
|
||||
#copy-path {
|
||||
#copy-path, .action, #cancel-rebuild {
|
||||
flex: none;
|
||||
padding: 5px 12px;
|
||||
font-family: var(--font-display);
|
||||
@@ -356,7 +356,23 @@ button { cursor: pointer; }
|
||||
border: 1px solid var(--control);
|
||||
border-radius: 2px;
|
||||
}
|
||||
#copy-path:hover { border-color: var(--text-muted); }
|
||||
#copy-path:hover, .action:hover, #cancel-rebuild:hover { border-color: var(--text-muted); }
|
||||
|
||||
.form-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.keep-note { margin: -8px 0 0; font-size: 0.78rem; color: var(--aqua); }
|
||||
#review-actions { display: flex; gap: 10px; margin-top: 14px; }
|
||||
#review-lineage { margin: -10px 0 14px; }
|
||||
.lineage {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
color: var(--aqua);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
.lineage:hover { border-bottom-color: var(--aqua); }
|
||||
.job-mark { grid-area: 1 / 2; justify-self: end; font-size: 0.8rem; color: var(--text-muted); }
|
||||
|
||||
#detail {
|
||||
margin: 13px 0 0;
|
||||
@@ -480,6 +496,7 @@ button { cursor: pointer; }
|
||||
<div class="stack">
|
||||
<label class="field"><span>YouTube URL</span><input name="youtube_url"></label>
|
||||
<label class="field"><span>Video file</span><input type="file" name="video"></label>
|
||||
<p class="keep-note" data-keep="video" hidden></p>
|
||||
<div class="pair">
|
||||
<label class="field"><span>Genre</span><input name="genre"></label>
|
||||
<label class="field"><span>Year</span><input name="year" type="number"></label>
|
||||
@@ -489,9 +506,13 @@ button { cursor: pointer; }
|
||||
<label class="field"><span>BPM override</span><input name="bpm" type="number" step="any"></label>
|
||||
</div>
|
||||
<label class="field"><span>Cover image</span><input type="file" name="cover"></label>
|
||||
<p class="keep-note" data-keep="cover" hidden></p>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="primary">CREATE SONG</button>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary">CREATE SONG</button>
|
||||
<button type="button" id="cancel-rebuild" hidden>CANCEL</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="form-error" role="alert"></p>
|
||||
</section>
|
||||
@@ -499,9 +520,11 @@ button { cursor: pointer; }
|
||||
<section id="review">
|
||||
<h2>REVIEW</h2>
|
||||
<h3 id="review-title"></h3>
|
||||
<p id="review-lineage" hidden></p>
|
||||
<p id="review-error" class="review-error" hidden></p>
|
||||
<div id="warnings"><p class="empty-note">no songs queued yet — nothing to review</p></div>
|
||||
<div id="output-path"></div>
|
||||
<div id="review-actions"></div>
|
||||
<details>
|
||||
<summary>Log</summary>
|
||||
<pre id="detail">no songs queued yet — nothing to log</pre>
|
||||
@@ -527,6 +550,13 @@ const nowBar = document.getElementById("now-bar");
|
||||
const nowLabel = document.getElementById("now-label");
|
||||
const nowTitle = document.getElementById("now-title");
|
||||
const stageTrack = document.getElementById("stage-track");
|
||||
const reviewActions = document.getElementById("review-actions");
|
||||
const reviewLineage = document.getElementById("review-lineage");
|
||||
const cancelRebuild = document.getElementById("cancel-rebuild");
|
||||
const formHeading = document.querySelector("#new-song h2");
|
||||
const submitButton = form.querySelector("button[type=submit]");
|
||||
let rebuildOf = null;
|
||||
let JOBS = [];
|
||||
let selectedId = null;
|
||||
let STAGES = [];
|
||||
let pollTimer = null;
|
||||
@@ -557,13 +587,15 @@ form.addEventListener("submit", async (e) => {
|
||||
if (isFile && value.size === 0) data.delete(name);
|
||||
else if (!isFile && value === "") data.delete(name);
|
||||
}
|
||||
const resp = await fetch("/api/jobs", { method: "POST", body: data });
|
||||
const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";
|
||||
const resp = await fetch(url, { method: "POST", body: data });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}));
|
||||
formError.textContent = body.detail || `error: ${resp.status}`;
|
||||
return; // before the reset below, so a rejected submission is never retyped
|
||||
}
|
||||
form.reset();
|
||||
selectedId = (await resp.json()).id;
|
||||
exitRebuild();
|
||||
refreshJobs();
|
||||
});
|
||||
|
||||
@@ -588,6 +620,8 @@ for (const input of form.querySelectorAll('input[type="file"]')) {
|
||||
});
|
||||
}
|
||||
|
||||
cancelRebuild.addEventListener("click", exitRebuild);
|
||||
|
||||
function jobCard(job) {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
@@ -610,7 +644,15 @@ function jobCard(job) {
|
||||
const time = document.createElement("span");
|
||||
time.className = "job-elapsed";
|
||||
|
||||
card.append(title, artist, stateLine, time);
|
||||
const mark = document.createElement("span");
|
||||
mark.className = "job-mark";
|
||||
const markGlyph = document.createElement("span");
|
||||
markGlyph.setAttribute("aria-hidden", "true");
|
||||
const markLabel = document.createElement("span");
|
||||
markLabel.className = "sr-only";
|
||||
mark.append(markGlyph, markLabel);
|
||||
|
||||
card.append(title, artist, stateLine, time, mark);
|
||||
card.addEventListener("click", () => selectJob(job.id));
|
||||
paintCard(card, job);
|
||||
return card;
|
||||
@@ -618,7 +660,7 @@ function jobCard(job) {
|
||||
|
||||
function paintCard(card, job) {
|
||||
const state = STATE[job.status] ?? { glyph: "·", cls: "st-queued" };
|
||||
const [title, artist, stateLine, time] = card.children;
|
||||
const [title, artist, stateLine, time, mark] = card.children;
|
||||
card.className = `job ${state.cls}`;
|
||||
if (job.id === selectedId) card.setAttribute("aria-current", "true");
|
||||
else card.removeAttribute("aria-current");
|
||||
@@ -627,6 +669,8 @@ function paintCard(card, job) {
|
||||
stateLine.children[0].textContent = state.glyph;
|
||||
stateLine.children[1].textContent = job.stage ? `${job.status} · ${job.stage}` : job.status;
|
||||
time.textContent = elapsed(job);
|
||||
mark.children[0].textContent = job.rebuilt_from ? "↻" : "";
|
||||
mark.children[1].textContent = job.rebuilt_from ? " rebuilt" : "";
|
||||
}
|
||||
|
||||
// patched in place rather than replaced wholesale: detaching a card would blur
|
||||
@@ -709,6 +753,7 @@ function schedulePoll(jobs) {
|
||||
async function refreshJobs() {
|
||||
const resp = await fetch("/api/jobs");
|
||||
const jobs = await resp.json();
|
||||
JOBS = jobs;
|
||||
if (selectedId === null && jobs.length) selectedId = jobs[0].id;
|
||||
renderJobs(jobs);
|
||||
renderNowBar(jobs);
|
||||
@@ -829,6 +874,90 @@ function renderOutputPath(job) {
|
||||
outputPathDiv.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
function setFormMode(inp) {
|
||||
formHeading.textContent = rebuildOf ? "REBUILD" : "NEW SONG";
|
||||
submitButton.textContent = rebuildOf ? "REBUILD SONG" : "CREATE SONG";
|
||||
cancelRebuild.hidden = !rebuildOf;
|
||||
for (const [field, key] of [["video", "video_filename"], ["cover", "cover_filename"]]) {
|
||||
const note = form.querySelector(`[data-keep="${field}"]`);
|
||||
const kept = rebuildOf && inp ? inp[key] : null;
|
||||
note.textContent = kept ? `keeping ${kept} — attach a file to replace it` : "";
|
||||
note.hidden = !kept;
|
||||
}
|
||||
}
|
||||
|
||||
function exitRebuild() {
|
||||
rebuildOf = null;
|
||||
form.reset();
|
||||
setFormMode(null);
|
||||
}
|
||||
|
||||
async function startRebuild(id) {
|
||||
const resp = await fetch(`/api/jobs/${id}/input`);
|
||||
if (!resp.ok) return;
|
||||
const inp = await resp.json();
|
||||
form.reset();
|
||||
for (const [name, value] of Object.entries(inp)) {
|
||||
// the payload is keyed by form field name; *_filename match nothing, by design
|
||||
const el = form.elements[name];
|
||||
if (el && value !== null) el.value = value;
|
||||
}
|
||||
rebuildOf = id;
|
||||
setFormMode(inp);
|
||||
formError.textContent = "";
|
||||
form.elements.title.focus();
|
||||
}
|
||||
|
||||
async function rerunJob(id) {
|
||||
formError.textContent = "";
|
||||
const resp = await fetch(`/api/jobs/${id}/rerun`, { 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();
|
||||
}
|
||||
|
||||
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));
|
||||
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"] : [];
|
||||
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;
|
||||
reviewActions.dataset.jobId = job.id;
|
||||
reviewActions.replaceChildren(...wanted.map(a => actionButton(a, job.id)));
|
||||
}
|
||||
|
||||
function renderLineage(job) {
|
||||
if (reviewLineage.dataset.jobId === job.id) return;
|
||||
reviewLineage.dataset.jobId = job.id;
|
||||
reviewLineage.replaceChildren();
|
||||
reviewLineage.hidden = !job.rebuilt_from;
|
||||
if (!job.rebuilt_from) return;
|
||||
const parent = JOBS.find(j => j.id === job.rebuilt_from);
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "lineage";
|
||||
link.textContent = parent ? `rebuilt from ${parent.title}`
|
||||
: "rebuilt from an earlier run";
|
||||
link.addEventListener("click", () => selectJob(job.rebuilt_from));
|
||||
reviewLineage.append(link);
|
||||
}
|
||||
|
||||
async function refreshDetail() {
|
||||
const id = selectedId;
|
||||
const resp = await fetch(`/api/jobs/${id}`);
|
||||
@@ -842,6 +971,8 @@ async function refreshDetail() {
|
||||
reviewError.textContent = job.error ?? "";
|
||||
renderWarnings(job);
|
||||
renderOutputPath(job);
|
||||
renderLineage(job);
|
||||
renderActions(job);
|
||||
detail.textContent = job.log.join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -498,6 +498,29 @@ def test_running_stage_pulses_unless_motion_is_reduced():
|
||||
assert _pulse_flaws(_index_html()) == []
|
||||
|
||||
|
||||
def _submit_body(html):
|
||||
start = html.index('form.addEventListener("submit"')
|
||||
return html[start:html.index("\n});\n", start)]
|
||||
|
||||
|
||||
def _rebuild_flaws(html):
|
||||
"""Ways rebuild mode could quietly stop working."""
|
||||
flaws, submit = [], _submit_body(html)
|
||||
if "rebuildOf ?" not in submit or "/rebuild" not in submit:
|
||||
flaws.append("the form always posts to /api/jobs, so edits create a new song")
|
||||
|
||||
actions = _fn_body(html, "renderActions")
|
||||
if not re.search(r"if \(.*dataset\.jobId === job\.id.*\) return;", actions):
|
||||
flaws.append("the action row is rebuilt on every poll, blurring a focused button")
|
||||
if '"failed"' not in actions or '"done"' not in actions:
|
||||
flaws.append("the action row does not key off the job status")
|
||||
|
||||
start = _fn_body(html, "startRebuild")
|
||||
if "/input" not in start or "form.elements[" not in start:
|
||||
flaws.append("rebuild mode does not prefill the form from the job's input")
|
||||
return flaws
|
||||
|
||||
|
||||
# each mutation is a plausible regression; the checker above must reject all of them
|
||||
MUTATIONS = [
|
||||
(_focus_flaws, "queue: card moved unconditionally",
|
||||
@@ -520,6 +543,12 @@ MUTATIONS = [
|
||||
".stage-pending .stage-dot { color: var(--aqua); animation: stage-pulse"),
|
||||
(_pulse_flaws, "pulse: reduced motion no longer wins",
|
||||
"animation: none !important", "animation: none"),
|
||||
(_rebuild_flaws, "rebuild: form posts to /api/jobs even in rebuild mode",
|
||||
'const url = rebuildOf ? `/api/jobs/${rebuildOf}/rebuild` : "/api/jobs";',
|
||||
'const url = "/api/jobs";'),
|
||||
(_rebuild_flaws, "rebuild: action row rebuilt on every poll",
|
||||
"if (reviewActions.dataset.jobId === job.id && shown.join() === wanted.join()) return;",
|
||||
""),
|
||||
]
|
||||
|
||||
|
||||
@@ -547,6 +576,32 @@ def test_narrow_stage_labels_stay_in_the_accessibility_tree():
|
||||
assert "display: none" not in hidden[1] and "visibility: hidden" not in hidden[1]
|
||||
|
||||
|
||||
def test_rebuild_mode_reuses_the_one_form():
|
||||
html = _index_html()
|
||||
assert html.count("<form") == 1
|
||||
assert _rebuild_flaws(html) == []
|
||||
|
||||
|
||||
def test_index_has_rebuild_hooks():
|
||||
html = _index_html()
|
||||
for hook in ("review-actions", "review-lineage", "cancel-rebuild",
|
||||
'data-keep="video"', 'data-keep="cover"'):
|
||||
assert hook in html, hook
|
||||
|
||||
|
||||
def test_rebuild_controls_are_not_offered_mid_run():
|
||||
"""Both endpoints answer 409 while a job is queued or running."""
|
||||
actions = _fn_body(_index_html(), "renderActions")
|
||||
assert '"queued"' not in actions and '"running"' not in actions
|
||||
|
||||
|
||||
def test_lineage_marker_survives_in_the_accessibility_tree():
|
||||
html = _index_html()
|
||||
body = _fn_body(html, "paintCard")
|
||||
assert "rebuilt_from" in body
|
||||
assert '"sr-only"' in _fn_body(html, "jobCard")
|
||||
|
||||
|
||||
def make_done_job(client, **overrides):
|
||||
data = {"title": "T", "artist": "A", "lyrics": "x", "youtube_url": "http://y"}
|
||||
data.update(overrides)
|
||||
|
||||
Reference in New Issue
Block a user