mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 19:26:48 +00:00
Settings > Clone was a horizontal form with unlabelled checkboxes. The owner chose this layout from three rendered options: the clone's name on top, a "Network" card with a new MAC address per NIC, a "Storage" card with a new volume name per disk, then the optional title and description and a footer with Clone. Codex reviewed cloning end to end; these are fixed: - Every NIC of the clone gets a new random MAC when the form opens. Only the first did, so a VM with two NICs gave its clone the second NIC's MAC. Guess MAC is offered for the first NIC only: a name's DHCP host entry holds one address. - A VM without a NIC can be cloned: the first NIC's MAC was required. - The disk name script uses the persistent disks, as the form does; a disk added pending the next start kept the source's volume name. - A running or paused VM is not cloned while a copied disk changes at the next start: the backup copies the running disk, so the clone would get the old contents under the new definition. - A derived volume name that does not start with the source's name now starts with the clone's name and the disk's device, so a second clone or a second disk does not take the same name. - Guess for the clone name says when the DHCP file has no free name; it stopped with a script error. - The qcow2 metadata preallocation box shows only for a shut-off VM: a live clone's backup ignores it. Other users no longer get hidden MAC and disk inputs or the scripts that filled them: the server derives those values and ignored the posted ones.
449 lines
21 KiB
HTML
449 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) {
|
|
if (data['status'] != vm_status && !backgroundJobRunning) {
|
|
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;
|
|
submit.disabled = false;
|
|
}).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 %}
|