1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/instances/templates/instance.html
catborise 6434d4cbfe fix(instances): the VM page's section bars fill their row
The buttons of the VM page's section bar and of the Settings bar under it
sat at the start of the row and left its end empty. Both bars now share
the whole row out among their buttons (Bootstrap's nav-fill), also when
they wrap on a narrow screen.
2026-11-01 23:17:12 +03:00

497 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% 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>
{% if instance.is_template %}<span class="badge text-bg-light border">{% trans "Template" %}</span>{% endif %}
</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.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>
{% 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>
{% endif %}
<a class="btn btn-sm btn-secondary" href="{% url 'instances:instance' instance.id %}">{% bs_icon 'arrow-clockwise' %} {% trans "Refresh" %}</a>
</div>
</div>
{% if not instance.is_template %}
<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>
{% endif %}
<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 }} MiB</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>
{% if not instance.is_template %}
<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>
{% endif %}
</dl>
</div>
<div class="card-footer p-2">
<ul class="nav nav-pills nav-fill flex-wrap flex-lg-nowrap gap-1 small" id="navbtn" role="tablist" aria-label="{% trans "Instance actions" %}">
{# a template is cloned, not run: no power, access, snapshots or stats #}
{% if not instance.is_template %}
<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>
{% endif %}
<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 and not instance.is_template %}
<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 %}
{% if not instance.is_template %}
<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>
{% endif %}
<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>
{% if not instance.is_template %}
{% include 'instances/power_tab.html' %}
{% include 'instances/access_tab.html' %}
{% endif %}
{% include 'instances/resize_tab.html' %}
{% if can_snapshot and not instance.is_template %}{% include 'instances/snapshots_tab.html' %}{% endif %}
{% if show_settings %}{% include 'instances/settings_tab.html' %}{% endif %}
{% if not instance.is_template %}{% include 'instances/stats_tab.html' %}{% endif %}
{% 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>
// 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>
{% 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>
{% endblock %}