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)