mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-11 12:46:49 +00:00
refactor(instances): the VM page's scripts load from static files
The VM page's thirteen inline script blocks and its inline handlers move to static files: instance_page.js (tabs, OS details, clone names and MACs, Add disk dialog, boot order, host devices, migrate plan, console window, switches that send their form), instance_stats.js (charts and the Logs view) and instance_status.js (the reload when the VM's state changes). Their values and messages come as data attributes of the elements they belong to, page-wide ones on #vm-summary; a form marked data-please-wait shows the please-wait dialog. Three blocks that did nothing are dropped: popovers (the page has none), the video model preselection (the server selects it) and the delete form's poll stop (that form is on the destroy page). The HTML sink inventory now also reads the app's own scripts.
This commit is contained in:
parent
393aecb8f1
commit
561937b31a
18 changed files with 507 additions and 460 deletions
|
|
@ -219,7 +219,7 @@ class PageControlsTestCase(PageControls, TestCase):
|
|||
if user == owner:
|
||||
self.assertIn("The VM is paused. An administrator can resume it.", tab.text_content())
|
||||
if status == 5:
|
||||
self.assertEqual(tab.xpath(".//form/@onsubmit"), ["showPleaseWaitDialog()"])
|
||||
self.assertEqual(tab.xpath(".//form/@data-please-wait"), [""])
|
||||
|
||||
def test_a_running_paused_or_shut_off_vm_can_be_cloned(self):
|
||||
changer = self.user("vm_cloner", perms=("clone_instances",), owner={"is_change": True})
|
||||
|
|
@ -271,8 +271,8 @@ class TemplatePageControlsTestCase(PageControls, TestCase):
|
|||
doc = lxml_html.fromstring(self.last_page)
|
||||
self.assertEqual(doc.xpath("string(//section[@aria-labelledby='vm-name']//span[@class='badge text-bg-light border'])"),
|
||||
"Template")
|
||||
self.assertTrue(doc.xpath("//button[contains(@onclick, \"show_tab('#clone')\")]"))
|
||||
self.assertIn('location.hash || "#clone"', self.last_page)
|
||||
self.assertTrue(doc.xpath("//button[@id='clone-button']"))
|
||||
self.assertEqual(doc.xpath("string(//section[@id='vm-summary']/@data-opens-on)"), "#clone")
|
||||
for pane in ("power", "access", "graphics", "vncsettings", "hostdevs"):
|
||||
self.assertEqual(doc.xpath("//*[@id=$p]", p=pane), [], pane)
|
||||
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ class PendingEditsTestCase(TestCase):
|
|||
self.assertEqual(form.xpath(".//input[@type='text']/@name"),
|
||||
["name", "clone-net-mac-0", "clone-net-mac-1", "disk-vda", "disk-vdb", "clone-title"])
|
||||
# the DHCP entry of the clone's name holds one address: only the first NIC guesses it
|
||||
self.assertEqual(len(form.xpath(".//button[contains(@onclick, 'guess_mac_address')]")), 1)
|
||||
self.assertEqual(len(form.xpath(".//button[@id='guess-clone-mac']")), 1)
|
||||
# a running or paused VM is copied by a backup, which does not preallocate
|
||||
self.assertEqual(form.xpath(".//input[@type='checkbox']/@name"), [*metadata, "clone-start"])
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from vrtManager.instance import USAGE_MAX_WINDOW
|
|||
|
||||
# the page script runs after the shared chart helpers
|
||||
CHARTS_JS = settings.BASE_DIR / "static" / "js" / "charts.js"
|
||||
STATS_JS = settings.BASE_DIR / "static" / "js" / "instance_stats.js"
|
||||
|
||||
SAMPLE = {"key": [7, 4, ["vda"], [["52:54:00:00:00:01", "vnet0"]]], "counters": [1, [[2, 3]], [[4, 5]], 100.0]}
|
||||
|
||||
|
|
@ -144,7 +145,15 @@ class StatsScriptTestCase(TestDriverVM, TestCase):
|
|||
return response.content.decode()
|
||||
|
||||
def test_the_next_poll_sends_the_previous_sample(self):
|
||||
script = next(s for s in html.fromstring(self.page()).xpath("//script[not(@src)]/text()") if "statsSample" in s)
|
||||
doc = html.fromstring(self.page())
|
||||
self.assertTrue(doc.xpath("//script[@src=$s]", s=settings.STATIC_URL + "js/instance_stats.js"))
|
||||
(pane,) = doc.xpath("//div[@id='graphics']")
|
||||
dataset = {"url": pane.get("data-url"), "averageOver": pane.get("data-average-over"),
|
||||
"read": pane.get("data-read"), "write": pane.get("data-write"),
|
||||
"in": pane.get("data-in"), "out": pane.get("data-out")}
|
||||
canvases = [{"dataset": {"disk": c.get("data-disk")}} for c in doc.xpath("//canvas[@data-disk]")]
|
||||
canvases += [{"dataset": {"mac": c.get("data-mac")}} for c in doc.xpath("//canvas[@data-mac]")]
|
||||
script = STATS_JS.read_text()
|
||||
js = """
|
||||
const vm = require("vm");
|
||||
const charts = [], requests = [], handlers = {};
|
||||
|
|
@ -155,9 +164,18 @@ class StatsScriptTestCase(TestDriverVM, TestCase):
|
|||
requests.push(params);
|
||||
return {fail: (callback) => { if (fail) callback(); return {}; }};
|
||||
};
|
||||
// the Stats pane with its data and charts; no Logs view; any other element
|
||||
function getElementById(id) {
|
||||
if (id === "graphics") {
|
||||
return {dataset: DATASET,
|
||||
querySelectorAll: (selector) => CANVASES.filter((c) => selector.includes(Object.keys(c.dataset)[0]))};
|
||||
}
|
||||
if (id === "logs-tab") return null;
|
||||
return {};
|
||||
}
|
||||
const page = {
|
||||
$,
|
||||
document: {body: {}, getElementById: () => ({}),
|
||||
document: {body: {}, getElementById,
|
||||
querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})},
|
||||
// Chart.js adds the lists a config leaves out
|
||||
getComputedStyle: () => ({getPropertyValue: () => ""}),
|
||||
|
|
@ -194,7 +212,8 @@ class StatsScriptTestCase(TestDriverVM, TestCase):
|
|||
console.log(JSON.stringify({requests, label, every: [poll.interval, poll.first], cpu: charts[0].data.datasets[0].data,
|
||||
disk: charts[2].data.datasets[1].data, net: charts[3].data.datasets[0].data,
|
||||
mem: charts[1].data.datasets[0].data, memMax: charts[1].options.scales.y.max}));
|
||||
""".replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text()))
|
||||
""".replace("SCRIPT", json.dumps(script)).replace("CHARTS", json.dumps(CHARTS_JS.read_text())).replace(
|
||||
"DATASET", json.dumps(dataset)).replace("CANVASES", json.dumps(canvases))
|
||||
out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 0, out.stderr)
|
||||
result = json.loads(out.stdout)
|
||||
|
|
|
|||
|
|
@ -110,13 +110,13 @@ class TemplateHtmlConcatenationTestCase(TestCase):
|
|||
return (ROOT / relative).read_text()
|
||||
|
||||
def test_logs_table_does_not_concatenate_log_fields_into_html(self):
|
||||
src = self._read("instances/templates/instances/stats_tab.html")
|
||||
src = self._read("static/js/instance_stats.js")
|
||||
for field in ("date", "user", "message"):
|
||||
self.assertNotIn(f"+row['{field}']+", src)
|
||||
self.assertNotIn('$("#logs_table > tbody").html(', src)
|
||||
self.assertNotIn(".html(", src)
|
||||
|
||||
def test_volume_dropdown_does_not_concatenate_volume_names_into_html(self):
|
||||
src = self._read("instances/templates/instance.html")
|
||||
src = self._read("static/js/instance_page.js")
|
||||
self.assertNotIn("'<option value=' + item", src)
|
||||
self.assertNotIn('pool + "<span', src)
|
||||
|
||||
|
|
@ -129,13 +129,16 @@ class TemplateHtmlConcatenationTestCase(TestCase):
|
|||
self.assertNotIn("'<option value=' + item", src)
|
||||
|
||||
|
||||
# Every template line that builds HTML from JavaScript must be listed here with
|
||||
# a reason; a new one fails the test and has to be reviewed (use .text() /
|
||||
# .val() / DOM APIs for server data instead).
|
||||
# Every line of a template or of the app's own scripts that builds HTML from
|
||||
# JavaScript must be listed here with a reason; a new one fails the test and
|
||||
# has to be reviewed (use .text() / .val() / DOM APIs for server data instead).
|
||||
HTML_SINK = re.compile(r"""\.html\(|innerHTML\s*=|append\(\s*['"]<""")
|
||||
# the libraries kept as published, not the app's scripts
|
||||
VENDORED_SCRIPTS = {"jquery.js", "js.cookie.js", "socket.io.js"}
|
||||
ALLOWED_HTML_SINKS = {
|
||||
("instances/templates/instance.html", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
|
||||
("instances/templates/instance.html", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
|
||||
("static/js/instance_page.js", "hostDevices.innerHTML = html;"): "the panel's own fragment, host_devices_form.html, escaped by Django",
|
||||
("static/js/instance_page.js", "migratePlan.innerHTML = html;"): "the panel's own fragment, migrate_plan.html, escaped by Django",
|
||||
("operations/static/operations/job_panel.js", "element.innerHTML = html;"): "built by the panel, every job value through esc()",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -153,5 +156,13 @@ class TemplateHtmlSinkInventoryTestCase(TestCase):
|
|||
for line in path.read_text().splitlines():
|
||||
if HTML_SINK.search(line):
|
||||
found.add((rel, line.strip()))
|
||||
scripts = [*(ROOT / "static/js").glob("*.js"), *ROOT.glob("*/static/**/*.js")]
|
||||
for path in scripts:
|
||||
if path.name in VENDORED_SCRIPTS or path.name.endswith(".min.js"):
|
||||
continue
|
||||
rel = str(path.relative_to(ROOT))
|
||||
for line in path.read_text().splitlines():
|
||||
if HTML_SINK.search(line):
|
||||
found.add((rel, line.strip()))
|
||||
self.assertEqual(sorted(found - set(ALLOWED_HTML_SINKS)), [], "unreviewed HTML sinks")
|
||||
self.assertEqual(sorted(set(ALLOWED_HTML_SINKS) - found), [], "stale allowlist entries")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue