1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 19:56:48 +00:00

refactor(templates): the host pages' scripts load from static files

The host pages lose their last inline scripts: the overview's charts
(host_graphs.js), the New pool, New network and New interface dialogs
(pool_dialog.js, network_dialog.js, interface_dialog.js), a network's
and a network filter's XML editors and dialogs (network_page.js,
nwfilter_page.js, nwfilters_page.js), the secrets page's labels
(secrets_page.js, its texts from the view as usage_texts) and a pool's
page with the ISO upload (storage_page.js). Their URLs and messages are
data attributes of the elements they belong to; the upload reads its
token and address from its form. No template has an inline script or
an inline event handler any more.
This commit is contained in:
catborise 2026-11-02 09:45:26 +03:00
parent 946629b035
commit 50dcaba699
23 changed files with 431 additions and 407 deletions

54
static/js/host_graphs.js Normal file
View file

@ -0,0 +1,54 @@
// The host's CPU and memory charts (overview.html), polled every 5 s.
var state = document.getElementById("graph_state");
// the seconds each CPU point averages
var cpuWindows = [];
var cpuChart = lineChart(document.getElementById("cpuChart"), "%", ["Usage"], {
max: 100,
tooltip: function (context, text) {
var span = cpuWindows[context.dataIndex];
return span ? text + ' (' + state.dataset.averageOver + ' ' + span + ' s)' : text;
},
});
var memChart = lineChart(document.getElementById("memChart"), "GB", ["Usage"]);
var cpuNow = document.getElementById("cpu_now");
var memNow = document.getElementById("mem_now");
var lastUpdate = "–";
function gb(bytes) {
return Math.round(bytes / 107374182.4) / 10;
}
// a failed reading leaves the charts as they were: say since when
function stale() {
state.textContent = state.dataset.stale.replace("%(time)s", lastUpdate);
state.classList.add("text-danger");
}
// the CPU sample of the previous answer: usage is taken since then
var cpuSample = null;
pollWhileVisible(5000, function () {
return $.getJSON(state.dataset.url, cpuSample ? {cpu_sample: cpuSample} : {}).fail(stale);
}, function (data) {
cpuSample = data.cpu_sample || null;
cpuWindows.push(data.window);
if (cpuWindows.length > CHART_POINTS) {
cpuWindows.shift();
}
addPoint(cpuChart, data.timeline, [data.cpudata]);
// a host that could not be read gives no values: a gap, same axis
if (data.memdata.total) {
memChart.options.scales.y.max = gb(data.memdata.total);
addPoint(memChart, data.timeline, [gb(data.memdata.usage)]);
memNow.textContent = gb(data.memdata.usage) + " / " + gb(data.memdata.total) + " GB";
} else {
addPoint(memChart, data.timeline, [null]);
}
if (data.cpudata == null) {
stale();
return;
}
cpuNow.textContent = data.cpudata + " %";
lastUpdate = data.timeline;
state.textContent = state.dataset.updated.replace("%(time)s", data.timeline);
state.classList.remove("text-danger");
}, 0);

View file

@ -0,0 +1,13 @@
// The New interface dialog (create_iface_block.html).
// a static address's fields show with its choice (after the page's
// scripts, Bootstrap's among them)
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('interface-form');
form.querySelectorAll('select[data-static]').forEach(function (method) {
var fields = form.querySelector(method.dataset.static);
function show() { fields.hidden = fields.disabled = method.value !== 'static'; }
method.addEventListener('change', show);
show();
});
choiceDialog(document.getElementById('AddInterface'), 'itype', function () {});
});

View file

@ -0,0 +1,17 @@
// The New network dialog (create_net_block.html).
// the IPv6 fields show with their box; a NAT or routed network needs a
// subnet (after the page's scripts, Bootstrap's among them)
document.addEventListener('DOMContentLoaded', function () {
var ipv6 = document.getElementById('net_ipv6');
function showIpv6() {
var fields = document.getElementById('net_ipv6_fields');
fields.hidden = !ipv6.checked;
fields.querySelectorAll('input').forEach(function (input) { input.disabled = !ipv6.checked; });
document.getElementById('net_subnet6').required = ipv6.checked;
}
ipv6.addEventListener('change', showIpv6);
choiceDialog(document.getElementById('AddNetPool'), 'forward', function (mode) {
document.getElementById('net_subnet').required = mode === 'nat' || mode === 'route';
showIpv6();
});
});

42
static/js/network_page.js Normal file
View file

@ -0,0 +1,42 @@
// A network's page (network.html): its XML editor and the dialogs its rows open.
var editor = ace.edit("edit_editor");
editor.getSession().setMode("ace/mode/xml");
var editInput = $('input[name="edit_xml"]');
editor.getSession().on("change", function () {
editInput.val(editor.getSession().getValue());
});
// the editor measures itself when its section is shown
document.getElementById("network-xml").addEventListener("shown.bs.collapse", () => editor.resize());
// a dialog opened by a row's button gets that row's values, one opened by an Add button starts empty
function fillFrom(dialogId, fill) {
var dialog = document.getElementById(dialogId);
if (dialog) dialog.addEventListener("show.bs.modal", function (event) {
fill(dialog.querySelector("form"), event.relatedTarget.dataset);
});
}
fillFrom("FixedAddress", function (form, data) {
form.elements.original.value = data.original || "";
form.elements.address.value = data.address || "";
form.elements.mac.value = data.mac || "";
form.elements.name.value = data.name || "";
// Add starts from the first subnet, not the one a row chose before
form.elements.ip_index.value = data.ipIndex || form.elements.ip_index.options[0].value;
document.getElementById("fixed_subnet_row").hidden = Boolean(data.original);
});
fillFrom("DhcpRange", function (form, data) {
form.elements.ip_index.value = data.ipIndex;
form.elements.range_start.value = data.start;
form.elements.range_end.value = data.end;
document.getElementById("dhcp_subnet").textContent = data.subnet;
});
fillFrom("QosLimit", function (form, data) {
var direction = form.elements.qos_direction;
direction.querySelectorAll("option").forEach(function (option) {
option.disabled = data.direction ? option.value !== data.direction : option.hasAttribute("data-taken");
});
direction.value = data.direction || direction.querySelector("option:not(:disabled)").value;
form.elements.qos_average.value = data.average || "";
form.elements.qos_peak.value = data.peak || "";
form.elements.qos_burst.value = data.burst || "";
});

View file

@ -0,0 +1,18 @@
// A network filter's page (nwfilter.html).
// each editor's text goes in its form's hidden field, the starter too if sent as it is,
// and it measures itself when its section or dialog is shown
function xmlEditor(textareaId, fieldName, shownBy, shownEvent) {
var textarea = document.getElementById(textareaId);
var form = textarea.closest("form");
var editor = ace.edit(textareaId);
editor.getSession().setMode("ace/mode/xml");
editor.setOption("textInputAriaLabel", textarea.getAttribute("aria-label"));
var field = form.elements[fieldName];
field.value = editor.getSession().getValue();
editor.getSession().on("change", function () {
field.value = editor.getSession().getValue();
});
document.getElementById(shownBy).addEventListener(shownEvent, () => editor.resize());
}
xmlEditor("edit_editor", "edit_xml", "nwfilter-xml", "shown.bs.collapse");
xmlEditor("rule_editor", "nwfilterrule_xml", "AddNWFilterRule", "shown.bs.modal");

View file

@ -0,0 +1,23 @@
// The network filters page (nwfilters.html): the Clone dialog takes the
// filter of its row, and the New filter dialog's XML editor fills its form.
var cloneDialog = document.getElementById("CloneNWFilter");
if (cloneDialog) {
cloneDialog.addEventListener("show.bs.modal", function (event) {
var name = event.relatedTarget.dataset.name;
this.querySelector("form").elements.nwfiltername.value = name;
document.getElementById("clone_source").textContent = name;
document.getElementById("clone_name").value = name + "-copy";
});
}
var textarea = document.getElementById("create_editor");
var editor = ace.edit("create_editor");
editor.getSession().setMode("ace/mode/xml");
editor.setOption("textInputAriaLabel", textarea.getAttribute("aria-label"));
var input = $('input[name="nwfilter_xml"]');
input.val(editor.getSession().getValue()); // the starter too, if sent as it is
editor.getSession().on("change", function () {
input.val(editor.getSession().getValue());
});
// the editor measures itself when its dialog is shown
document.getElementById("AddNWFilter").addEventListener("shown.bs.modal", () => editor.resize());

31
static/js/pool_dialog.js Normal file
View file

@ -0,0 +1,31 @@
// The New pool dialog (create_stg_block.html).
// the ISO pool is a directory named iso; a path follows the pool's name
// until it is edited (after the page's scripts, Bootstrap's among them)
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('pool-form');
var name = form.elements.namedItem('name');
var otherName = name.value;
var hasCephSecrets = "cephSecrets" in form.dataset;
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
path.dataset.derived = name.value ? path.dataset.followName + name.value : '';
});
name.addEventListener('input', function () {
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
var derived = name.value ? path.dataset.followName + name.value : '';
if (!path.value || path.value === path.dataset.derived) path.value = derived;
path.dataset.derived = derived;
});
});
// a new device or name: the erasing is agreed to again
form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) {
field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; });
});
choiceDialog(document.getElementById('AddStgPool'), 'pool_kind', function (kind) {
var iso = kind === 'iso';
if (!name.readOnly) otherName = name.value;
name.value = iso ? 'iso' : otherName;
name.readOnly = iso;
form.elements.namedItem('stg_type').value = iso ? 'dir' : kind;
form.querySelector('button[name=create]').disabled = kind === 'rbd' && !hasCephSecrets;
});
});

34
static/js/secrets_page.js Normal file
View file

@ -0,0 +1,34 @@
// The secrets page (secrets.html): the labels of a secret's usage and
// value follow its usage type (usage_texts from the view).
var usageTexts = JSON.parse(document.getElementById("usage-texts").textContent);
var usageType = document.getElementById("secret_usage_type");
if (usageType) {
var showUsage = function () {
var texts = usageTexts[usageType.value];
document.getElementById("secret_usage_label").textContent = texts.usage;
document.getElementById("secret_usage_help").textContent = texts.usageHelp;
document.getElementById("secret_value_label").textContent = texts.value;
document.getElementById("secret_value_help").textContent = texts.valueHelp + " " + usageTexts.later;
};
usageType.addEventListener("change", showUsage);
showUsage();
}
var setValue = document.getElementById("SetSecretValue");
if (setValue) {
setValue.addEventListener("show.bs.modal", function (event) {
var row = event.relatedTarget.dataset;
var texts = usageTexts[row.type] || usageTexts.none;
var form = this.querySelector("form");
form.reset(); // no value of another secret stays in the field
form.elements.uuid.value = row.uuid;
document.getElementById("value_usage").textContent = row.usage;
document.getElementById("set_value_label").textContent = texts.value;
document.getElementById("set_value_help").textContent = texts.valueHelp;
// a passphrase's data stays encrypted with the old one
var passphrase = ["volume", "tls", "vtpm"].includes(row.type);
document.getElementById("set_value_note").hidden = !passphrase;
form.elements.value.setAttribute("aria-describedby", passphrase ? "set_value_help set_value_note" : "set_value_help");
});
}

151
static/js/storage_page.js Normal file
View file

@ -0,0 +1,151 @@
// A storage pool's page (storage.html): the volume Clone dialog, a refused
// new volume's dialog, and the ISO upload in chunks.
// the clone dialog: filled for the volume whose button opened it, its
// choices reset; the format rows show while converting
function showCloneRows() {
var convert = $('#clone-convert').prop('checked');
$('#clone-format-row').toggleClass('d-none', !convert);
$('#clone-prealloc-row').toggleClass('d-none', !(convert && $('#clone-format').val() == 'qcow2'));
}
$('#CloneVolume').on('show.bs.modal', function (event) {
var volume = event.relatedTarget.dataset.volume;
$('#clone-image').val(volume);
$('#clone-image-name').text(volume);
$('#clone-name').val('');
$('#clone-convert, #clone-prealloc').prop('checked', false);
$('#clone-format').val('raw');
showCloneRows();
});
$('#clone-convert, #clone-format').on('change', showCloneRows);
// a refused new volume: its dialog opens again on what was entered, at the refused field
var addImage = document.getElementById('AddImage');
if (addImage && addImage.hasAttribute('data-open')) {
addImage.addEventListener('shown.bs.modal', focusFirstError, {once: true});
bootstrap.Modal.getOrCreateInstance(addImage).show();
}
$(document).ready(function() {
var pendingUpload = null;
var uploadInProgress = false;
// the browser sends the file: leaving the page stops the upload
window.addEventListener('beforeunload', function(e) {
if (uploadInProgress) { e.preventDefault(); e.returnValue = ''; }
});
$('#isoUploadForm').on('submit', function(e) {
e.preventDefault();
// the form's messages and token; it is sent to this page
var form = this;
var texts = form.dataset;
if (uploadInProgress) return;
var fileInput = $('#id_file')[0];
var errorMessage = $('#upload-error-message');
if (fileInput.files.length === 0) {
errorMessage.text(texts.noFile).removeClass("d-none");
return;
}
var file = fileInput.files[0];
var fileName = file.name;
if (!/\.iso$/i.test(fileName) || fileName.length <= 4) {
errorMessage.text(texts.notIso).removeClass("d-none");
return;
}
if (file.size === 0) {
errorMessage.text(texts.empty).removeClass("d-none");
return;
}
var chunkSize = 32 * 1024 * 1024; // Fewer SSH connections per upload
var totalChunks = Math.ceil(file.size / chunkSize);
var fileSignature = [fileName, file.size, file.lastModified].join(':');
if (!pendingUpload || pendingUpload.signature !== fileSignature) {
pendingUpload = {
signature: fileSignature,
chunkIndex: 0,
uploadId: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).substring(2, 10))
};
}
var chunkIndex = pendingUpload.chunkIndex;
var uploadId = pendingUpload.uploadId;
var retryCount = 0;
uploadInProgress = true;
var uploadDialog = bootstrap.Modal.getOrCreateInstance(document.getElementById('IsoUpload'));
var uploadStatus = $('#upload-status');
var uploadStatusText = $('#upload-status-text');
var uploadButton = $('#isoUploadForm button[type="submit"]');
errorMessage.addClass("d-none");
uploadStatusText.text(texts.starting);
uploadStatus.removeClass('d-none').addClass('d-flex');
uploadButton.prop('disabled', true);
function uploadChunk() {
if (chunkIndex >= totalChunks) {
return;
}
var start = chunkIndex * chunkSize;
var end = Math.min(start + chunkSize, file.size);
var chunk = file.slice(start, end);
var formData = new FormData();
formData.append('file', chunk, fileName);
formData.append('file_name', fileName);
formData.append('chunk_index', chunkIndex);
formData.append('total_chunks', totalChunks);
formData.append('file_size', file.size);
formData.append('upload_id', uploadId);
formData.append('iso_upload', 'true');
formData.append('csrfmiddlewaretoken', form.elements.csrfmiddlewaretoken.value);
$.ajax({
url: form.action,
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(data) {
if (chunkIndex === 0) {
// the job panel follows the upload from here
window.resourceJobPanel.track(data.job);
uploadStatusText.text(texts.uploading);
uploadDialog.hide();
}
retryCount = 0;
chunkIndex++;
pendingUpload.chunkIndex = chunkIndex;
if (chunkIndex < totalChunks) {
uploadChunk();
} else {
pendingUpload = null;
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
}
},
error: function(jqXHR, textStatus, errorThrown) {
if ((jqXHR.status === 0 || jqXHR.status === 429 || jqXHR.status >= 500) && retryCount < 3) {
retryCount++;
uploadStatusText.text(texts.retrying);
setTimeout(uploadChunk, 1000 * Math.pow(2, retryCount - 1));
return;
}
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
if (jqXHR.status >= 400 && jqXHR.status < 500 && jqXHR.status !== 429) {
pendingUpload = null;
}
var errorText = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : texts.failed;
errorMessage.text(errorText).removeClass("d-none");
uploadDialog.show();
}
});
}
uploadChunk();
});
});