mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
Settings > Users was a bold title, an unlabelled "+" and a table of names with a trash icon. The owner chose this layout from three rendered options: one list of owners, each with what it may do (change settings, delete the VM, change console settings, or power and console only), Edit rights and Remove, an empty state, and a labelled "Add owner". - Remove and Edit rights come back to the VM's Users tab: the return address carried #users unencoded, so it was lost, and Edit rights went to the user's account page. Both accounts views now go to a ?next page of this site. - The add-owner list offers only users who are not owners yet and not administrators, who can use every VM anyway; a superuser owner also took the only place when a VM may have one owner. - The unused del_owner view and URL are removed: the list uses the accounts page, which asks before removing an owner. XML: - An XML edit not saved yet is kept: the page no longer reloads itself when the VM's state changes, and the browser asks before leaving it. A state change lost the edit. - The button that opens the review dialog reads "Review changes"; it did not save, as "Change" suggested. Closing the dialog gives focus back to the editor.
451 lines
21 KiB
HTML
451 lines
21 KiB
HTML
{% extends "base.html" %}
|
|
{% load static %}
|
|
{% load vm_states %}
|
|
{% load i18n %}
|
|
{% load operations %}
|
|
{% load bootstrap_icons %}
|
|
|
|
{% block title %}{% trans "Instance" %} - {{ instance.name }}{% endblock %}
|
|
|
|
{% block page_header %}{% endblock page_header %}{# the card below has the heading #}
|
|
|
|
{% 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">
|
|
<div class="card-body p-3">
|
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
|
|
<div>
|
|
<div class="d-flex align-items-center gap-2">
|
|
<h1 class="h4 mb-0" id="vm-name">{{ instance.name }}</h1>
|
|
{% vm_state instance.status as state %}
|
|
<span class="badge text-bg-{{ state.1 }}">{{ state.0 }}</span>
|
|
</div>
|
|
{% if instance.title %}<p class="text-body-secondary mb-1">{{ instance.title }}</p>{% endif %}
|
|
{% include 'instances/host_link.html' %}
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
{% 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>
|
|
{% endif %}
|
|
<a class="btn btn-sm btn-secondary" href="{% url 'instances:instance' instance.id %}">{% bs_icon 'arrow-clockwise' %} {% trans "Refresh" %}</a>
|
|
</div>
|
|
</div>
|
|
<div class="d-flex flex-wrap column-gap-3 row-gap-1 small mt-2">
|
|
{% if not instance.guest_agent %}
|
|
<span>{% bs_icon 'dash-circle' %} {% trans "Guest agent off" %}</span>
|
|
{% elif instance.guest_agent_ready %}
|
|
<span><span class="text-success">{% bs_icon 'check-circle' %}</span> {% trans "Guest agent connected" %}</span>
|
|
{% else %}
|
|
<span><span class="text-warning">{% bs_icon 'exclamation-circle' %}</span> {% trans "Guest agent not connected" %}</span>
|
|
{% endif %}
|
|
<span>{% bs_icon 'camera' %} {% blocktrans count counter=instance.snapshots|length %}{{ counter }} snapshot{% plural %}{{ counter }} snapshots{% endblocktrans %}</span>
|
|
</div>
|
|
<dl class="row g-2 small mb-0 mt-1">
|
|
<div class="col-6 col-lg-2">
|
|
<dt class="fw-normal text-body-secondary">{% trans "CPU" %}</dt>
|
|
<dd class="mb-0">{% firstof instance.cur_vcpu instance.vcpu %} {% trans "of" %} {{ instance.vcpu }} {% trans "vCPU" %}</dd>
|
|
</div>
|
|
<div class="col-6 col-lg-2">
|
|
<dt class="fw-normal text-body-secondary">{% trans "Memory" %}</dt>
|
|
<dd class="mb-0">{{ instance.cur_memory }} {% trans "MB" %}</dd>
|
|
</div>
|
|
<div class="col-6 col-lg-5">
|
|
<dt class="fw-normal text-body-secondary">{% trans "Disks" %}</dt>
|
|
<dd class="mb-0">{% for disk in instance.disks %}{{ disk.dev }} {{ disk.size|filesizeformat }}{% if not forloop.last %} · {% endif %}{% empty %}{% trans "None" %}{% endfor %}</dd>
|
|
</div>
|
|
<div class="col-6 col-lg-3">
|
|
<dt class="fw-normal text-body-secondary">{% trans "IPv4" %}</dt>
|
|
<dd class="mb-0 font-monospace">{{ ipv4_addresses|join:", "|default:"—" }}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
<div class="card-footer p-2">
|
|
<ul class="nav nav-pills flex-wrap flex-lg-nowrap gap-1 small" id="navbtn" role="tablist" aria-label="{% trans "Instance actions" %}">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap active" id="power-tab" aria-controls="power" data-bs-toggle="pill" data-bs-target="#power" type="button" role="tab" aria-selected="true">{% bs_icon 'power' %} {% trans "Power" %}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="access-tab" aria-controls="access" data-bs-toggle="pill" data-bs-target="#access" type="button" role="tab" aria-selected="false">{% bs_icon 'lock' %} {% trans "Access" %}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="resize-tab" aria-controls="resize" data-bs-toggle="pill" data-bs-target="#resize" type="button" role="tab" aria-selected="false">{% bs_icon 'arrows-angle-expand' %} {% trans "Resize" %}</button>
|
|
</li>
|
|
{% if can_snapshot %}
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="snapshots-tab" aria-controls="snapshots" data-bs-toggle="pill" data-bs-target="#snapshots" type="button" role="tab" aria-selected="false">{% bs_icon 'camera' %} {% trans "Snapshot" %}</button>
|
|
</li>
|
|
{% endif %}
|
|
{% if show_settings %}
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="settings-tab" aria-controls="settings" data-bs-toggle="pill" data-bs-target="#settings" type="button" role="tab" aria-selected="false">{% bs_icon 'gear-wide-connected' %} {% trans "Settings" %}</button>
|
|
</li>
|
|
{% endif %}
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="graphics-tab" aria-controls="graphics" data-bs-toggle="pill" data-bs-target="#graphics" type="button" role="tab" aria-selected="false">{% bs_icon 'bar-chart-line' %} {% trans "Stats" %}</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link text-nowrap" id="undefine-tab" aria-controls="undefine" data-bs-toggle="pill" data-bs-target="#undefine" type="button" role="tab" aria-selected="false">{% bs_icon 'trash' %} {% trans "Destroy" %}</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<div id="job-panel" aria-live="polite"></div>
|
|
<div id="mainTabMenu">
|
|
<!-- Tab panes -->
|
|
<div class="tab-content" id=mainTabMenuContent>
|
|
{% include 'instances/power_tab.html' %}
|
|
{% include 'instances/access_tab.html' %}
|
|
{% include 'instances/resize_tab.html' %}
|
|
{% if can_snapshot %}{% include 'instances/snapshots_tab.html' %}{% endif %}
|
|
{% if show_settings %}{% include 'instances/settings_tab.html' %}{% endif %}
|
|
{% include 'instances/stats_tab.html' %}
|
|
{% include 'instances/destroy_tab.html' %}
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
{% block script %}
|
|
<script src="{% static 'js/ace/ace.js' %}" type="text/javascript" charset="utf-8"></script>
|
|
<script>
|
|
function get_volumes(compute_id, pool) {
|
|
get_vol_url = "/computes/" + compute_id + "/storage/" + pool + "/volumes";
|
|
$.getJSON(get_vol_url, function (data) {
|
|
$("#vols").find('option').remove();
|
|
$("#vols").removeAttr("disabled");
|
|
|
|
$.each(data['vols'], function(i, item) {
|
|
$("#vols").append($("<option>").val(item).text(item));
|
|
})
|
|
});
|
|
|
|
var sto_drop = document.getElementById('select_storage');
|
|
sto_drop.value = pool;
|
|
sto_drop.textContent = pool;
|
|
var caret = document.createElement("span");
|
|
caret.className = "caret";
|
|
sto_drop.appendChild(caret);
|
|
|
|
var sto_input = document.getElementById('selected_storage');
|
|
sto_input.setAttribute('value', pool);
|
|
//sto_input.innerHTML = pool;
|
|
}
|
|
</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 %}
|
|
$(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>
|
|
$(function () {
|
|
$('.js-custom__checkbox').change(function () {
|
|
var container = $(this).closest('.js-custom__container');
|
|
var toggles = container.find('.js-custom__toggle');
|
|
toggles.toggleClass("d-none");
|
|
// a custom value is sent only while its box is ticked
|
|
container.find('input.js-custom__toggle').prop('disabled', !this.checked);
|
|
});
|
|
});
|
|
</script>
|
|
<script src="{% static 'js/chart.umd.min.js' %}"></script>
|
|
<script src="{% static 'js/charts.js' %}"></script>
|
|
<script src="{% static 'js/poll.js' %}"></script>
|
|
<script>
|
|
{% trans "average over" as average_over %}
|
|
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.
|
|
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"), "MB", ["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 %}
|
|
var netChart = {};
|
|
{% for net in instance.networks %}
|
|
netChart[{{ forloop.counter0 }}] = lineChart(document.getElementById("netEth{{ forloop.counter0 }}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]);
|
|
// a VM whose memory could not be read gives total 0: a gap, same axis
|
|
if (data.memdata.total) {
|
|
memChart.options.scales.y.max = Math.round(data.memdata.total / 1024);
|
|
addPoint(memChart, data.timeline, [Math.round(data.memdata.used / 1024)]);
|
|
} else {
|
|
addPoint(memChart, data.timeline, [null]);
|
|
}
|
|
// a disk or NIC added after the page loaded has no chart until a reload
|
|
function push(chart, values) {
|
|
if (chart) {
|
|
addPoint(chart, data.timeline, values);
|
|
}
|
|
}
|
|
data.blkdata.forEach(function (disk) { push(diskChart[disk.dev], disk.data); });
|
|
data.netdata.forEach(function (nic) { push(netChart[nic.dev], nic.data); });
|
|
}, 0);
|
|
});
|
|
</script>
|
|
<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>
|
|
{% 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 () {
|
|
restoreInstanceTab(document, location.hash, show_tab, load_osinfo);
|
|
});
|
|
</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
|
|
});
|
|
}
|
|
if (migratePlan && migrateCompute) {
|
|
document.querySelector('[data-bs-target="#migrate"]').addEventListener("shown.bs.tab", function () {
|
|
if (!migratePlan.dataset.loaded) {
|
|
migratePlan.dataset.loaded = "true";
|
|
loadMigratePlan();
|
|
}
|
|
});
|
|
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>
|
|
{% endblock %}
|