1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00
webvirtcloud/instances/templates/create_instance_w2.html
catborise ab5ed59ea1 feat(instances): a new VM is made on one form in three steps
The create page had three tabs (flavor, custom, template), each with its
own copy of the same create form; every flavor rendered one more copy in
a dialog, and fixes had to be made in each copy. All of them posted the
same form to the same job, differing only in where the disk comes from.

The page is now one form in sections: name and size, disk, networks and
an Advanced section. The disk section offers three sources as radio
cards (a new disk, existing volumes, a copy of a template); only the
chosen source's fields are shown, and the others are disabled, so they
are neither sent nor validated. A template's copy goes into the
template's pool unless another is picked. Flavors are presets that fill
vCPU, memory and disk size and pick a new disk; they are added and
deleted in a dialog outside the form. Every source uses the
per-interface network rows, so a new disk no longer requires a MAC:
libvirt gives an interface without one its address, as it did for the
other sources. A refused form comes back with its source, volumes,
template and networks, and the focus on its first error. Without a
storage pool or a network the page shows why instead of the form, and a
host that cannot be read is a message, not a 500.

The host dialog and the machine page share the form's look: hosts are
rows with their CPU and memory use, the machine page is a card with
Cancel/Next and the XML way is a collapsible section. All three steps
show the same step indicator, done steps linking back.

The create job refuses more than one disk source (the page sends one; a
crafted request could send two, and the job would have used the first).
A field with help text is described by its help and its error.

Frontend review fixes: the presets and the disclosure buttons show a
focus ring for keyboard users; a disclosure spans its card and its
chevron turns while open; a preset says it picks a new disk; numeric
fields bring a numeric keyboard and MAC fields no autocorrection; the
machine page's Next waits for a machine type. Tom Select's control
keeps the text colour of the select it replaces: its own fixed colour
made typed text invisible on dark Bootswatch themes, on every page that
uses it.
2026-10-23 23:26:28 +03:00

624 lines
39 KiB
HTML

{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% load operations %}
{% load django_bootstrap5 %}
{% load form_fields %}
{% load bootstrap_icons %}
{% block body_attrs %} data-error-focus="manual"{% if refused %} data-refused="1"{% endif %}{% endblock %}
{% block title %}{% trans "Create new instance" %}{% endblock %}
{% block style %}
<link href="{% static "css/tom-select.bootstrap5.min.css" %}" rel="stylesheet">
<style>
/* the chosen images and networks are listed below the pickers, not in them */
#image-control + .ts-wrapper .ts-control > .item, #network-control + .ts-wrapper .ts-control > .item { display: none; }
.create-vm { max-width: 56rem; }
.source-option { cursor: pointer; }
/* the chosen card by its border only: the Bootswatch dark themes keep light "subtle" backgrounds */
.source-option:has(input:checked) { border-color: var(--bs-primary) !important; box-shadow: inset 0 0 0 1px var(--bs-primary); }
.source-option:has(input:focus-visible) { outline: 2px solid var(--bs-primary); outline-offset: 2px; }
.row-list { list-style: none; padding-left: 0; }
.row-list li { overflow-wrap: anywhere; }
</style>
{% endblock %}
{% block page_heading %}
{% blocktrans with host=compute.name %}New instance on {{ host }} {% endblocktrans %}
{% endblock page_heading %}
{% block content %}
{% include 'pleasewaitdialog.html' %}
<div class="create-vm mx-auto">
{% include "create_steps.html" with step=3 %}
{% if vm_form %}
<form method="post" id="create-vm-form" aria-label="{% trans "Create virtual machine" %}"
{% if template_restore %}data-template-pool="{{ template_restore.pool }}" data-template="{{ template_restore.template }}"{% endif %}>
{% csrf_token %}{% submission_key %}
{% if submitted_lists %}{{ submitted_lists|json_script:"submitted-lists" }}{% endif %}
{% include "instances/form_errors.html" with form=vm_form %}
<section class="card mb-3" aria-labelledby="size-heading">
<div class="card-body">
<h2 class="h5 card-title" id="size-heading">{% trans "Name and size" %}</h2>
<div class="mb-3">
<label for="vm_name" class="form-label">{% trans "Name" %}</label>
<input id="vm_name" type="text" class="form-control{{ vm_form.name|invalid }}" name="name"
value="{{ vm_form.name.value|default_if_none:"" }}" maxlength="64" required pattern="[a-zA-Z0-9._\-]+"
autocomplete="off" spellcheck="false"{% error_attrs vm_form.name "vm_name" "vm_name_help" %}>
<div id="vm_name_help" class="form-text">{% trans "Letters, digits, dot, dash and underscore." %}</div>
{% field_error vm_form.name "vm_name" %}
</div>
<div class="mb-3">
<div class="d-flex align-items-center mb-1">
<span class="form-label mb-0" id="presets-label">{% trans "Presets" %}</span>
<button type="button" class="btn btn-link btn-sm ms-auto" data-bs-toggle="modal" data-bs-target="#flavors-dialog">
{% trans "Manage flavors" %}
</button>
</div>
{% if flavors %}
<div class="d-flex flex-wrap gap-2" role="group" aria-labelledby="presets-label" aria-describedby="presets_help">
{% for flavor in flavors %}
<button type="button" class="btn btn-sm border text-body focus-ring preset" aria-pressed="false"
data-label="{{ flavor.label }}" data-vcpu="{{ flavor.vcpu }}" data-memory="{{ flavor.memory }}" data-disk="{{ flavor.disk }}">
<strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB
</button>
{% endfor %}
</div>
<div id="presets_help" class="form-text">{% trans "A preset fills vCPUs, memory and a new disk of its size." %}</div>
{% else %}
<p class="form-text mb-0">{% trans "No flavors yet." %}</p>
{% endif %}
<div id="preset-status" class="visually-hidden" role="status"></div>
</div>
<div class="row g-3">
<div class="col-6">
<label for="vm_vcpu" class="form-label">{% trans "vCPUs" %}</label>
<input id="vm_vcpu" type="number" min="1" inputmode="numeric" class="form-control{{ vm_form.vcpu|invalid }}" name="vcpu"
value="{{ vm_form.vcpu.value|default_if_none:"" }}" required{% error_attrs vm_form.vcpu "vm_vcpu" %}>
{% field_error vm_form.vcpu "vm_vcpu" %}
</div>
<div class="col-6">
<label for="vm_memory" class="form-label">{% trans "Memory (MiB)" %}</label>
<input id="vm_memory" type="number" min="1" inputmode="numeric" class="form-control{{ vm_form.memory|invalid }}" name="memory"
value="{{ vm_form.memory.value|default_if_none:"" }}" required{% error_attrs vm_form.memory "vm_memory" %}>
{% field_error vm_form.memory "vm_memory" %}
</div>
{% if firmwares %}
<div class="col-md-6">
<label for="vm_firmware" class="form-label">{% trans "Firmware" %}</label>
<select id="vm_firmware" class="form-select{{ vm_form.firmware|invalid }}" name="firmware"{% error_attrs vm_form.firmware "vm_firmware" %}>
{% for frm in firmwares %}
<option value="{{ frm }}"{% if frm == vm_form.firmware.value %} selected{% endif %}>{{ frm }}</option>
{% endfor %}
</select>
{% field_error vm_form.firmware "vm_firmware" %}
</div>
{% endif %}
{% if dom_caps.cpu_modes %}
<div class="col-md-6">
<label for="vm_vcpu_mode" class="form-label">{% trans "CPU mode" %}</label>
<select id="vm_vcpu_mode" class="form-select{{ vm_form.vcpu_mode|invalid }}" name="vcpu_mode"{% error_attrs vm_form.vcpu_mode "vm_vcpu_mode" %}>
<option value="">{% trans 'no-mode' %}</option>
{% for mode in dom_caps.cpu_modes %}
{% if mode == 'custom' %}
<optgroup label="{% trans "Custom CPU Models" %}">
{% for model in dom_caps.cpu_custom_models %}
<option value="{{ model }}"{% if model == vm_form.vcpu_mode.value %} selected{% endif %}>{{ model }}</option>
{% endfor %}
</optgroup>
{% else %}
<option value="{{ mode }}"{% if mode == vm_form.vcpu_mode.value %} selected{% endif %}>{% trans mode %}</option>
{% endif %}
{% endfor %}
</select>
{% field_error vm_form.vcpu_mode "vm_vcpu_mode" %}
</div>
{% endif %}
</div>
</div>
</section>
<section class="card mb-3" aria-labelledby="disk-heading">
<div class="card-body">
<h2 class="h5 card-title" id="disk-heading">{% trans "Disk" %}</h2>
<fieldset class="mb-3">
<legend class="form-label fs-6">{% trans "Disk source" %}</legend>
<div class="row g-2">
{% for value, title, help in disk_sources %}
<div class="col-md-4">
<label class="source-option border rounded p-2 h-100 d-flex gap-2">
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="disk_source" value="{{ value }}"{% if value == disk_source %} checked{% endif %}>
<span><span class="d-block fw-semibold">{{ title }}</span><span class="d-block small text-body-secondary">{{ help }}</span></span>
</label>
</div>
{% endfor %}
</div>
</fieldset>
<fieldset id="source-new" class="disk-source"{% if disk_source != "new" %} hidden disabled{% endif %}>
<legend class="visually-hidden">{% trans "New disk" %}</legend>
<div class="row g-3">
<div class="col-md-6">
<label for="new_storage" class="form-label">{% trans "Storage pool" %}</label>
<select id="new_storage" class="form-select{{ vm_form.storage|invalid }}" name="storage"{% error_attrs vm_form.storage "new_storage" %}>
{% for storage in storages %}
<option value="{{ storage }}"{% if storage == vm_form.storage.value %} selected{% endif %}>{{ storage }}</option>
{% endfor %}
</select>
{% field_error vm_form.storage "new_storage" %}
</div>
<div class="col-md-6">
<label for="vm_hdd_size" class="form-label">{% trans "Size (GiB)" %}</label>
<input id="vm_hdd_size" type="number" min="1" inputmode="numeric" class="form-control{{ vm_form.hdd_size|invalid }}" name="hdd_size"
value="{{ vm_form.hdd_size.value|default_if_none:"" }}" required{% error_attrs vm_form.hdd_size "vm_hdd_size" %}>
{% field_error vm_form.hdd_size "vm_hdd_size" %}
</div>
<div class="col-12">
<div class="form-check">
<input id="new_meta_prealloc" class="form-check-input" type="checkbox" name="meta_prealloc" value="true"{% if vm_form.meta_prealloc.value %} checked{% endif %}>
<label for="new_meta_prealloc" class="form-check-label">{% trans "Preallocate metadata (qcow2)" %}</label>
</div>
</div>
</div>
</fieldset>
<fieldset id="source-existing" class="disk-source"{% if disk_source != "existing" %} hidden disabled{% endif %}>
<legend class="visually-hidden">{% trans "Existing volumes" %}</legend>
<input id="images" name="images" type="hidden" value="">
<div class="row g-3">
<div class="col-md-6">
<label for="storage-control" class="form-label">{% trans "Storage pool" %}</label>
<select class="form-select" id="storage-control" name="storage-control">
<option value disabled selected>{% trans "Select pool" %}...</option>
{% for storage in storages %}<option value="{{ storage }}">{{ storage }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="image-control" class="form-label">{% trans "Volumes" %}</label>
<select class="form-select{{ vm_form.images|invalid }}" id="image-control" multiple aria-label="{% trans "Volumes" %}"{% error_attrs vm_form.images "image-control" %}></select>
{% field_error vm_form.images "image-control" %}
</div>
</div>
<ul id="img-list" class="row-list mt-2 mb-0" aria-label="{% trans "Chosen volumes" %}"></ul>
<template id="disk-row">
<li class="d-flex flex-wrap align-items-center gap-2 py-1">
<span class="row-label font-monospace"></span>
<select class="form-select form-select-sm w-auto disk-device" aria-label="{% trans "Device" %}">
{% for dev in disk_devices %}<option value="{{ dev }}">{% trans dev %}</option>{% endfor %}
</select>
<select class="form-select form-select-sm w-auto disk-bus" aria-label="{% trans "Bus" %}">
{% for bus in disk_buses %}<option value="{{ bus }}"{% if bus == default_bus %} selected{% endif %}>{% trans bus %}</option>{% endfor %}
</select>
<span class="row-item"></span>
<button type="button" class="btn btn-sm btn-link ms-auto row-remove" aria-label="{% trans "Remove" %}">{% bs_icon 'x-lg' %}</button>
</li>
</template>
</fieldset>
<fieldset id="source-template" class="disk-source"{% if disk_source != "template" %} hidden disabled{% endif %}>
<legend class="visually-hidden">{% trans "Clone from template" %}</legend>
<div class="row g-3">
<div class="col-md-6">
<label for="template_pool" class="form-label">{% trans "Template pool" %}</label>
<select class="form-select" id="template_pool" name="template_pool">
<option value disabled selected>{% trans "Select pool" %}...</option>
{% for storage in storages %}<option value="{{ storage }}">{{ storage }}</option>{% endfor %}
</select>
</div>
<div class="col-md-6">
<label for="template" class="form-label">{% trans "Template volume" %}</label>
<select id="template" class="form-select{{ vm_form.template|invalid }}" name="template" required{% error_attrs vm_form.template "template" %}></select>
{% field_error vm_form.template "template" %}
</div>
<div class="col-md-6">
<label for="template_storage" class="form-label">{% trans "Copy into pool" %}</label>
<select id="template_storage" class="form-select{{ vm_form.storage|invalid }}" name="storage"{% error_attrs vm_form.storage "template_storage" %}>
{% for storage in storages %}
<option value="{{ storage }}"{% if storage == vm_form.storage.value %} selected{% endif %}>{{ storage }}</option>
{% endfor %}
</select>
{% field_error vm_form.storage "template_storage" %}
</div>
<div class="col-md-6 align-self-end">
<div class="form-check">
<input id="template_meta_prealloc" class="form-check-input" type="checkbox" name="meta_prealloc" value="true"{% if vm_form.meta_prealloc.value %} checked{% endif %}>
<label for="template_meta_prealloc" class="form-check-label">{% trans "Preallocate metadata (qcow2)" %}</label>
</div>
</div>
</div>
</fieldset>
</div>
</section>
<section class="card mb-3" aria-labelledby="network-heading">
<div class="card-body">
<h2 class="h5 card-title" id="network-heading">{% trans "Networks" %}</h2>
<label for="network-control" class="form-label">{% trans "Add networks" %}</label>
<select class="form-select{{ vm_form.networks|invalid }}" id="network-control" multiple aria-label="{% trans "Networks" %}"{% error_attrs vm_form.networks "network-control" %}>
{% for network in networks %}<option value="{{ network }}">{{ network }}</option>{% endfor %}
</select>
{% field_error vm_form.networks "network-control" %}
<input id="networks" type="hidden" name="networks" value="">
<input id="vm_mac" type="hidden" name="mac" value="">
<ul id="net-list" class="row-list mt-2 mb-0" aria-label="{% trans "Network interfaces" %}"></ul>
<template id="network-row">
<li class="d-flex flex-wrap align-items-center gap-2 py-1">
<span class="row-label font-monospace"></span>
<span class="row-item"></span>
<input type="text" class="form-control form-control-sm w-auto font-monospace" maxlength="17" size="17" required
autocomplete="off" autocapitalize="none" spellcheck="false"
pattern="[a-fA-F0-9:]+" aria-label="{% trans "MAC address" %}">
<button type="button" class="btn btn-sm btn-link ms-auto row-remove" aria-label="{% trans "Remove" %}">{% bs_icon 'x-lg' %}</button>
</li>
</template>
{% field_error vm_form.mac "vm_mac" %}
</div>
</section>
<section class="card mb-3" aria-labelledby="advanced-heading">
<div class="card-body">
<h2 class="h5 card-title mb-0" id="advanced-heading">
<button class="btn p-0 border-0 text-body fw-medium w-100 text-start focus-ring disclosure" type="button" data-bs-toggle="collapse" data-bs-target="#advanced"
aria-expanded="false" aria-controls="advanced">
{% bs_icon 'chevron-down' %} {% trans "Advanced" %}
</button>
</h2>
<div class="collapse" id="advanced">
<div class="row g-3 pt-3">
<div class="col-md-6">
<label for="vm_cache_mode" class="form-label">{% trans "Disk cache mode" %}</label>
<select id="vm_cache_mode" class="form-select{{ vm_form.cache_mode|invalid }}" name="cache_mode"{% error_attrs vm_form.cache_mode "vm_cache_mode" %}>
{% for mode, name in cache_modes %}
<option value="{{ mode }}"{% if mode == vm_form.cache_mode.value %} selected{% endif %}>{% trans name %}</option>
{% endfor %}
</select>
{% field_error vm_form.cache_mode "vm_cache_mode" %}
</div>
<div class="col-md-6">
<label for="vm_net_model" class="form-label">{% trans "NIC type" %}</label>
<select id="vm_net_model" class="form-select{{ vm_form.net_model|invalid }}" name="net_model"{% error_attrs vm_form.net_model "vm_net_model" %}>
{% for model in net_models_host %}
<option value="{{ model }}"{% if model == vm_form.net_model.value %} selected{% endif %}>{{ model }}</option>
{% endfor %}
</select>
{% field_error vm_form.net_model "vm_net_model" %}
</div>
<div class="col-md-6">
<label for="vm_nwfilter" class="form-label">{% trans "Network filter" %}</label>
<select id="vm_nwfilter" class="form-select{{ vm_form.nwfilter|invalid }}" name="nwfilter"{% error_attrs vm_form.nwfilter "vm_nwfilter" %}>
<option value="">{% trans "None" %}</option>
{% for nwfilter in nwfilters %}
<option value="{{ nwfilter }}"{% if nwfilter == vm_form.nwfilter.value %} selected{% endif %}>{{ nwfilter }}</option>
{% endfor %}
</select>
{% field_error vm_form.nwfilter "vm_nwfilter" %}
</div>
<div class="col-md-6">
<label for="vm_video" class="form-label">{% trans "Video" %}</label>
<select id="vm_video" class="form-select{{ vm_form.video|invalid }}" name="video"{% error_attrs vm_form.video "vm_video" %}>
{% if not videos %}<option value="vga">vga</option><option value="cirrus">cirrus</option>{% endif %}
{% for video in videos %}
<option value="{{ video }}"{% if video == vm_form.video.value %} selected{% endif %}>{{ video }}</option>
{% endfor %}
</select>
{% field_error vm_form.video "vm_video" %}
</div>
<div class="col-md-6">
<label for="vm_listener_addr" class="form-label">{% trans "Console access" %}</label>
<select id="vm_listener_addr" class="form-select{{ vm_form.listener_addr|invalid }}" name="listener_addr"{% error_attrs vm_form.listener_addr "vm_listener_addr" %}>
{% for addr, label in listener_addr %}
<option value="{{ addr }}"{% if addr == vm_form.listener_addr.value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% field_error vm_form.listener_addr "vm_listener_addr" %}
</div>
<div class="col-md-6">
<label for="vm_add_cdrom" class="form-label">{% trans "CD-ROM" %}</label>
<select id="vm_add_cdrom" class="form-select{{ vm_form.add_cdrom|invalid }}" name="add_cdrom"{% error_attrs vm_form.add_cdrom "vm_add_cdrom" %}>
<option value="None"{% if vm_form.add_cdrom.value == "None" %} selected{% endif %}>{% trans "None" %}</option>
{% for bus in cdrom_buses %}
<option value="{{ bus }}"{% if bus == vm_form.add_cdrom.value %} selected{% endif %}>{% trans bus %}</option>
{% endfor %}
</select>
{% field_error vm_form.add_cdrom "vm_add_cdrom" %}
</div>
<div class="col-md-6">
<label for="vm_add_input" class="form-label">{% trans "Input device" %}</label>
<select id="vm_add_input" class="form-select{{ vm_form.add_input|invalid }}" name="add_input"{% error_attrs vm_form.add_input "vm_add_input" %}>
<option value="None"{% if vm_form.add_input.value == "None" %} selected{% endif %}>{% trans "None" %}</option>
{% for bus in input_device_buses %}
<option value="{{ bus }}"{% if bus == vm_form.add_input.value %} selected{% endif %}>{% trans bus %}</option>
{% endfor %}
</select>
{% field_error vm_form.add_input "vm_add_input" %}
</div>
<div class="col-md-6 d-flex flex-column justify-content-end">
<div class="form-check">
<input id="vm_qemu_ga" class="form-check-input" type="checkbox" name="qemu_ga" value="true"{% if vm_form.qemu_ga.value %} checked{% endif %}>
<label for="vm_qemu_ga" class="form-check-label">{% trans "Guest agent" %}</label>
</div>
{% if virtio_support %}
<div class="form-check">
<input id="vm_virtio" class="form-check-input" type="checkbox" name="virtio" value="{{ virtio_support }}"{% if vm_form.virtio.value %} checked{% endif %}>
<label for="vm_virtio" class="form-check-label">{% trans "VirtIO devices" %}</label>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</section>
<div class="d-flex flex-wrap gap-2 justify-content-end mb-4">
<a class="btn btn-link" href="{% url 'instances' compute.id %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary" name="create" value="1" formnovalidate>{% trans "Create VM" %}</button>
</div>
</form>
{% endif %}
</div>
<div class="modal fade" id="flavors-dialog" tabindex="-1" aria-labelledby="flavors-dialog-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title h5" id="flavors-dialog-title">{% trans "Flavors" %}</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
{% if flavors %}
<ul class="list-group mb-3">
{% for flavor in flavors %}
<li class="list-group-item d-flex flex-wrap align-items-center gap-2">
<span><strong>{{ flavor.label }}</strong> · {{ flavor.vcpu }} vCPU · {{ flavor.memory }} MiB · {{ flavor.disk }} GiB</span>
<form class="ms-auto" action="{% url 'instances:flavor_delete' flavor.id %}" method="post">{% csrf_token %}
<button type="submit" class="btn btn-sm btn-secondary"
data-confirm="{% blocktrans with name=flavor.label %}Delete flavor {{ name }}? Only the size template goes; VMs made from it keep their size.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% trans "Delete" %}</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<h3 class="h6">{% trans "Add flavor" %}</h3>
<form method="post" action="{% url 'instances:flavor_create' %}">{% csrf_token %}
{% bootstrap_form flavor_form %}
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="{% static 'js/tom-select.complete.min.js' %}"></script>
<script>
const COMPUTE = {{ compute_id }};
let images_control, networks_control;
// the volume and network pickers: each chosen item is a row below its picker
function make_pickers() {
images_control = new TomSelect('#image-control', {
plugins: ['checkbox_options'],
placeholder: '{{ _("Add volume")|escapejs }}',
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#img-list", value)) add_disk_row(value); },
onItemRemove: function (value) { remove_row("#img-list", value); },
});
networks_control = new TomSelect('#network-control', {
plugins: ['checkbox_options'],
placeholder: '{{ _("Add network")|escapejs }}',
hidePlaceholder: false,
onItemAdd: function (value) { if (!row_for("#net-list", value)) add_network_row(value, ""); },
onItemRemove: function (value) { remove_row("#net-list", value); },
});
}
$(document).ready(function () {
if (!document.querySelector("#create-vm-form")) return;
make_pickers();
document.querySelectorAll('input[name="disk_source"]').forEach(function (radio) {
radio.addEventListener("change", function () {
show_source(this.value);
if (this.value !== "new") clear_presets(); // its size is not sent
});
});
show_source(document.querySelector('input[name="disk_source"]:checked').value);
document.querySelectorAll(".preset").forEach(function (button) {
button.addEventListener("click", function () { apply_preset(button); });
});
["#vm_vcpu", "#vm_memory", "#vm_hdd_size"].forEach(function (sel) {
document.querySelector(sel).addEventListener("input", clear_presets);
});
document.querySelector("#storage-control").addEventListener("change", function () { load_volumes(this.value); });
document.querySelector("#template_pool").addEventListener("change", function () { template_pool_chosen(this.value); });
if (document.body.dataset.refused) restore_submitted();
else add_first_network();
});
// a new VM starts with one interface: on "default" if the host has it
function add_first_network() {
const names = Object.keys(networks_control.options);
if (names.length) networks_control.addItem(names.includes("default") ? "default" : names[0]);
}
// Only the chosen disk source's fields are shown and sent: the others
// are disabled, so the browser neither submits nor validates them.
function show_source(source) {
document.querySelectorAll(".disk-source").forEach(function (fieldset) {
const on = fieldset.id === "source-" + source;
fieldset.hidden = !on;
fieldset.disabled = !on;
});
images_control[source === "existing" ? "enable" : "disable"]();
}
// A preset fills the size fields and picks a new disk; editing one of
// them afterwards leaves no preset marked as applied.
function apply_preset(button) {
document.querySelector("#vm_vcpu").value = button.dataset.vcpu;
document.querySelector("#vm_memory").value = button.dataset.memory;
document.querySelector("#vm_hdd_size").value = button.dataset.disk;
document.querySelector('input[name="disk_source"][value="new"]').checked = true;
show_source("new");
clear_presets();
button.setAttribute("aria-pressed", "true");
button.classList.replace("text-body", "btn-primary");
document.querySelector("#preset-status").textContent =
`${button.dataset.label}: ${button.dataset.vcpu} vCPU, ${button.dataset.memory} MiB, new ${button.dataset.disk} GiB disk`;
}
function clear_presets() {
document.querySelectorAll(".preset").forEach(function (b) {
b.setAttribute("aria-pressed", "false");
b.classList.replace("btn-primary", "text-body");
});
}
// Disk and network rows: one per chosen volume or network, cloned from
// the page's row templates; the fields the server reads are derived
// from them in sync_lists.
function new_row(list, template, item) {
const row = document.getElementById(template).content.firstElementChild.cloneNode(true);
row.dataset.item = item;
row.querySelector(".row-item").textContent = item;
document.querySelector(list).append(row);
return row;
}
function add_disk_row(volume) {
const row = new_row("#img-list", "disk-row", volume);
const bus = row.querySelector(".disk-bus");
row.querySelector(".disk-device").addEventListener("change", function () {
load_buses(bus, this.value);
});
row.querySelector(".row-remove").addEventListener("click", function () { images_control.removeItem(volume); });
sync_lists();
return row;
}
// a network row without a MAC gets a random one
function add_network_row(network, mac) {
const row = new_row("#net-list", "network-row", network);
const input = row.querySelector("input");
input.value = mac;
input.addEventListener("input", sync_lists);
row.querySelector(".row-remove").addEventListener("click", function () { networks_control.removeItem(network); });
if (!mac) random_mac(input);
sync_lists();
return row;
}
function row_for(list, item) {
return [...document.querySelector(list).children].find(row => row.dataset.item === item);
}
function remove_row(list, item) {
row_for(list, item)?.remove();
sync_lists();
}
// volumes, networks and their MACs in row order; device<i> and bus<i>
// belong to the i-th volume
function sync_lists() {
const disks = [...document.querySelector("#img-list").children];
const nics = [...document.querySelector("#net-list").children];
disks.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "hdd" + i;
row.querySelector(".disk-device").name = "device" + i;
row.querySelector(".disk-bus").name = "bus" + i;
});
nics.forEach(function (row, i) {
row.querySelector(".row-label").textContent = "eth" + i;
});
document.querySelector("#images").value = disks.map(row => row.dataset.item).join(",");
document.querySelector("#networks").value = nics.map(row => row.dataset.item).join(",");
document.querySelector("#vm_mac").value = nics.map(row => row.querySelector("input").value).join(",");
}
// a MAC typed while the request was out stays
function random_mac(input) {
$.getJSON("{% url 'instances:random_mac_address' %}", function (data) {
if (!input.value) input.value = data['mac'];
sync_lists();
});
}
function volumes_url(pool) {
return "/computes/" + COMPUTE + "/storage/" + encodeURIComponent(pool) + "/volumes";
}
function load_volumes(pool) {
return $.getJSON(volumes_url(pool), function (data) {
images_control.clearOptions();
data['vols'].forEach(item => images_control.addOption({value: item, text: item}));
});
}
// the copy goes into the template's pool unless the user picks another
function template_pool_chosen(pool) {
select_value(document.querySelector("#template_storage"), pool);
return load_templates(pool);
}
function load_templates(pool) {
return $.getJSON(volumes_url(pool), function (data) {
document.querySelector("#template").replaceChildren(...data['vols'].map(item => new Option(item, item)));
});
}
function load_buses(bus_select, disk_type) {
const url = "/computes/" + COMPUTE + "/archs/{{ arch|escapejs }}/machines/{{ machine|escapejs }}/disks/" + disk_type + "/buses";
return $.getJSON(url, function (data) {
bus_select.replaceChildren(...data['bus'].map(item => new Option(item, item)));
});
}
// a value the loaded list no longer has is kept, not swapped for the first
function select_value(select, value) {
if (![...select.options].some(option => option.value === value)) select.add(new Option(value, value));
select.value = value;
}
// A refused form comes back as it was sent: its networks, volumes and
// template loaded first, any Advanced field with an error shown, then
// the focus on the first error.
function restore_submitted() {
const form = document.querySelector("#create-vm-form");
const element = document.getElementById("submitted-lists");
const lists = element ? JSON.parse(element.textContent) : {networks: [], disks: [], pool: ""};
lists.networks.forEach(function (nic) {
networks_control.addOption({value: nic.name, text: nic.name});
add_network_row(nic.name, nic.mac);
networks_control.addItem(nic.name, true);
});
const loads = [];
if (lists.pool) {
select_value(document.querySelector("#storage-control"), lists.pool);
loads.push(load_volumes(lists.pool).then(function () {
return $.when(...lists.disks.map(function (disk) {
images_control.addOption({value: disk.volume, text: disk.volume});
const row = add_disk_row(disk.volume);
images_control.addItem(disk.volume, true);
const bus = row.querySelector(".disk-bus");
select_value(row.querySelector(".disk-device"), disk.device);
return load_buses(bus, disk.device).then(() => select_value(bus, disk.bus));
}));
}));
}
if (form.dataset.templatePool) {
select_value(document.querySelector("#template_pool"), form.dataset.templatePool);
loads.push(load_templates(form.dataset.templatePool).then(function () {
select_value(document.querySelector("#template"), form.dataset.template);
}));
}
const advanced = document.querySelector("#advanced");
if (advanced.querySelector(".is-invalid")) bootstrap.Collapse.getOrCreateInstance(advanced, {toggle: false}).show();
$.when(...loads).then(focusFirstError);
}
</script>
{% endblock %}