1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(operations): a job that succeeds reloads its page

Since VM creation, clones, migrations and uploads run as jobs, the page
came back before they ended and the job panel then showed the outcome
with a Refresh button: a new VM, for one, showed only after a click. A
job of the page that succeeds without a warning now reloads the page,
which shows what it changed. The outcome box stays, with Refresh, for a
job that failed or warned, and when a reload would lose something: a
dialog open, a field in use, an XML edit not saved, or another job still
running, shown or awaited. A remembered job is taken when the page
renders, so the reload does not see it again.
This commit is contained in:
catborise 2026-11-01 19:17:35 +03:00
parent cf991adad5
commit f88deb22c1
2 changed files with 81 additions and 3 deletions

View file

@ -1,6 +1,7 @@
// The job panel of a resource page (operations framework). It polls the
// resource's jobs, shows progress and outcomes, and keeps the page's own
// status poll from reloading (backgroundJobRunning) while it shows anything.
// A job that succeeds without a warning reloads the page instead.
// The Bootstrap colour of each job state, for badges and alerts; the jobs
// pages use the same map (operations/templatetags/operations.py)
@ -113,6 +114,20 @@ function jobPanel(element, urls, csrfToken, initialIds) {
holding = busy;
}
// a job that did what it was asked, with nothing more to read: the page shows its effect
function quiet(job) {
var result = job.result || {};
return job.state === "succeeded" && !result.warning && !(result.left_behind || []).length;
}
// a dialog open, a field in use or an XML edit not saved: its outcome waits in the panel, with Refresh
function interacting() {
var active = document.activeElement;
return !!document.querySelector(".modal.show")
|| !!(active && active.matches && active.matches("input, select, textarea"))
|| !!(window.xmlEditUnsaved && window.xmlEditUnsaved());
}
function fetchOutcome(id) {
if (inflight[id]) { return; }
inflight[id] = true;
@ -123,6 +138,12 @@ function jobPanel(element, urls, csrfToken, initialIds) {
// ends; the next poll asks for it again
if (["queued", "running", "cancelling"].indexOf(job.state) >= 0) { return; }
delete pending[id];
// only when no other job runs, is shown or awaited: a reload would lose its outcome
var others = Object.keys(outcomes).length + Object.keys(pending).length + Object.keys(shown).length;
if (quiet(job) && !interacting() && !others) {
window.location.reload(); // the job is not remembered again: no loop
return;
}
outcomes[id] = job;
render();
})

View file

@ -18,7 +18,7 @@ function advance(ms) {
}
const realNow = Date.now;
Date.now = () => 1700000000000 + now;
global.document = { hidden: false, addEventListener() {} };
global.document = { hidden: false, addEventListener() {}, activeElement: null, querySelector: () => null };
global.window = { backgroundJobRunning: false, location: { reload() { window.reloaded = true; } } };
// requests settle only when the test says so; callbacks run in registration order
@ -259,8 +259,65 @@ assert.strictEqual(window.backgroundJobRunning, true, "still followed");
advance(3000);
answer("/r", empty);
assert.strictEqual(open.filter((r) => !r.settled && r.url === "/j/u").length, 1, "asked again once per poll");
window.reloaded = false;
answer("/j/u", { ...running, id: "u", label: "Upload", state: "succeeded" });
assert.ok(el11.innerHTML.includes("Upload") && el11.innerHTML.includes('<span class="badge bg-success">succeeded</span>'),
"the outcome shows once the job ends");
assert.ok(window.reloaded, "a job that succeeds without a warning reloads the page: it shows the job's effect");
assert.ok(!el11.innerHTML.includes('<span class="badge bg-success">succeeded</span>'), "not shown as well");
// a dialog open or a field in use: the outcome waits in the panel, with Refresh
for (const [label, doc] of [["dialog", { querySelector: (s) => s === ".modal.show" ? {} : null }],
["field", { activeElement: { matches: (s) => s === "input, select, textarea" } }]]) {
open = []; timers = []; window.reloaded = false;
Object.assign(document, { activeElement: null, querySelector: () => null }, doc);
const elm = { innerHTML: "", addEventListener() {} };
jobPanel(elm, urls, "tok", ["m"]);
advance(0);
answer("/r", empty);
answer("/j/m", { ...running, id: "m", label: "Clone", state: "succeeded" });
assert.ok(!window.reloaded && elm.innerHTML.includes('<span class="badge bg-success">succeeded</span>')
&& elm.innerHTML.includes("data-refresh"), "with a " + label + ": the outcome with Refresh, no reload");
}
Object.assign(document, { activeElement: null, querySelector: () => null });
// an XML edit not saved: no reload, as for the page's status poll
open = []; timers = []; window.reloaded = false;
window.xmlEditUnsaved = () => true;
const elx = { innerHTML: "", addEventListener() {} };
jobPanel(elx, urls, "tok", ["x"]);
advance(0);
answer("/r", empty);
answer("/j/x", { ...running, id: "x", state: "succeeded" });
assert.ok(!window.reloaded && elx.innerHTML.includes("data-refresh"), "an unsaved XML edit keeps the page");
delete window.xmlEditUnsaved;
// another job's outcome shown or still awaited: a quiet success does not wipe it
open = []; timers = []; window.reloaded = false;
const elo = { innerHTML: "", addEventListener() {} };
jobPanel(elo, urls, "tok", ["f", "s", "w"]);
advance(0);
answer("/r", empty);
answer("/j/f", { ...running, id: "f", label: "Migrate", state: "failed", error: "no route" });
answer("/j/s", { ...running, id: "s", label: "Clone", state: "succeeded" });
assert.ok(!window.reloaded && elo.innerHTML.includes("no route") && elo.innerHTML.includes("Clone"),
"the failure stays, the success shows beside it");
answer("/j/w", { ...running, id: "w", label: "Snapshot", state: "succeeded" });
assert.ok(!window.reloaded, "still not while an outcome is shown");
open = []; timers = []; window.reloaded = false;
const elp = { innerHTML: "", addEventListener() {} };
jobPanel(elp, urls, "tok", ["s2", "f2"]);
advance(0);
answer("/r", empty);
answer("/j/s2", { ...running, id: "s2", state: "succeeded" });
assert.ok(!window.reloaded, "not while another outcome is awaited");
answer("/j/f2", { ...running, id: "f2", state: "failed", error: "refused" });
assert.ok(elp.innerHTML.includes("refused"), "which then shows");
// another job still running: its outcome would be lost to a reload
open = []; timers = []; window.reloaded = false;
const elr = { innerHTML: "", addEventListener() {} };
jobPanel(elr, urls, "tok", ["s3"]);
advance(0);
answer("/r", { ...empty, jobs: [{ ...running, id: "r3" }] });
answer("/j/s3", { ...running, id: "s3", state: "succeeded" });
assert.ok(!window.reloaded, "not while another job runs");
Date.now = realNow;
console.log("ok");