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:
parent
393aecb8f1
commit
561937b31a
18 changed files with 507 additions and 460 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"> </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"> </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 %}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
280
static/js/instance_page.js
Normal file
280
static/js/instance_page.js
Normal 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
115
static/js/instance_stats.js
Normal 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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
13
static/js/instance_status.js
Normal file
13
static/js/instance_status.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: () => {}});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue