diff --git a/computes/templates/overview.html b/computes/templates/overview.html index 0837983f..ecadbce4 100644 --- a/computes/templates/overview.html +++ b/computes/templates/overview.html @@ -117,6 +117,7 @@ {% endblock %} {% block script %} + {% endblock %} diff --git a/instances/templates/instance.html b/instances/templates/instance.html index 03ed0baf..d63694e5 100644 --- a/instances/templates/instance.html +++ b/instances/templates/instance.html @@ -378,9 +378,19 @@ }); + diff --git a/static/js/poll.js b/static/js/poll.js new file mode 100644 index 00000000..91b12fb5 --- /dev/null +++ b/static/js/poll.js @@ -0,0 +1,50 @@ +// Repeats request() while the page is visible and passes each answer to +// onData. request() returns a jQuery promise (e.g. $.getJSON(url)); onData is +// called after the poller's own bookkeeping, so an error in it does not stop +// the polling. One request at a time: the next one starts +// interval ms after the previous one has finished, so a slow host does not +// pile up requests, each holding a web worker. A hidden page asks nothing; +// when it is shown again it asks at once. stop() pauses, start() resumes +// at once (for example while a tab of the page is shown). +function pollWhileVisible(interval, request, onData) { + var timer = null; + var running = false; + var stopped = false; + + function run() { + timer = null; + if (stopped || document.hidden) { + return; + } + running = true; + request() + .always(function () { + running = false; + if (!stopped && !document.hidden) { + timer = setTimeout(run, interval); + } + }) + .done(onData); + } + + function resume() { + if (!stopped && !document.hidden && !running && timer === null) { + run(); + } + } + + document.addEventListener("visibilitychange", resume); + timer = setTimeout(run, interval); + + return { + stop: function () { + stopped = true; + clearTimeout(timer); + timer = null; + }, + start: function () { + stopped = false; + resume(); + }, + }; +} diff --git a/webvirtcloud/js_tests/poll.test.js b/webvirtcloud/js_tests/poll.test.js new file mode 100644 index 00000000..54a90f7b --- /dev/null +++ b/webvirtcloud/js_tests/poll.test.js @@ -0,0 +1,95 @@ +// Behaviour of static/js/poll.js with a fake clock, document and jQuery promise. +// Run by webvirtcloud/test_poll_js.py: node poll.test.js static/js/poll.js +const fs = require("fs"); +const assert = require("assert"); + +let now = 0, timers = [], nextId = 1; +global.setTimeout = (fn, ms) => { const id = nextId++; timers.push({ id, at: now + ms, fn }); return id; }; +global.clearTimeout = (id) => { timers = timers.filter((t) => t.id !== id); }; +function advance(ms) { + const end = now + ms; + for (;;) { + timers.sort((a, b) => a.at - b.at); + const t = timers[0]; + if (!t || t.at > end) break; + timers.shift(); now = t.at; t.fn(); + } + now = end; +} +const listeners = {}; +global.document = { hidden: false, addEventListener: (ev, fn) => { listeners[ev] = fn; } }; +function setHidden(h) { document.hidden = h; listeners.visibilitychange(); } + +// requests: each returns a promise that the test settles. Like jQuery, it runs +// the callbacks in the order they were added, and an exception in one stops +// the ones after it. +let requests = []; +function request() { + const callbacks = []; + const r = { + started: now, + always(fn) { callbacks.push(fn); return r; }, + done(fn) { callbacks.push(fn); return r; }, + finish() { + try { callbacks.forEach((fn) => fn({ ok: true })); } catch (e) { r.error = e; } + }, + }; + requests.push(r); + return r; +} +let answers = []; +let failNext = false; +function onData(data) { + answers.push(data); + if (failNext) { failNext = false; throw new Error("a chart for an unknown device"); } +} +eval(fs.readFileSync(process.argv[2], "utf8")); + +const poll = pollWhileVisible(10000, request, onData); +advance(9999); assert.strictEqual(requests.length, 0, "first request after the interval"); +advance(1); assert.strictEqual(requests.length, 1); + +// a slow answer: no second request while the first is open +advance(45000); assert.strictEqual(requests.length, 1, "no pile-up while a request is open"); +requests[0].finish(); +advance(9999); assert.strictEqual(requests.length, 1); +advance(1); assert.strictEqual(requests.length, 2, "next one interval after the answer"); +requests[1].finish(); + +// hidden: nothing is asked +setHidden(true); +advance(60000); assert.strictEqual(requests.length, 2, "a hidden page asks nothing"); +// shown again: asks at once +setHidden(false); assert.strictEqual(requests.length, 3, "asks at once when shown"); +requests[2].finish(); +advance(10000); assert.strictEqual(requests.length, 4); + +// hidden while a request is open: no new one when it finishes, one on show +setHidden(true); requests[3].finish(); +advance(60000); assert.strictEqual(requests.length, 4); +setHidden(false); assert.strictEqual(requests.length, 5); + +// briefly hidden while waiting: the pending timer still fires once, no double request +requests[4].finish(); +setHidden(true); setHidden(false); +advance(10000); assert.strictEqual(requests.length, 6, "no extra request for a short hide"); + +// stop: nothing more, also not on show +poll.stop(); requests[5].finish(); +setHidden(true); setHidden(false); advance(60000); +assert.strictEqual(requests.length, 6, "stopped"); +// start after stop: asks at once, then every interval +poll.start(); assert.strictEqual(requests.length, 7, "start asks at once"); +requests[6].finish(); advance(10000); assert.strictEqual(requests.length, 8); +// start while running or waiting adds nothing +poll.start(); assert.strictEqual(requests.length, 8); +requests[7].finish(); poll.start(); advance(9999); assert.strictEqual(requests.length, 8, "start while waiting"); +// start while hidden asks nothing until shown +poll.stop(); setHidden(true); poll.start(); assert.strictEqual(requests.length, 8); +setHidden(false); assert.strictEqual(requests.length, 9); +// an error in onData does not stop the polling +assert.strictEqual(answers.length, 8, "each answer reaches onData"); +failNext = true; requests[8].finish(); +assert.ok(requests[8].error, "the error reaches the page"); +advance(10000); assert.strictEqual(requests.length, 10, "polling goes on after an error in onData"); +console.log("poll.js: all checks passed"); diff --git a/webvirtcloud/test_poll_js.py b/webvirtcloud/test_poll_js.py new file mode 100644 index 00000000..eb8553b9 --- /dev/null +++ b/webvirtcloud/test_poll_js.py @@ -0,0 +1,31 @@ +"""Pages poll the server through static/js/poll.js: one request at a time, +none while the page is hidden. Its behaviour is checked with Node.""" + +import shutil +import subprocess +import unittest +from pathlib import Path + +from django.conf import settings + +BASE = Path(settings.BASE_DIR) +POLLING_TEMPLATES = ["instances/templates/instance.html", "computes/templates/overview.html"] + + +class PollTestCase(unittest.TestCase): + @unittest.skipUnless(shutil.which("node"), "node is not installed") + def test_poll_js(self): + result = subprocess.run( + ["node", str(BASE / "webvirtcloud/js_tests/poll.test.js"), str(BASE / "static/js/poll.js")], + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_pages_poll_through_poll_js(self): + for template in POLLING_TEMPLATES: + with self.subTest(template=template): + source = (BASE / template).read_text() + self.assertNotIn("setInterval", source) + self.assertIn("pollWhileVisible(", source)