1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +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:
catborise 2026-11-02 09:04:29 +03:00
parent 393aecb8f1
commit 561937b31a
18 changed files with 507 additions and 460 deletions

View file

@ -46,7 +46,7 @@
</div>
<div class="col-12">
<label class="form-label" for="add-net-mac">{% trans "MAC" %}</label>
<input type="text" class="form-control font-monospace" id="add-net-mac" name="add-net-mac" aria-describedby="add-net-mac-help">
<input type="text" class="form-control font-monospace" id="add-net-mac" name="add-net-mac" aria-describedby="add-net-mac-help"{% if request.user.is_superuser %} data-new-mac{% endif %}>
<div class="form-text" id="add-net-mac-help">{% trans "A random address is filled in; empty: libvirt chooses one." %}</div>
</div>
</div>

View file

@ -69,7 +69,7 @@
<div class="col-sm-6">
<label class="form-label" for="selected_storage">{% trans "Storage pool" %}</label>
<select id="selected_storage" name="selected_storage" class="form-select" required
onchange="get_volumes({{ instance.compute.id }}, this.value)">
data-volumes-url="{% url 'volumes' instance.compute.id 'POOL' %}">
<option value="" selected disabled>{% trans "Choose a pool" %}</option>
{% for storage in storages_host %}
<option value="{{ storage }}">{{ storage }}</option>
@ -112,12 +112,4 @@
</div>
</div>
<script src="{% static 'js/choice_dialog.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('addvol-form');
choiceDialog(document.getElementById('addvol'), 'disk_source', function (source) {
form.action = form.dataset[source];
});
});
</script>
{% endif %}

View file

@ -12,7 +12,11 @@
{% block content %}
{% include 'pleasewaitdialog.html' %}
{# the VM at a glance in one card: who and where it is, what it has, and its sections as the card's footer #}
<section class="card mb-3" aria-labelledby="vm-name">
<section class="card mb-3" aria-labelledby="vm-name" id="vm-summary" data-status="{{ instance.status }}"
data-status-url="{% url 'instances:status' instance.id %}" data-osinfo-url="{% url 'instances:osinfo' instance.id %}"
data-opens-on="{% if instance.is_template and can_clone %}#clone{% endif %}"
data-random-mac-url="{% url 'instances:random_mac_address' %}"
{% if can_open_console and vdi_reachable %}data-vdi-url="{% url 'vdi_url' compute.id instance.name %}"{% endif %}>
<div class="card-body p-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
@ -27,10 +31,10 @@
</div>
<div class="d-flex gap-2">
{% if instance.is_template and can_clone %}
<button type="button" class="btn btn-sm btn-primary" onclick="show_tab('#settings'); show_tab('#clone');">{% bs_icon 'copy' %} {% trans "Clone" %}</button>
<button type="button" class="btn btn-sm btn-primary" id="clone-button">{% bs_icon 'copy' %} {% trans "Clone" %}</button>
{% endif %}
{% if instance.guest_agent_ready %}
<button type="button" class="btn btn-sm btn-secondary" onclick="get_osinfo();">{% bs_icon 'info-circle' %} {% trans "OS details" %}</button>
<button type="button" class="btn btn-sm btn-secondary" id="os-details-button">{% bs_icon 'info-circle' %} {% trans "OS details" %}</button>
{% endif %}
<a class="btn btn-sm btn-secondary" href="{% url 'instances:instance' instance.id %}">{% bs_icon 'arrow-clockwise' %} {% trans "Refresh" %}</a>
</div>
@ -123,375 +127,13 @@
{% endblock %}
{% block script %}
<script src="{% static 'js/ace/ace.js' %}" type="text/javascript" charset="utf-8"></script>
<script>
// the volumes of the pool chosen in the Add disk dialog
function get_volumes(compute_id, pool) {
$.getJSON("/computes/" + compute_id + "/storage/" + encodeURIComponent(pool) + "/volumes/", function (data) {
var vols = $("#vols").empty();
$.each(data['vols'], function (i, item) {
vols.append($("<option>").val(item).text(item));
});
});
}
</script>
<script src="{% static 'js/xml_editor.js' %}"></script>
<script>
function random_mac(net) {
$.getJSON("{% url 'instances:random_mac_address' %}", function (data) {
$('input[name="' + net + '"]').val(data['mac']);
});
}
</script>
<script>
function guess_mac_address(src_elem, net) {
new_vname = $(src_elem).val();
guess_mac_address_url = "{% url 'instances:guess_mac_address' 1 %}".replace(1, new_vname);
$.getJSON(guess_mac_address_url, function(data) {
$('input[name="clone-net-mac-'+net+'"]').val(data['mac']);
});
}
</script>
<script>
function guess_clone_name() {
$.getJSON("{% url 'instances:guess_clone_name' %}", function(data) {
// a free name of the DHCP file, if there is one
if (!data.name) {
$('#clone-name-help').text("{{ _("The DHCP file has no free name to suggest.")|escapejs }}");
return;
}
$('#clone-name-help').text('');
guessed_name = data['name'].split(".")[0];
$('#clone_name').val(guessed_name);
update_clone_disk_name(guessed_name);
guess_mac_address('#clone_name', 0);
});
}
</script>
<script>
function update_clone_disk_name(new_vname) {
vname = '{{ instance.name|escapejs }}';
{% for disk in instance.config_disks %}
disk_name = '{{ disk.image|escapejs }}';
disk_dot = disk_name.split('.');
disk_dot_suffix = disk_dot[disk_dot.length-1];
if (disk_name.startsWith(vname)) {
image = disk_name.replace(vname, new_vname);
} else if (disk_name.lastIndexOf('.') > -1 && disk_dot_suffix.length <= 7) {
disk_dot.pop();
disk_name_only = disk_dot.join('-');
image = new_vname + "-" + disk_name_only + "." + disk_dot_suffix
} else if (new_vname != disk_name) {
image = new_vname
} else {
image = new_vname + '-clone';
}
$('#disk_name-{{ disk.dev|escapejs }}').val(image);
{% endfor %}
}
</script>
<script>
$('#clone_name').on('input', function () {
update_clone_disk_name($(this).val());
});
$(document).ready(function () {
// get video model or fall back to default
let video_model = "{{ instance.config_video_model }}";
if (video_model != '') {
$("#video_model_select option[value='" + video_model + "']").prop('selected', true);
}
});
{% if can_open_console and vdi_reachable %}
$(document).ready(function () {
// set vdi url
$.get("{% url 'vdi_url' compute.id instance.name %}", function(data) {
$("#vdi_url_input").attr("value", data);
$("#vdi_url").attr("href", data);
});
});
{% endif %}
{% if request.user.is_superuser %}
$(document).ready(function () {
// every NIC of the clone a new address: one the VM has would clash with it
$('input[name^="clone-net-mac-"]').each(function () { random_mac(this.name); });
random_mac('add-net-mac');
update_clone_disk_name($('#clone_name').val());
});
{% endif %}
</script>
<script>
$(document).ready(function(){
$('[data-bs-toggle="popover"]').popover({
placement : 'top'
});
});
</script>
<script>
// the boot order: the checked devices, top to bottom, as "disk:vda,cdrom:sda"
function set_orderlist() {
var order = $('#b_order input:checked').map(function () { return this.value; }).get();
$('#bootorder').val(order.join(','));
}
$(function () {
set_orderlist();
$('#b_order').on('change', 'input', set_orderlist);
$('#boot_order_up').on('click', function () {
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() - 1;
if (newPos > -1) {
$('#b_order label').eq(newPos).before(label);
}
});
set_orderlist();
});
$('#boot_order_down').on('click', function () {
var countOptions = $('#b_order label').length;
var countSelected = $('#b_order label input:checked').length;
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() + countSelected;
if (newPos < countOptions) {
$('#b_order label').eq(newPos).after(label);
}
});
set_orderlist();
});
});
</script>
<script src="{% static 'js/chart.umd.min.js' %}"></script>
<script src="{% static 'js/charts.js' %}"></script>
<script src="{% static 'js/poll.js' %}"></script>
{% if not instance.is_template %}
<script>
{% trans "average over" as average_over %}
{% trans "In" as rx_label %}{% trans "Out" as tx_label %}{% trans "Read" as rd_label %}{% trans "Write" as wr_label %}
var stats_tab = document.querySelector('#graphics-tab');
var stats_poll = null;
// the seconds each CPU point averages
var cpuWindows = [];
// the usage sample of the previous answer: the next one averages since then
var statsSample = null;
// The charts are polled while the Stats tab is shown; they are made once.
// a reading for the line above a chart: one decimal, a dash where it is missing
function reading(value) {
return value == null ? "–" : String(Math.round(value * 10) / 10);
}
stats_tab.addEventListener('hidden.bs.tab', function (event) {
stats_poll.stop();
});
stats_tab.addEventListener('shown.bs.tab', function (event) {
if (stats_poll !== null) {
stats_poll.start();
return;
}
var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], {
max: 100,
tooltip: function (context, text) {
var span = cpuWindows[context.dataIndex];
return span ? text + ' ({{ average_over|escapejs }} ' + span + ' s)' : text;
},
});
var memChart = lineChart(document.getElementById("memChart"), "MiB", ["Usage (RSS)"]);
var diskChart = {};
{% for disk in instance.disks %}
diskChart['{{ disk.dev|escapejs }}'] = lineChart(document.getElementById("blk{{ disk.dev|escapejs }}Chart"), "MB/s", ["Read", "Write"]);
{% endfor %}
// two NICs with one MAC (a misconfiguration) share the first one's chart
var netChart = {};
{% for net in instance.networks %}
netChart['{{ net.mac|escapejs }}'] = netChart['{{ net.mac|escapejs }}'] || lineChart(document.getElementById("net{{ net.mac|escapejs }}Chart"), "Mbit/s", ["Inbound", "Outbound"]);
{% endfor %}
// the first point at once, then every 5 s like the host graph
stats_poll = pollWhileVisible(5000, function () {
return $.getJSON("{% url 'instances:stats' instance.id %}", statsSample ? {sample: statsSample} : {})
.fail(function () {
statsSample = null;
});
}, function graph_usage(data) {
statsSample = data.sample || null;
cpuWindows.push(data.window);
if (cpuWindows.length > CHART_POINTS) {
cpuWindows.shift();
}
addPoint(cpuChart, data.timeline, [data.cpudata]);
document.getElementById("cpuNow").textContent = reading(data.cpudata) + " %";
// a VM whose memory could not be read gives total 0: a gap, same axis;
// the RSS may pass the balloon size: then the axis grows to it
if (data.memdata.total) {
var used = Math.round(data.memdata.used / 1024), total = Math.round(data.memdata.total / 1024);
memChart.options.scales.y.max = Math.max(total, used);
addPoint(memChart, data.timeline, [used]);
document.getElementById("memNow").textContent = used + " / " + total + " MiB";
} else {
addPoint(memChart, data.timeline, [null]);
document.getElementById("memNow").textContent = "";
}
// a disk or NIC added after the page loaded has no chart until a reload,
// and one removed keeps its last points
function push(chart, id, labels, unit, values) {
if (chart) {
addPoint(chart, data.timeline, values);
document.getElementById(id).textContent =
labels[0] + " " + reading(values[0]) + " · " + labels[1] + " " + reading(values[1]) + " " + unit;
}
}
data.blkdata.forEach(function (disk) {
push(diskChart[disk.dev], "blk" + disk.dev + "Now", ["{{ rd_label|escapejs }}", "{{ wr_label|escapejs }}"], "MB/s", disk.data);
});
data.netdata.forEach(function (nic) {
push(netChart[nic.mac], "net" + nic.mac + "Now", ["{{ rx_label|escapejs }}", "{{ tx_label|escapejs }}"], "Mbit/s", nic.data);
});
}, 0);
});
</script>
{% endif %}
<script>
backgroundJobRunning = false;
// not "status": that is window.status, which the browser may not keep
var vm_status = {{ instance.status|lower }};
var status_poll = pollWhileVisible(5000, function () {
return $.getJSON("{% url 'instances:status' instance.id %}");
}, function get_status(data) {
// an XML edit not saved yet stays: the reload waits until it is saved or undone
if (data['status'] != vm_status && !backgroundJobRunning && !(window.xmlEditUnsaved && window.xmlEditUnsaved())) {
window.location.reload()
}
});
// Stop getting status info before delete instance
$('#delete_form').submit(function(){
status_poll.stop();
return true;
});
</script>
{% if not instance.is_template %}<script src="{% static 'js/instance_stats.js' %}"></script>{% endif %}
<script src="{% static 'js/instance_status.js' %}"></script>
{% job_panel_script job_resource %}
<script src="{% static 'js/instance_tabs.js' %}"></script>
<script>
// Open the tab whose control targets "#<pane id>"
function show_tab(target) {
var controls = document.querySelectorAll('[data-bs-target], a[data-bs-toggle][href]');
for (var i = 0; i < controls.length; i++) {
var c = controls[i];
if ((c.getAttribute('data-bs-target') || c.getAttribute('href')) === target) {
bootstrap.Tab.getOrCreateInstance(c).show();
return;
}
}
}
// after the whole page is parsed: load_osinfo is defined further down
document.addEventListener('DOMContentLoaded', function () {
// a template opens on its clone form
restoreInstanceTab(document, location.hash || "{% if instance.is_template and can_clone %}#clone{% endif %}", show_tab, load_osinfo);
// none open (a template has no Power tab): the first section the page has
if (!document.querySelector("#navbtn .nav-link.active")) {
var first = document.querySelector("#navbtn .nav-link");
if (first) { show_tab(first.getAttribute("data-bs-target")); }
}
});
</script>
<script>
// the Host devices tab's add forms: loaded once, when the tab first opens
var hostDevices = document.getElementById("host-devices");
if (hostDevices) {
document.querySelector('[data-bs-target="#hostdevs"]').addEventListener("shown.bs.tab", function () {
if (hostDevices.dataset.loaded) return;
hostDevices.dataset.loaded = "true";
fetch(hostDevices.dataset.url).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
hostDevices.innerHTML = html;
}).catch(function () {
hostDevices.textContent = "{{ _("The host's devices could not be read.")|escapejs }}";
});
});
}
// the Migrate tab's disks: loaded for the chosen destination when the tab opens or the choice changes
// the form is not sent until the disks of the chosen destination are shown
var migratePlan = document.getElementById("migrate-plan");
var migrateCompute = document.getElementById("migrate_compute");
var migrateLoading = null;
function loadMigratePlan() {
if (!migrateCompute.value) return;
if (migrateLoading) migrateLoading.abort();
var loading = migrateLoading = new AbortController();
var submit = migratePlan.closest("form").querySelector('button[name="migrate"]');
submit.disabled = true;
migratePlan.textContent = "{{ _("Checking the disks…")|escapejs }}";
fetch(migratePlan.dataset.url + "?compute_id=" + encodeURIComponent(migrateCompute.value),
{signal: loading.signal}).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
migratePlan.innerHTML = html;
// a destination that did not answer, or a disk that cannot go there
submit.disabled = migratePlan.querySelector("[data-refused]") !== null;
}).catch(function () {
if (loading.signal.aborted) return; // another destination was chosen
migratePlan.textContent = "{{ _("The disks could not be checked.")|escapejs }}";
submit.disabled = false; // the migration checks them again
});
}
// the destinations that cannot be chosen (unreachable, or this host under
// another record) are marked first; then the disks of the first one left
function loadMigrateTargets() {
var submit = migratePlan.closest("form").querySelector('button[name="migrate"]');
submit.disabled = true;
migrateCompute.disabled = true; // no disks are checked for a host before the hosts are
migratePlan.textContent = "{{ _("Checking the hosts…")|escapejs }}";
fetch(migratePlan.dataset.targetsUrl).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.json();
}).then(function (refused) {
migrateCompute.disabled = false;
Array.prototype.forEach.call(migrateCompute.options, function (option) {
if (refused[option.value]) {
option.disabled = true;
option.textContent = refused[option.value]; // it names the host
}
});
var first = Array.prototype.find.call(migrateCompute.options, function (option) { return !option.disabled; });
if (!first) {
migratePlan.textContent = "{{ _("No host can take the VM now.")|escapejs }}";
return;
}
if (migrateCompute.selectedOptions[0].disabled) migrateCompute.value = first.value;
loadMigratePlan();
}).catch(function () {
migratePlan.textContent = "{{ _("The hosts could not be checked.")|escapejs }}";
});
}
if (migratePlan && migrateCompute) {
document.querySelector('[data-bs-target="#migrate"]').addEventListener("shown.bs.tab", function () {
if (!migratePlan.dataset.loaded) {
migratePlan.dataset.loaded = "true";
loadMigrateTargets();
}
});
migrateCompute.addEventListener("change", loadMigratePlan);
}
function get_osinfo() {
show_tab('#settings');
show_tab('#osinfo');
load_osinfo();
}
function load_osinfo() {
$.getJSON("{% url 'instances:osinfo' instance.id %}", function (data) {
$('#osinfo-error').addClass('d-none');
$('#oshostname').text(data['host-name'] || '');
$('#osname').text(data.id || '');
$('#osprettyname').text(data['pretty-name'] || '');
$('#oskernelrelease').text(data['kernel-release'] || '');
$('#oskernelversion').text(data['kernel-version'] || '');
$('#osversion').text(data.version || '');
$('#ostimezone').text(data.zone ? data.zone + " / " + data.offset : '');
}).fail(function (xhr) {
var error = xhr.responseJSON && xhr.responseJSON.error;
$('#osinfo-error').text(error || "{% trans 'The guest agent did not answer' %}").removeClass('d-none');
});
}
</script>
<script src="{% static 'js/instance_page.js' %}"></script>
{% endblock %}

View file

@ -12,8 +12,7 @@
<div class="row g-2 align-items-center">
<div class="col-12 col-md-auto">
<button type="button" class="btn btn-primary w-100" id="console_button"
data-url="{% url 'console' compute.id instance.uuid %}" data-window="console-{{ instance.uuid }}"
onclick="window.open(this.dataset.url, this.dataset.window, 'width=850,height=650')">{% trans "Console" %}</button>
data-url="{% url 'console' compute.id instance.uuid %}" data-window="console-{{ instance.uuid }}">{% trans "Console" %}</button>
</div>
{% if vdi_reachable %}
<div class="col-12 col-md">

View file

@ -1,7 +1,7 @@
{% load i18n %}
<div class="tab-pane" id="osinfo" role="tabpanel">
<h3 class="mb-3">{% trans "Guest Info" %}</h3>
<div id="osinfo-error" class="alert alert-warning d-none" role="alert"></div>
<div id="osinfo-error" class="alert alert-warning d-none" role="alert" data-silent="{% trans 'The guest agent did not answer' %}"></div>
<dl class="mx-3 row">
<dt class="col-3">{% trans "Hostname" %}</dt>
<dd class="col-9" id="oshostname"></dd>

View file

@ -12,7 +12,7 @@
{% if "poweroff" in power_actions or "suspend" in power_actions or "resume" in power_actions or "poweron" in power_actions or "force_off" in power_actions or "powercycle" in power_actions %}
<div class="row g-3">
{% if "poweroff" in power_actions or "suspend" in power_actions or "resume" in power_actions or "poweron" in power_actions %}
<form class="col-12 col-md-6" method="post"{% if "poweron" in power_actions %} action="{% url 'instances:poweron' instance.id %}" onsubmit="showPleaseWaitDialog()"{% endif %}>{% csrf_token %}
<form class="col-12 col-md-6" method="post"{% if "poweron" in power_actions %} action="{% url 'instances:poweron' instance.id %}" data-please-wait{% endif %}>{% csrf_token %}
<div class="btn-group mb-2" role="group" aria-label="{% trans "Power actions that keep the guest's data" %}">
{% if "poweron" in power_actions %}<button type="submit" class="btn btn-primary" name="poweron">{% trans "Power On" %}</button>{% endif %}
{% if "poweroff" in power_actions %}<button type="submit" class="btn btn-primary" name="poweroff" formaction="{% url 'instances:poweroff' instance.id %}">{% trans "Power Off" %}</button>{% endif %}

View file

@ -9,7 +9,7 @@
<ul class="nav nav-underline nav-fill small" role="tablist" aria-label="Instance settings">
{% if instance.guest_agent_ready %}
<li class="nav-item" role="presentation">
<button class="px-1 nav-link" data-bs-toggle="tab" id="osinfo-tab" data-bs-target="#osinfo" type="button" role="tab" aria-controls="osinfo" aria-selected="false" onclick="get_osinfo();">
<button class="px-1 nav-link" data-bs-toggle="tab" id="osinfo-tab" data-bs-target="#osinfo" type="button" role="tab" aria-controls="osinfo" aria-selected="false">
<span>{% bs_icon 'info-circle' %} </span>
</button>
</li>
@ -98,7 +98,7 @@
<div class="list-group-item py-3">
<form action="{% if instance.autostart %}{% url 'instances:unset_autostart' instance.id %}{% else %}{% url 'instances:set_autostart' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="autostart" aria-describedby="autostart-help"{% if instance.autostart %} checked{% endif %} onchange="this.form.submit()">
<input class="form-check-input" type="checkbox" role="switch" id="autostart" aria-describedby="autostart-help"{% if instance.autostart %} checked{% endif %} data-submit-on-change>
<label class="form-check-label fw-semibold" for="autostart">{% trans "Autostart" %}</label>
</div>
<p class="small text-body-secondary mb-0 mt-1" id="autostart-help">{% trans "Start this VM when the host starts." %}</p>
@ -107,7 +107,7 @@
<div class="list-group-item py-3">
<form action="{% if instance.config_bootmenu %}{% url 'instances:unset_bootmenu' instance.id %}{% else %}{% url 'instances:set_bootmenu' instance.id %}{% endif %}" method="post">{% csrf_token %}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="bootmenu" aria-describedby="bootmenu-help"{% if instance.config_bootmenu %} checked{% endif %} onchange="this.form.submit()">
<input class="form-check-input" type="checkbox" role="switch" id="bootmenu" aria-describedby="bootmenu-help"{% if instance.config_bootmenu %} checked{% endif %} data-submit-on-change>
<label class="form-check-label fw-semibold" for="bootmenu">{% trans "Boot menu" %}</label>
</div>
<p class="small text-body-secondary mb-0 mt-1" id="bootmenu-help">{% trans "Show a device chooser when the VM starts." %}</p>
@ -314,7 +314,7 @@
<input type="hidden" name="mac" value="{{ network.mac }}">
<input type="hidden" name="set_link_state" value="{{ network.state }}">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="link-{{ forloop.counter0 }}"{% if network.state == 'up' %} checked{% endif %} onchange="this.form.submit()">
<input class="form-check-input" type="checkbox" role="switch" id="link-{{ forloop.counter0 }}"{% if network.state == 'up' %} checked{% endif %} data-submit-on-change>
<label class="form-check-label" for="link-{{ forloop.counter0 }}">{% if network.state == 'up' %}{% trans "Link up" %}{% else %}{% trans "Link down" %}{% endif %}</label>
</div>
</form>
@ -437,7 +437,7 @@
<p class="small text-body-secondary">{% trans "Devices of the host given to the VM. The VM starts only on a host that has them; it is not migrated while it has them, and its clones get none." %}</p>
{% if request.user.is_superuser %}
{# the add forms come when the tab opens: their choices scan every VM of the host #}
<div id="host-devices" class="mb-3" data-url="{% url 'instances:host_devices' instance.id %}">
<div id="host-devices" class="mb-3" data-url="{% url 'instances:host_devices' instance.id %}" data-failed="{% trans "The host's devices could not be read." %}">
<span class="small text-body-secondary">{% trans "Reading the host's devices…" %}</span>
</div>
{% endif %}
@ -552,7 +552,10 @@
<section class="card mb-3" aria-labelledby="migrate-storage">
<div class="card-body">
<h2 class="h6 mb-3" id="migrate-storage">{% bs_icon 'device-hdd' %} {% trans "Storage" %}</h2>
<div id="migrate-plan" data-url="{% url 'instances:migrate_plan' instance.id %}" data-targets-url="{% url 'instances:migrate_targets' instance.id %}" aria-live="polite"></div>
<div id="migrate-plan" data-url="{% url 'instances:migrate_plan' instance.id %}" data-targets-url="{% url 'instances:migrate_targets' instance.id %}" aria-live="polite"
data-checking-hosts="{% trans "Checking the hosts…" %}" data-hosts-failed="{% trans "The hosts could not be checked." %}"
data-no-host="{% trans "No host can take the VM now." %}" data-checking-disks="{% trans "Checking the disks…" %}"
data-disks-failed="{% trans "The disks could not be checked." %}"></div>
</div>
</section>
<section class="card mb-4" aria-labelledby="migrate-transfer">
@ -765,13 +768,15 @@
<label class="form-label" for="clone_name">{% trans "Clone name" %}</label>
<div class="row g-2">
<div class="col-12 col-sm">
<input class="form-control" id="clone_name" name="name" type="text" value="{{ instance.name }}-clone" aria-describedby="clone-name-help">
<input class="form-control" id="clone_name" name="name" type="text" value="{{ instance.name }}-clone" aria-describedby="clone-name-help"
data-source="{{ instance.name }}" data-guess-url="{% url 'instances:guess_clone_name' %}"
data-guess-mac-url="{% url 'instances:guess_mac_address' 'NAME' %}">
</div>
<div class="col-12 col-sm-auto">
<button class="btn btn-secondary" type="button" onclick="guess_clone_name()">{% trans "Guess" %}</button>
<button class="btn btn-secondary" type="button" id="guess-clone-name">{% trans "Guess" %}</button>
</div>
</div>
<p class="small text-body-secondary mt-1 mb-0" id="clone-name-help"></p>
<p class="small text-body-secondary mt-1 mb-0" id="clone-name-help" data-no-name="{% trans "The DHCP file has no free name to suggest." %}"></p>
{% elif app_settings.CLONE_INSTANCE_AUTO_NAME == 'True' %}
<label class="form-label" for="clone_instance_auto_name">{% trans "Clone name" %}</label>
<input class="form-control" id="clone_instance_auto_name" type="text" value="{% trans "Automatic" %}" disabled>
@ -795,12 +800,12 @@
<div{% if not forloop.first %} class="border-top pt-3 mt-3"{% endif %}>
<p class="small mb-2"><strong>eth{{ forloop.counter0 }}</strong> <span class="text-body-secondary">· {{ network.nic }}</span></p>
<label class="form-label small" for="clone-net-mac-{{ forloop.counter0 }}">{% trans "New MAC address" %}</label>
<input class="form-control" id="clone-net-mac-{{ forloop.counter0 }}" name="clone-net-mac-{{ forloop.counter0 }}" type="text" value="{{ network.mac }}">
<input class="form-control" id="clone-net-mac-{{ forloop.counter0 }}" name="clone-net-mac-{{ forloop.counter0 }}" type="text" value="{{ network.mac }}" data-new-mac>
<div class="d-flex flex-wrap gap-2 mt-2">
<button class="btn btn-sm btn-secondary" type="button" onclick="random_mac('clone-net-mac-{{ forloop.counter0 }}')">{% trans "Random" %}</button>
<button class="btn btn-sm btn-secondary" type="button" data-random-mac="clone-net-mac-{{ forloop.counter0 }}">{% trans "Random" %}</button>
{% if forloop.first %}
{# the DHCP host entry of the clone's name holds one address: the first NIC's #}
<button class="btn btn-sm btn-secondary" type="button" onclick="guess_mac_address('#clone_name', 0)">{% trans "Guess" %}</button>
<button class="btn btn-sm btn-secondary" type="button" id="guess-clone-mac">{% trans "Guess" %}</button>
{% endif %}
</div>
</div>
@ -817,7 +822,7 @@
<div{% if not forloop.first %} class="border-top pt-3 mt-3"{% endif %}>
<p class="small mb-2"><strong>{{ disk.dev }}</strong> <span class="text-body-secondary">· {% blocktrans with pool=disk.storage %}Pool: {{ pool }}{% endblocktrans %} · {{ disk.format }}</span></p>
<label class="form-label small" for="disk_name-{{ disk.dev }}">{% trans "New volume name" %}</label>
<input class="form-control" id="disk_name-{{ disk.dev }}" name="disk-{{ disk.dev }}" type="text" value="{{ disk.image }}">
<input class="form-control" id="disk_name-{{ disk.dev }}" name="disk-{{ disk.dev }}" type="text" value="{{ disk.image }}" data-image="{{ disk.image }}">
{# a running or paused VM's disks are copied by a backup job, which does not preallocate #}
{% if disk.format == 'qcow2' and instance.status == 5 %}
<div class="form-check mt-2">
@ -906,7 +911,7 @@
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="is_template" value="True" id="is_template" aria-describedby="template-help template-state"
{% if instance.is_template %}checked{% endif %}
{% if instance.status != 5 or not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %} onchange="this.form.submit()">
{% if instance.status != 5 or not request.user.is_superuser and not request.user.is_staff %}disabled{% endif %} data-submit-on-change>
<label class="form-check-label" for="is_template">{% trans "Use as a template" %}</label>
</div>
<p class="small text-body-secondary mt-1 mb-0" id="template-state">
@ -948,7 +953,7 @@
<div class="col-12 col-lg-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="rng" value="True" id="rng" aria-describedby="rng-help rng-state"
{% if instance.config_rng %}checked{% endif %}{% if instance.config_rng == "custom" %} disabled{% endif %} onchange="this.form.submit()">
{% if instance.config_rng %}checked{% endif %}{% if instance.config_rng == "custom" %} disabled{% endif %} data-submit-on-change>
<label class="form-check-label" for="rng">{% trans "Virtio RNG" %}</label>
</div>
{% if instance.config_rng == "custom" %}
@ -966,7 +971,7 @@
<div class="col-12 col-lg-8">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="tpm" value="True" id="tpm" aria-describedby="tpm-help tpm-state"
{% if instance.config_tpm %}checked{% endif %}{% if instance.config_tpm == "custom" or not instance.config_tpm and not tpm_support %} disabled{% endif %} onchange="this.form.submit()">
{% if instance.config_tpm %}checked{% endif %}{% if instance.config_tpm == "custom" or not instance.config_tpm and not tpm_support %} disabled{% endif %} data-submit-on-change>
<label class="form-check-label" for="tpm">{% trans "TPM 2.0" %}</label>
</div>
{% if instance.config_tpm == "custom" %}

View file

@ -30,7 +30,7 @@
<div class="d-flex flex-wrap column-gap-4 mt-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="memory" id="snapshot-memory"{% if instance.status != 1 %} disabled{% endif %}
onchange="var q = document.getElementById('snapshot-quiesce'); if (q.dataset.offered) { if (this.checked) q.dataset.was = q.checked; q.disabled = this.checked; q.checked = !this.checked && q.dataset.was !== 'false'; }">
>
<label class="form-check-label" for="snapshot-memory">{% trans "Save memory too" %}</label>
</div>
<div class="form-check">

View file

@ -1,5 +1,7 @@
{% load i18n %}
<div class="tab-pane" id="graphics" role="tabpanel">
<div class="tab-pane" id="graphics" role="tabpanel" data-url="{% url 'instances:stats' instance.id %}"
data-average-over="{% trans "average over" %}" data-in="{% trans "In" %}" data-out="{% trans "Out" %}"
data-read="{% trans "Read" %}" data-write="{% trans "Write" %}">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
@ -41,14 +43,14 @@
<section class="col-12 col-md-6 col-lg-4">
<h2 class="h6 mb-1">{% trans "Network" %} · {{ net.mac }}</h2>
<p class="small text-body-secondary mb-2" id="net{{ net.mac }}Now">&nbsp;</p>
<div class="chart position-relative"><canvas id="net{{ net.mac }}Chart" role="img" aria-label="{% blocktrans with mac=net.mac %}Network {{ mac }}, recent readings{% endblocktrans %}"></canvas></div>
<div class="chart position-relative"><canvas id="net{{ net.mac }}Chart" data-mac="{{ net.mac }}" role="img" aria-label="{% blocktrans with mac=net.mac %}Network {{ mac }}, recent readings{% endblocktrans %}"></canvas></div>
</section>
{% endfor %}
{% for disk in instance.disks %}
<section class="col-12 col-md-6 col-lg-4">
<h2 class="h6 mb-1">{% trans "Disk" %} · {{ disk.dev }}</h2>
<p class="small text-body-secondary mb-2" id="blk{{ disk.dev }}Now">&nbsp;</p>
<div class="chart position-relative"><canvas id="blk{{ disk.dev }}Chart" role="img" aria-label="{% blocktrans with dev=disk.dev %}Disk {{ dev }}, recent readings{% endblocktrans %}"></canvas></div>
<div class="chart position-relative"><canvas id="blk{{ disk.dev }}Chart" data-disk="{{ disk.dev }}" role="img" aria-label="{% blocktrans with dev=disk.dev %}Disk {{ dev }}, recent readings{% endblocktrans %}"></canvas></div>
</section>
{% endfor %}
</div>
@ -57,7 +59,8 @@
{% if request.user.is_superuser %}
<div class="tab-pane" id="logs" role="tabpanel">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0" id="logs_table">
<table class="table table-sm align-middle mb-0" id="logs_table" data-url="{% url 'vm_logs' instance.id %}"
data-empty="{% trans "None" %}" data-failed="{% trans "The logs could not be loaded." %}">
<thead>
<tr>
<th scope="col">{% trans "Date" %}</th>
@ -79,35 +82,3 @@
</div>
</div>
</div>
{% if request.user.is_superuser %}
<script>
{% trans "None" as no_rows %}
{% trans "The logs could not be loaded." as logs_failed %}
// a click or the arrow keys: the rows of the moment each time Logs is shown
document.getElementById("logs-tab").addEventListener("shown.bs.tab", function () {
var tbody = $("#logs_table > tbody");
function note(text) {
tbody.empty().append($("<tr>").append($("<td colspan='4' class='text-body-secondary'>").text(text)));
}
$.getJSON("{% url 'vm_logs' instance.id %}", function(data) {
if (!data.length) {
note("{{ no_rows|escapejs }}");
return;
}
// Log messages contain user-entered names (snapshots, disks, clones);
// insert them as text, never as HTML.
tbody.empty();
$.each(data, function(id, row) {
$("<tr>")
.append($("<td>").addClass("text-nowrap").text(row['date']))
.append($("<td>").text(row['user']))
.append($("<td>").text(row['message']))
.append($("<td>").text(row['result']))
.appendTo(tbody);
});
}).fail(function () {
note("{{ logs_failed|escapejs }}");
});
});
</script>
{% endif %}

View file

@ -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)

View file

@ -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"])

View file

@ -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)

View file

@ -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")

280
static/js/instance_page.js Normal file
View file

@ -0,0 +1,280 @@
// The VM page (instance.html and its tabs). Page-wide values are data
// attributes of #vm-summary; the others are on the element they belong to.
(function () {
var summary = document.getElementById("vm-summary");
// Open the tab whose control targets "#<pane id>"
function show_tab(target) {
var controls = document.querySelectorAll('[data-bs-target], a[data-bs-toggle][href]');
for (var i = 0; i < controls.length; i++) {
var c = controls[i];
if ((c.getAttribute('data-bs-target') || c.getAttribute('href')) === target) {
bootstrap.Tab.getOrCreateInstance(c).show();
return;
}
}
}
function on(id, event, handler) {
var element = document.getElementById(id);
if (element) element.addEventListener(event, handler);
}
// the guest OS details, read through the guest agent
function load_osinfo() {
var error = $('#osinfo-error');
var silent = error[0].dataset.silent;
$.getJSON(summary.dataset.osinfoUrl, function (data) {
error.addClass('d-none');
$('#oshostname').text(data['host-name'] || '');
$('#osname').text(data.id || '');
$('#osprettyname').text(data['pretty-name'] || '');
$('#oskernelrelease').text(data['kernel-release'] || '');
$('#oskernelversion').text(data['kernel-version'] || '');
$('#osversion').text(data.version || '');
$('#ostimezone').text(data.zone ? data.zone + " / " + data.offset : '');
}).fail(function (xhr) {
var message = xhr.responseJSON && xhr.responseJSON.error;
error.text(message || silent).removeClass('d-none');
});
}
function get_osinfo() {
show_tab('#settings');
show_tab('#osinfo');
load_osinfo();
}
on("os-details-button", "click", get_osinfo);
on("osinfo-tab", "click", get_osinfo);
on("clone-button", "click", function () {
show_tab('#settings');
show_tab('#clone');
});
// one window per VM: opening it again reloads it
on("console_button", "click", function () {
window.open(this.dataset.url, this.dataset.window, 'width=850,height=650');
});
if (summary.dataset.vdiUrl) {
$.get(summary.dataset.vdiUrl, function (data) {
$("#vdi_url_input").attr("value", data);
$("#vdi_url").attr("href", data);
});
}
// a switch whose form is sent as soon as it is flipped
document.querySelectorAll("[data-submit-on-change]").forEach(function (input) {
input.addEventListener("change", function () { input.form.submit(); });
});
// a memory snapshot freezes the guest by itself: the freeze option waits
// until memory is unchecked, then is what it was
on("snapshot-memory", "change", function () {
var quiesce = document.getElementById('snapshot-quiesce');
if (!quiesce.dataset.offered) return;
if (this.checked) quiesce.dataset.was = quiesce.checked;
quiesce.disabled = this.checked;
quiesce.checked = !this.checked && quiesce.dataset.was !== 'false';
});
// the Add disk dialog: the form goes where the chosen source is made,
// and an existing volume is chosen from the volumes of its pool
var addvol = document.getElementById('addvol');
if (addvol) {
var addvolForm = document.getElementById('addvol-form');
choiceDialog(addvol, 'disk_source', function (source) {
addvolForm.action = addvolForm.dataset[source];
});
}
var pool = document.getElementById("selected_storage");
if (pool) {
pool.addEventListener("change", function () {
$.getJSON(pool.dataset.volumesUrl.replace("POOL", encodeURIComponent(pool.value)), function (data) {
var vols = $("#vols").empty();
$.each(data['vols'], function (i, item) {
vols.append($("<option>").val(item).text(item));
});
});
});
}
// MAC addresses and names of a clone or a new NIC
function random_mac(name) {
$.getJSON(summary.dataset.randomMacUrl, function (data) {
$('input[name="' + name + '"]').val(data['mac']);
});
}
document.querySelectorAll("[data-random-mac]").forEach(function (button) {
button.addEventListener("click", function () { random_mac(button.dataset.randomMac); });
});
var cloneName = document.getElementById("clone_name");
// the DHCP host entry of the clone's name holds one address: the first NIC's
function guess_mac_address() {
$.getJSON(cloneName.dataset.guessMacUrl.replace("NAME", encodeURIComponent(cloneName.value)), function (data) {
$('input[name="clone-net-mac-0"]').val(data['mac']);
});
}
// each disk of the clone is named after the clone
function update_clone_disk_name(new_vname) {
var vname = cloneName.dataset.source;
document.querySelectorAll("input[data-image]").forEach(function (input) {
var disk_name = input.dataset.image;
var disk_dot = disk_name.split('.');
var disk_dot_suffix = disk_dot[disk_dot.length - 1];
var image;
if (disk_name.startsWith(vname)) {
image = disk_name.replace(vname, new_vname);
} else if (disk_name.lastIndexOf('.') > -1 && disk_dot_suffix.length <= 7) {
disk_dot.pop();
image = new_vname + "-" + disk_dot.join('-') + "." + disk_dot_suffix;
} else if (new_vname != disk_name) {
image = new_vname;
} else {
image = new_vname + '-clone';
}
input.value = image;
});
}
function guess_clone_name() {
var help = $('#clone-name-help');
var noName = help[0].dataset.noName;
$.getJSON(cloneName.dataset.guessUrl, function (data) {
// a free name of the DHCP file, if there is one
if (!data.name) {
help.text(noName);
return;
}
help.text('');
cloneName.value = data['name'].split(".")[0];
update_clone_disk_name(cloneName.value);
guess_mac_address();
});
}
if (cloneName) {
cloneName.addEventListener("input", function () { update_clone_disk_name(cloneName.value); });
on("guess-clone-name", "click", guess_clone_name);
on("guess-clone-mac", "click", guess_mac_address);
update_clone_disk_name(cloneName.value);
}
// every NIC of the clone a new address: one the VM has would clash with it
document.querySelectorAll("[data-new-mac]").forEach(function (input) { random_mac(input.name); });
// the boot order: the checked devices, top to bottom, as "disk:vda,cdrom:sda"
function set_orderlist() {
var order = $('#b_order input:checked').map(function () { return this.value; }).get();
$('#bootorder').val(order.join(','));
}
set_orderlist();
$('#b_order').on('change', 'input', set_orderlist);
$('#boot_order_up').on('click', function () {
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() - 1;
if (newPos > -1) {
$('#b_order label').eq(newPos).before(label);
}
});
set_orderlist();
});
$('#boot_order_down').on('click', function () {
var countOptions = $('#b_order label').length;
var countSelected = $('#b_order label input:checked').length;
$('#b_order label input:checked').each(function () {
var label = $(this).parent();
var newPos = label.index() + countSelected;
if (newPos < countOptions) {
$('#b_order label').eq(newPos).after(label);
}
});
set_orderlist();
});
// the Host devices tab's add forms: loaded once, when the tab first opens
var hostDevices = document.getElementById("host-devices");
if (hostDevices) {
document.querySelector('[data-bs-target="#hostdevs"]').addEventListener("shown.bs.tab", function () {
if (hostDevices.dataset.loaded) return;
hostDevices.dataset.loaded = "true";
fetch(hostDevices.dataset.url).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
hostDevices.innerHTML = html;
}).catch(function () {
hostDevices.textContent = hostDevices.dataset.failed;
});
});
}
// the Migrate tab's disks: loaded for the chosen destination when the tab opens or the choice changes
// the form is not sent until the disks of the chosen destination are shown
var migratePlan = document.getElementById("migrate-plan");
var migrateCompute = document.getElementById("migrate_compute");
var migrateLoading = null;
function loadMigratePlan() {
if (!migrateCompute.value) return;
if (migrateLoading) migrateLoading.abort();
var loading = migrateLoading = new AbortController();
var submit = migratePlan.closest("form").querySelector('button[name="migrate"]');
submit.disabled = true;
migratePlan.textContent = migratePlan.dataset.checkingDisks;
fetch(migratePlan.dataset.url + "?compute_id=" + encodeURIComponent(migrateCompute.value),
{signal: loading.signal}).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.text();
}).then(function (html) {
migratePlan.innerHTML = html;
// a destination that did not answer, or a disk that cannot go there
submit.disabled = migratePlan.querySelector("[data-refused]") !== null;
}).catch(function () {
if (loading.signal.aborted) return; // another destination was chosen
migratePlan.textContent = migratePlan.dataset.disksFailed;
submit.disabled = false; // the migration checks them again
});
}
// the destinations that cannot be chosen (unreachable, or this host under
// another record) are marked first; then the disks of the first one left
function loadMigrateTargets() {
var submit = migratePlan.closest("form").querySelector('button[name="migrate"]');
submit.disabled = true;
migrateCompute.disabled = true; // no disks are checked for a host before the hosts are
migratePlan.textContent = migratePlan.dataset.checkingHosts;
fetch(migratePlan.dataset.targetsUrl).then(function (response) {
if (!response.ok) throw new Error(response.statusText);
return response.json();
}).then(function (refused) {
migrateCompute.disabled = false;
Array.prototype.forEach.call(migrateCompute.options, function (option) {
if (refused[option.value]) {
option.disabled = true;
option.textContent = refused[option.value]; // it names the host
}
});
var first = Array.prototype.find.call(migrateCompute.options, function (option) { return !option.disabled; });
if (!first) {
migratePlan.textContent = migratePlan.dataset.noHost;
return;
}
if (migrateCompute.selectedOptions[0].disabled) migrateCompute.value = first.value;
loadMigratePlan();
}).catch(function () {
migratePlan.textContent = migratePlan.dataset.hostsFailed;
});
}
if (migratePlan && migrateCompute) {
document.querySelector('[data-bs-target="#migrate"]').addEventListener("shown.bs.tab", function () {
if (!migratePlan.dataset.loaded) {
migratePlan.dataset.loaded = "true";
loadMigrateTargets();
}
});
migrateCompute.addEventListener("change", loadMigratePlan);
}
// the tab a redirect points to; a template opens on its clone form
restoreInstanceTab(document, location.hash || summary.dataset.opensOn, show_tab, load_osinfo);
// none open (a template has no Power tab): the first section the page has
if (!document.querySelector("#navbtn .nav-link.active")) {
var first = document.querySelector("#navbtn .nav-link");
if (first) { show_tab(first.getAttribute("data-bs-target")); }
}
})();

115
static/js/instance_stats.js Normal file
View file

@ -0,0 +1,115 @@
// The VM page's Stats tab (stats_tab.html): its charts are polled while the
// tab is shown and made once; superusers' Logs view loads its rows each time
// it is shown.
(function () {
var pane = document.getElementById("graphics");
var stats_tab = document.querySelector('#graphics-tab');
var stats_poll = null;
// the seconds each CPU point averages
var cpuWindows = [];
// the usage sample of the previous answer: the next one averages since then
var statsSample = null;
// a reading for the line above a chart: one decimal, a dash where it is missing
function reading(value) {
return value == null ? "–" : String(Math.round(value * 10) / 10);
}
stats_tab.addEventListener('hidden.bs.tab', function () {
stats_poll.stop();
});
stats_tab.addEventListener('shown.bs.tab', function () {
if (stats_poll !== null) {
stats_poll.start();
return;
}
var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], {
max: 100,
tooltip: function (context, text) {
var span = cpuWindows[context.dataIndex];
return span ? text + ' (' + pane.dataset.averageOver + ' ' + span + ' s)' : text;
},
});
var memChart = lineChart(document.getElementById("memChart"), "MiB", ["Usage (RSS)"]);
var diskChart = {};
pane.querySelectorAll("canvas[data-disk]").forEach(function (canvas) {
diskChart[canvas.dataset.disk] = lineChart(canvas, "MB/s", ["Read", "Write"]);
});
// two NICs with one MAC (a misconfiguration) share the first one's chart
var netChart = {};
pane.querySelectorAll("canvas[data-mac]").forEach(function (canvas) {
var mac = canvas.dataset.mac;
netChart[mac] = netChart[mac] || lineChart(canvas, "Mbit/s", ["Inbound", "Outbound"]);
});
// the first point at once, then every 5 s like the host graph
stats_poll = pollWhileVisible(5000, function () {
return $.getJSON(pane.dataset.url, statsSample ? {sample: statsSample} : {})
.fail(function () {
statsSample = null;
});
}, function graph_usage(data) {
statsSample = data.sample || null;
cpuWindows.push(data.window);
if (cpuWindows.length > CHART_POINTS) {
cpuWindows.shift();
}
addPoint(cpuChart, data.timeline, [data.cpudata]);
document.getElementById("cpuNow").textContent = reading(data.cpudata) + " %";
// a VM whose memory could not be read gives total 0: a gap, same axis;
// the RSS may pass the balloon size: then the axis grows to it
if (data.memdata.total) {
var used = Math.round(data.memdata.used / 1024), total = Math.round(data.memdata.total / 1024);
memChart.options.scales.y.max = Math.max(total, used);
addPoint(memChart, data.timeline, [used]);
document.getElementById("memNow").textContent = used + " / " + total + " MiB";
} else {
addPoint(memChart, data.timeline, [null]);
document.getElementById("memNow").textContent = "";
}
// a disk or NIC added after the page loaded has no chart until a reload,
// and one removed keeps its last points
function push(chart, id, labels, unit, values) {
if (chart) {
addPoint(chart, data.timeline, values);
document.getElementById(id).textContent =
labels[0] + " " + reading(values[0]) + " · " + labels[1] + " " + reading(values[1]) + " " + unit;
}
}
data.blkdata.forEach(function (disk) {
push(diskChart[disk.dev], "blk" + disk.dev + "Now", [pane.dataset.read, pane.dataset.write], "MB/s", disk.data);
});
data.netdata.forEach(function (nic) {
push(netChart[nic.mac], "net" + nic.mac + "Now", [pane.dataset.in, pane.dataset.out], "Mbit/s", nic.data);
});
}, 0);
});
// a click or the arrow keys: the rows of the moment each time Logs is shown
var logsTab = document.getElementById("logs-tab");
if (!logsTab) return;
var table = document.getElementById("logs_table");
logsTab.addEventListener("shown.bs.tab", function () {
var tbody = $(table).children("tbody");
function note(text) {
tbody.empty().append($("<tr>").append($("<td colspan='4' class='text-body-secondary'>").text(text)));
}
$.getJSON(table.dataset.url, function (data) {
if (!data.length) {
note(table.dataset.empty);
return;
}
// Log messages contain user-entered names (snapshots, disks, clones);
// insert them as text, never as HTML.
tbody.empty();
$.each(data, function (id, row) {
$("<tr>")
.append($("<td>").addClass("text-nowrap").text(row['date']))
.append($("<td>").text(row['user']))
.append($("<td>").text(row['message']))
.append($("<td>").text(row['result']))
.appendTo(tbody);
});
}).fail(function () {
note(table.dataset.failed);
});
});
})();

View file

@ -0,0 +1,13 @@
// The VM page reloads when the VM's state changes (instance.html, #vm-summary).
backgroundJobRunning = false;
// not "status": that is window.status, which the browser may not keep
var vm_summary = document.getElementById("vm-summary");
var vm_status = Number(vm_summary.dataset.status);
var status_poll = pollWhileVisible(5000, function () {
return $.getJSON(vm_summary.dataset.statusUrl);
}, function get_status(data) {
// an XML edit not saved yet stays: the reload waits until it is saved or undone
if (data['status'] != vm_status && !backgroundJobRunning && !(window.xmlEditUnsaved && window.xmlEditUnsaved())) {
window.location.reload();
}
});

View file

@ -13,3 +13,7 @@ function hidePleaseWaitDialog() {
pleaseModal.hide();
backgroundJobRunning = false;
}
// a form marked data-please-wait shows the dialog while it is sent
document.addEventListener("submit", function (event) {
if (event.target.matches("form[data-please-wait]")) showPleaseWaitDialog();
});

View file

@ -2,7 +2,6 @@
none while the page is hidden. Its behaviour is checked with Node."""
import json
import re
import shutil
import subprocess
import unittest
@ -11,7 +10,7 @@ from pathlib import Path
from django.conf import settings
BASE = Path(settings.BASE_DIR)
POLLING_TEMPLATES = ["instances/templates/instance.html", "computes/templates/overview.html"]
POLLING_SOURCES = ["static/js/instance_status.js", "static/js/instance_stats.js", "computes/templates/overview.html"]
class PollTestCase(unittest.TestCase):
@ -37,9 +36,9 @@ class PollTestCase(unittest.TestCase):
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()
for path in POLLING_SOURCES:
with self.subTest(path=path):
source = (BASE / path).read_text()
self.assertNotIn("setInterval", source)
self.assertIn("pollWhileVisible(", source)
@ -47,11 +46,7 @@ class PollTestCase(unittest.TestCase):
def test_the_vm_page_reloads_only_when_the_state_changes(self):
# "status" is a property of the browser's window: a page variable of
# that name holds what the browser makes of it (a string, or nothing)
source = (BASE / "instances/templates/instance.html").read_text()
script = re.search(r"<script>\s*(backgroundJobRunning = false;.*?)</script>", source, re.S).group(1)
script = script.replace("{{ instance.status|lower }}", "1").replace(
"{% url 'instances:status' instance.id %}", "/instances/1/status/"
)
script = (BASE / "static/js/instance_status.js").read_text()
js = """
const vm = require("vm");
const results = {};
@ -60,6 +55,7 @@ class PollTestCase(unittest.TestCase):
const page = {
pollWhileVisible: (interval, request, onData) => { onData({status: answer}); return {stop() {}}; },
$: () => ({submit() {}}),
document: {getElementById: () => ({dataset: {status: "1", statusUrl: "/instances/1/status/"}})},
};
page.window = {location: {reload: () => reloads++}};
Object.defineProperty(page, "status", {get: () => "", set: () => {}});