implement SSR for instance config page

This commit is contained in:
2025-04-22 17:09:27 +00:00
parent 099f9c4e42
commit 8530b50f9a
25 changed files with 1062 additions and 739 deletions

View File

@@ -35,6 +35,9 @@ func Run() {
router.GET("/index/instances", routes.HandleGETInstancesFragment)
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/tdewolff/minify"
"github.com/tdewolff/minify/css"
"github.com/tdewolff/minify/html"
"github.com/tdewolff/minify/js"
)
type MimeType struct {
@@ -36,7 +37,7 @@ var MimeTypes = map[string]MimeType{
},
"js": {
Type: "application/javascript",
Minifier: nil,
Minifier: js.Minify,
},
"wasm": {
Type: "application/wasm",

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
@@ -101,6 +102,20 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
}
return s
},
"Map": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
}
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
engine.SetHTMLTemplate(tmpl)

View File

@@ -1,20 +1,347 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
"slices"
"sort"
fabric "proxmoxaas-fabric/app"
"github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2"
)
type VMPath struct {
Node string
Type string
VMID string
}
// imported types from fabric
type InstanceConfig struct {
Type fabric.InstanceType `json:"type"`
Name string `json:"name"`
Proctype string `json:"cpu"`
Cores uint64 `json:"cores"`
Memory uint64 `json:"memory"`
Swap uint64 `json:"swap"`
Volumes map[string]*fabric.Volume `json:"volumes"`
Nets map[string]*fabric.Net `json:"nets"`
Devices map[string]*fabric.Device `json:"devices"`
Boot fabric.BootOrder `json:"boot"`
// overrides
ProctypeSelect common.Select
}
func HandleGETConfig(c *gin.Context) {
_, err := common.GetAuth(c)
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
config.ProctypeSelect = common.Select{}
if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true
}
}
c.HTML(http.StatusOK, "html/config.html", gin.H{
"global": common.Global,
"page": "config",
"config": config,
})
} else {
c.Redirect(http.StatusFound, "/login.html")
c.Redirect(http.StatusFound, "/login")
}
}
func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
Body: map[string]any{},
}
res, code, err := common.RequestGetAPI(path, ctx)
if err != nil {
return config, err
}
if code != 200 {
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(ctx.Body, &config)
if err != nil {
return config, err
}
config.Memory = config.Memory / (1024 * 1024) // memory in MiB
config.Swap = config.Swap / (1024 * 1024) // swap in MiB
return config, nil
}
type GlobalConfig struct {
CPU struct {
Whitelist bool
}
}
type UserConfig struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
}
}
type CPUConfig struct {
Name string
}
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
cputypes := common.Select{
ID: "proctype",
}
// get global resource config
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
Body: map[string]any{},
}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
global := GlobalConfig{}
err = mapstructure.Decode(ctx.Body["resources"], &global)
if err != nil {
return cputypes, err
}
// get user resource config
ctx.Body = map[string]any{}
path = "/user/config/resources"
res, code, err = common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
user := UserConfig{}
err = mapstructure.Decode(ctx.Body, &user)
if err != nil {
return cputypes, err
}
// use node specific rules if present, otherwise use global rules
var userCPU []CPUConfig
if _, ok := user.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node]
} else {
userCPU = user.CPU.Global
}
if global.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
} else { // cpu is a blacklist
// get the supported cpu types from the node
ctx.Body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []CPUConfig
}{}
err = mapstructure.Decode(ctx.Body, supported)
if err != nil {
return cputypes, err
}
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
return c.Name == cpu.Name
})
if !contains {
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
}
}
// sort the options by lexicographical order
sort.Slice(cputypes.Options, func(i, j int) bool {
return cputypes.Options[i].Display < cputypes.Options[j].Display
})
return cputypes, nil
}

View File

@@ -0,0 +1 @@
{{template "boot" .config.Boot}}

View File

@@ -0,0 +1 @@
{{template "devices" .config.Devices}}

View File

@@ -0,0 +1 @@
{{template "nets" .config.Nets}}

View File

@@ -0,0 +1 @@
{{template "volumes" .config.Volumes}}

View File

@@ -14,6 +14,8 @@
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
overflow: hidden;
white-space: nowrap;
}
</style>
</head>
@@ -23,29 +25,44 @@
</header>
<main>
<section>
<h2 id="name"><a href="index.html">Instances</a> / %{vmname}</h2>
<h2 id="name"><a href="index">Instances</a> / {{.config.Name}}</h2>
<form>
<fieldset class="w3-card w3-padding">
<legend>Resources</legend>
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;"></div>
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
{{if eq .config.Type "VM"}}
{{template "proctype-input" .config.ProctypeSelect}}
{{end}}
{{template "cores-input" .config.Cores}}
{{template "memory-input" .config.Memory}}
{{if eq .config.Type "CT"}}
{{template "swap-input" .config.Swap}}
{{end}}
</div>
</fieldset>
<fieldset class="w3-card w3-padding">
<legend>Disks</legend>
<div class="input-grid" id="disks" style="grid-template-columns: auto auto 1fr auto;"></div>
<legend>Volumes</legend>
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
{{template "volumes" .config.Volumes}}
</div>
<div class="w3-container w3-center">
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
<span class="large" style="margin: 0;">Add Disk</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg>
</button>
<button type="button" id="cd-add" class="w3-button none" aria-label="Add New CD">
{{if eq .config.Type "VM"}}
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD">
<span class="large" style="margin: 0;">Mount CD</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
</button>
{{end}}
</div>
</fieldset>
<fieldset class="w3-card w3-padding">
<legend>Network Interfaces</legend>
<div class="input-grid" id="networks" style="grid-template-columns: auto auto 1fr auto;"></div>
<div class="input-grid" id="networks" style="grid-template-columns: auto auto 1fr auto;">
{{template "nets" .config.Nets}}
</div>
<div class="w3-container w3-center">
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
<span class="large" style="margin: 0;">Add Network</span>
@@ -53,9 +70,12 @@
</button>
</div>
</fieldset>
<fieldset class="w3-card w3-padding none" id="devices-card">
{{if eq .config.Type "VM"}}
<fieldset class="w3-card w3-padding">
<legend>PCIe Devices</legend>
<div class="input-grid" id="devices" style="grid-template-columns: auto auto 1fr auto;"></div>
<div class="input-grid" id="devices" style="grid-template-columns: auto auto 1fr auto;">
{{template "devices" .config.Devices}}
</div>
<div class="w3-container w3-center">
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
<span class="large" style="margin: 0;">Add Device</span>
@@ -63,12 +83,13 @@
</button>
</div>
</fieldset>
<fieldset class="w3-card w3-padding none" id="boot-card">
<fieldset class="w3-card w3-padding">
<legend>Boot Order</legend>
<draggable-container id="enabled"></draggable-container>
<hr style="padding: 0; margin: 0;">
<draggable-container id="disabled"></draggable-container>
<div id="boot-order">
{{template "boot" .config.Boot}}
</div>
</fieldset>
{{end}}
<div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button>
</div>

View File

@@ -0,0 +1 @@
../../common/delete-active.svg

View File

@@ -0,0 +1 @@
../../common/delete-inactive.svg

View File

@@ -1 +0,0 @@
../../common/add.svg

View File

@@ -1 +0,0 @@
../../common/config.svg

View File

@@ -0,0 +1 @@
../../common/delete-active.svg

View File

@@ -0,0 +1 @@
../../common/delete-inactive.svg

View File

@@ -1 +0,0 @@
../../common/add.svg

View File

@@ -1 +0,0 @@
../../common/config.svg

View File

@@ -1,45 +1,13 @@
import { requestPVE, requestAPI, goToPage, getURIData, resourcesConfig, bootConfig, setAppearance, setSVGSrc, setSVGAlt, mergeDeep, addResourceLine } from "./utils.js";
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setSVGSrc, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); // do the dumb thing where the disk config refreshes every second
const diskMetaData = resourcesConfig.disk;
const networkMetaData = resourcesConfig.network;
const pcieMetaData = resourcesConfig.pci;
const bootMetaData = bootConfig;
window.addEventListener("DOMContentLoaded", init);
let node;
let type;
let vmid;
let config;
const resourceInputTypes = { // input types for each resource for config page
cpu: {
element: "select",
attributes: {}
},
cores: {
element: "input",
attributes: {
type: "number"
}
},
memory: {
element: "input",
attributes: {
type: "number"
}
},
swap: {
element: "input",
attributes: {
type: "number"
}
}
};
const resourcesConfigPage = mergeDeep({}, resourcesConfig, resourceInputTypes);
async function init () {
setAppearance();
@@ -53,179 +21,65 @@ async function init () {
const name = type === "qemu" ? "name" : "hostname";
document.querySelector("#name").innerHTML = document.querySelector("#name").innerHTML.replace("%{vmname}", config.data[name]);
populateResources();
populateDisk();
populateNetworks();
populateDevices();
populateBoot();
initVolumes();
initNetworks();
initDevices();
document.querySelector("#exit").addEventListener("click", handleFormExit);
}
function getOrdered (keys) {
const orderedKeys = Object.keys(keys).sort((a, b) => {
return parseInt(a) - parseInt(b);
}); // ordered integer list
return orderedKeys;
}
async function getConfig () {
config = await requestPVE(`/nodes/${node}/${type}/${vmid}/config`, "GET");
}
async function populateResources () {
const field = document.querySelector("#resources");
if (type === "qemu") {
const global = (await requestAPI("/global/config/resources")).resources;
const user = await requestAPI("/user/config/resources");
let options = [];
const globalCPU = global.cpu;
const userCPU = node in user.cpu.nodes ? user.cpu.nodes[node] : user.cpu.global;
if (globalCPU.whitelist) {
userCPU.forEach((userType) => {
options.push(userType.name);
});
options = options.sort((a, b) => {
return a.localeCompare(b);
});
}
else {
const supported = await requestPVE(`/nodes/${node}/capabilities/qemu/cpu`);
supported.data.forEach((supportedType) => {
if (!userCPU.some((userType) => supportedType.name === userType.name)) {
options.push(supportedType.name);
}
});
options = options.sort((a, b) => {
return a.localeCompare(b);
});
}
addResourceLine(resourcesConfigPage.cpu, field, { value: config.data.cpu, options });
}
addResourceLine(resourcesConfigPage.cores, field, { value: config.data.cores, min: 1, max: 8192 });
addResourceLine(resourcesConfigPage.memory, field, { value: config.data.memory, min: 16, step: 1 });
if (type === "lxc") {
addResourceLine(resourcesConfigPage.swap, field, { value: config.data.swap, min: 0, step: 1 });
}
}
class VolumeAction extends HTMLElement {
shadowRoot = null;
async function populateDisk () {
document.querySelector("#disks").innerHTML = "";
for (let i = 0; i < diskMetaData[type].prefixOrder.length; i++) {
const prefix = diskMetaData[type].prefixOrder[i];
const busName = diskMetaData[type][prefix].name;
const disks = {};
Object.keys(config.data).forEach((element) => {
if (element.startsWith(prefix) && !isNaN(element.replace(prefix, ""))) {
disks[element.replace(prefix, "")] = config.data[element];
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
if (this.dataset.type === "move") {
this.addEventListener("click", this.handleDiskMove);
}
else if (this.dataset.type === "resize") {
this.addEventListener("click", this.handleDiskResize);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleDiskDelete);
}
else if (this.dataset.type === "attach") {
this.addEventListener("click", this.handleDiskAttach);
}
else if (this.dataset.type === "detach") {
this.addEventListener("click", this.handleDiskDetach);
}
});
const orderedKeys = getOrdered(disks);
orderedKeys.forEach((element) => {
const disk = disks[element];
addDiskLine("disks", prefix, busName, element, disk);
});
}
document.querySelector("#disk-add").addEventListener("click", handleDiskAdd);
if (type === "qemu") {
document.querySelector("#cd-add").classList.remove("none");
document.querySelector("#cd-add").addEventListener("click", handleCDAdd);
async setStatusLoading () {
const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`);
setSVGSrc(svg, "images/status/loading.svg");
}
}
function addDiskLine (fieldset, busPrefix, busName, device, diskDetails) {
const field = document.querySelector(`#${fieldset}`);
const diskName = `${busName} ${device}`;
const diskID = `${busPrefix}${device}`;
// Set the disk icon, either drive.svg or disk.svg
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
setSVGSrc(icon, diskMetaData[type][busPrefix].icon);
setSVGAlt(icon, diskName);
icon.dataset.disk = diskID;
field.appendChild(icon);
// Add a label for the disk bus and device number
const diskLabel = document.createElement("p");
diskLabel.innerText = diskName;
diskLabel.dataset.disk = diskID;
field.appendChild(diskLabel);
// Add text of the disk configuration
const diskDesc = document.createElement("p");
diskDesc.innerText = diskDetails;
diskDesc.dataset.disk = diskID;
diskDesc.style.overflowX = "hidden";
diskDesc.style.whiteSpace = "nowrap";
field.appendChild(diskDesc);
const actionDiv = document.createElement("div");
diskMetaData.actionBarOrder.forEach((element) => {
const action = document.createElementNS("http://www.w3.org/2000/svg", "svg");
if (element === "detach_attach" && diskMetaData[type][busPrefix].actions.includes("attach")) { // attach
setSVGSrc(action, diskMetaData.actions.attach.src);
setSVGAlt(action, diskMetaData.actions.attach.title);
action.title = "Attach Disk";
action.addEventListener("click", handleDiskAttach);
action.classList.add("clickable");
}
else if (element === "detach_attach" && diskMetaData[type][busPrefix].actions.includes("detach")) { // detach
setSVGSrc(action, diskMetaData.actions.detach.src);
setSVGAlt(action, diskMetaData.actions.detach.title);
action.addEventListener("click", handleDiskDetach);
action.classList.add("clickable");
}
else if (element === "delete") {
const active = diskMetaData[type][busPrefix].actions.includes(element) ? "active" : "inactive"; // resize
setSVGSrc(action, `images/actions/delete-${active}.svg`);
setSVGAlt(action, "Delete Disk");
if (active === "active") {
action.addEventListener("click", handleDiskDelete);
action.classList.add("clickable");
}
}
else {
const active = diskMetaData[type][busPrefix].actions.includes(element) ? "active" : "inactive"; // resize
setSVGSrc(action, `images/actions/disk/${element}-${active}.svg`);
if (active === "active") {
setSVGAlt(action, `${element.charAt(0).toUpperCase()}${element.slice(1)} Disk`);
if (element === "move") {
action.addEventListener("click", handleDiskMove);
}
else if (element === "resize") {
action.addEventListener("click", handleDiskResize);
}
action.classList.add("clickable");
}
}
action.dataset.disk = diskID;
actionDiv.append(action);
});
field.appendChild(actionDiv);
}
async function handleDiskDetach () {
const disk = this.dataset.disk;
async handleDiskDetach () {
const disk = this.dataset.volume;
const header = `Detach ${disk}`;
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg");
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
if (result.status !== 200) {
alert(`Attempted to detach ${disk} but got: ${result.error}`);
}
await getConfig();
populateDisk();
deleteBootLine(`boot-${disk}`);
refreshVolumes();
refreshBoot();
}
});
}
}
async function handleDiskAttach () {
const header = `Attach ${this.dataset.disk}`;
async handleDiskAttach () {
const header = `Attach ${this.dataset.volume}`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label>
@@ -236,25 +90,24 @@ async function handleDiskAttach () {
dialog(header, body, async (result, form) => {
if (result === "confirm") {
const device = form.get("device");
setSVGSrc(document.querySelector(`svg[data-disk="${this.dataset.disk}"]`), "images/status/loading.svg");
this.setStatusLoading();
const body = {
source: this.dataset.disk.replace("unused", "")
source: this.dataset.volume.replace("unused", "")
};
const prefix = type === "qemu" ? "scsi" : "mp";
const disk = `${prefix}${device}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to attach ${this.dataset.disk} to ${disk} but got: ${result.error}`);
alert(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`);
}
await getConfig();
populateDisk();
addBootLine("disabled", { id: disk, prefix, value: disk, detail: config.data[disk] });
refreshVolumes();
refreshBoot();
}
});
}
}
async function handleDiskResize () {
const header = `Resize ${this.dataset.disk}`;
async handleDiskResize () {
const header = `Resize ${this.dataset.volume}`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="size-increment">Size Increment (GiB)</label>
@@ -264,8 +117,8 @@ async function handleDiskResize () {
dialog(header, body, async (result, form) => {
if (result === "confirm") {
const disk = this.dataset.disk;
setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg");
const disk = this.dataset.volume;
this.setStatusLoading();
const body = {
size: form.get("size-increment")
};
@@ -273,19 +126,17 @@ async function handleDiskResize () {
if (result.status !== 200) {
alert(`Attempted to resize ${disk} but got: ${result.error}`);
}
await getConfig();
populateDisk();
const prefix = bootMetaData.eligiblePrefixes.find((pref) => disk.startsWith(pref));
updateBootLine(`boot-${disk}`, { id: disk, prefix, value: disk, detail: config.data[disk] });
refreshVolumes();
refreshBoot();
}
});
}
}
async function handleDiskMove () {
async handleDiskMove () {
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const header = `Move ${this.dataset.disk}`;
const header = `Move ${this.dataset.volume}`;
let options = "";
storage.data.forEach((element) => {
@@ -304,8 +155,8 @@ async function handleDiskMove () {
dialog(header, body, async (result, form) => {
if (result === "confirm") {
const disk = this.dataset.disk;
setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg");
const disk = this.dataset.volume;
this.setStatusLoading();
const body = {
storage: form.get("storage-select"),
delete: form.get("delete-check") === "on" ? "1" : "0"
@@ -314,30 +165,51 @@ async function handleDiskMove () {
if (result.status !== 200) {
alert(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
}
await getConfig();
populateDisk();
const prefix = bootMetaData.eligiblePrefixes.find((pref) => disk.startsWith(pref));
updateBootLine(`boot-${disk}`, { id: disk, prefix, value: config.data[disk] });
refreshVolumes();
refreshBoot();
}
});
}
}
async function handleDiskDelete () {
const disk = this.dataset.disk;
async handleDiskDelete () {
const disk = this.dataset.volume;
const header = `Delete ${disk}`;
const body = `<p>Are you sure you want to <strong>delete</strong> disk${disk}</p>`;
const body = `<p>Are you sure you want to <strong>delete</strong> disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg");
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
if (result.status !== 200) {
alert(`Attempted to delete ${disk} but got: ${result.error}`);
}
await getConfig();
populateDisk();
deleteBootLine(`boot-${disk}`);
refreshVolumes();
refreshBoot();
}
});
}
}
customElements.define("volume-action", VolumeAction);
async function initVolumes () {
document.querySelector("#disk-add").addEventListener("click", handleDiskAdd);
if (type === "qemu") {
document.querySelector("#cd-add").addEventListener("click", handleCDAdd);
}
}
async function refreshVolumes () {
let volumes = await requestDash(`/config/volumes?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (volumes.status !== 200) {
alert("Error fetching instance volumes.");
}
else {
volumes = volumes.data;
const container = document.querySelector("#volumes");
container.setHTMLUnsafe(volumes);
}
initVolumes();
}
async function handleDiskAdd () {
@@ -375,9 +247,8 @@ async function handleDiskAdd () {
if (result.status !== 200) {
alert(`Attempted to create ${disk} but got: ${result.error}`);
}
await getConfig();
populateDisk();
addBootLine("disabled", { id: disk, prefix, value: disk, detail: config.data[disk] });
refreshVolumes();
refreshBoot();
}
});
}
@@ -404,9 +275,8 @@ async function handleCDAdd () {
if (result.status !== 200) {
alert(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
}
await getConfig();
populateDisk();
addBootLine("disabled", { id: disk, prefix: "ide", value: disk, detail: config.data[disk] });
refreshVolumes();
refreshBoot();
}
});
@@ -418,74 +288,30 @@ async function handleCDAdd () {
isoSelect.selectedIndex = -1;
}
async function populateNetworks () {
document.querySelector("#networks").innerHTML = "";
const networks = {};
const prefix = networkMetaData.prefix;
Object.keys(config.data).forEach((element) => {
if (element.startsWith(prefix)) {
networks[element.replace(prefix, "")] = config.data[element];
class NetworkAction extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
if (this.dataset.type === "config") {
this.addEventListener("click", this.handleNetworkConfig);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleNetworkDelete);
}
}
});
const orderedKeys = getOrdered(networks);
orderedKeys.forEach((element) => {
addNetworkLine("networks", prefix, element, networks[element]);
});
document.querySelector("#network-add").addEventListener("click", handleNetworkAdd);
}
async setStatusLoading () {
const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`);
setSVGSrc(svg, "images/status/loading.svg");
}
function addNetworkLine (fieldset, prefix, netID, netDetails) {
const field = document.querySelector(`#${fieldset}`);
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
setSVGSrc(icon, networkMetaData.icon);
setSVGAlt(icon, `${prefix}${netID}`);
icon.dataset.network = netID;
icon.dataset.values = netDetails;
field.appendChild(icon);
const netLabel = document.createElement("p");
netLabel.innerText = `${prefix}${netID}`;
netLabel.dataset.network = netID;
netLabel.dataset.values = netDetails;
field.appendChild(netLabel);
const netDesc = document.createElement("p");
netDesc.innerText = netDetails;
netDesc.dataset.network = netID;
netDesc.dataset.values = netDetails;
netDesc.style.overflowX = "hidden";
netDesc.style.whiteSpace = "nowrap";
field.appendChild(netDesc);
const actionDiv = document.createElement("div");
const configBtn = document.createElementNS("http://www.w3.org/2000/svg", "svg");
configBtn.classList.add("clickable");
setSVGSrc(configBtn, "images/actions/network/config.svg");
setSVGAlt(configBtn, "Config Interface");
configBtn.addEventListener("click", handleNetworkConfig);
configBtn.dataset.network = netID;
configBtn.dataset.values = netDetails;
actionDiv.appendChild(configBtn);
const deleteBtn = document.createElementNS("http://www.w3.org/2000/svg", "svg");
deleteBtn.classList.add("clickable");
setSVGSrc(deleteBtn, "images/actions/delete-active.svg");
setSVGAlt(deleteBtn, "Delete Interface");
deleteBtn.addEventListener("click", handleNetworkDelete);
deleteBtn.dataset.network = netID;
deleteBtn.dataset.values = netDetails;
actionDiv.appendChild(deleteBtn);
field.appendChild(actionDiv);
}
async function handleNetworkConfig () {
async handleNetworkConfig () {
const netID = this.dataset.network;
const netDetails = this.dataset.values;
const header = `Edit net${netID}`;
const netDetails = this.dataset.value;
const header = `Edit ${netID}`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
@@ -494,42 +320,61 @@ async function handleNetworkConfig () {
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
this.setStatusLoading();
const body = {
rate: form.get("rate")
};
const net = `net${netID}`;
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/modify`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to change ${net} but got: ${result.error}`);
}
await getConfig();
populateNetworks();
updateBootLine(`boot-net${netID}`, { id: net, prefix: "net", value: net, detail: config.data[`net${netID}`] });
refreshNetworks();
refreshBoot();
}
});
d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0];
}
}
async function handleNetworkDelete () {
async handleNetworkDelete () {
const netID = this.dataset.network;
const header = `Delete net${netID}`;
const header = `Delete ${netID}`;
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `net${netID}`;
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) {
alert(`Attempted to delete ${net} but got: ${result.error}`);
}
await getConfig();
populateNetworks();
deleteBootLine(`boot-${net}`);
refreshNetworks();
refreshBoot();
}
});
}
}
customElements.define("network-action", NetworkAction);
async function initNetworks () {
document.querySelector("#network-add").addEventListener("click", handleNetworkAdd);
}
async function refreshNetworks () {
let nets = await requestDash(`/config/nets?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (nets.status !== 200) {
alert("Error fetching instance nets.");
}
else {
nets = nets.data;
const container = document.querySelector("#networks");
container.setHTMLUnsafe(nets);
}
initNetworks();
}
async function handleNetworkAdd () {
@@ -552,98 +397,41 @@ async function handleNetworkAdd () {
if (type === "lxc") {
body.name = form.get("name");
}
const netID = form.get("netid");
const net = `net${netID}`;
const id = form.get("netid");
const net = `net${id}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/create`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to create ${net} but got: ${result.error}`);
}
await getConfig();
populateNetworks();
const id = `net${netID}`;
addBootLine("disabled", { id, prefix: "net", value: id, detail: config.data[`net${netID}`] });
refreshNetworks();
refreshBoot();
}
});
}
async function populateDevices () {
if (type === "qemu") {
document.querySelector("#devices-card").classList.remove("none");
document.querySelector("#devices").innerHTML = "";
const devices = {};
const prefix = pcieMetaData.prefix;
Object.keys(config.data).forEach((element) => {
if (element.startsWith(prefix)) {
devices[element.replace(prefix, "")] = config.data[element];
class DeviceAction extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
if (this.dataset.type === "config") {
this.addEventListener("click", this.handleDeviceConfig);
}
else if (this.dataset.type === "delete") {
this.addEventListener("click", this.handleDeviceDelete);
}
});
const orderedKeys = getOrdered(devices);
orderedKeys.forEach(async (element) => {
const deviceData = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/hostpci${element}`, "GET");
addDeviceLine("devices", prefix, element, devices[element], deviceData.device_name);
});
document.querySelector("#device-add").addEventListener("click", handleDeviceAdd);
}
}
function addDeviceLine (fieldset, prefix, deviceID, deviceDetails, deviceName) {
const field = document.querySelector(`#${fieldset}`);
async setStatusLoading () {
const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`);
setSVGSrc(svg, "images/status/loading.svg");
}
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
setSVGSrc(icon, pcieMetaData.icon);
setSVGAlt(icon, `${prefix}${deviceID}`);
icon.dataset.device = deviceID;
icon.dataset.values = deviceDetails;
icon.dataset.name = deviceName;
field.appendChild(icon);
const IDLabel = document.createElement("p");
IDLabel.innerText = `hostpci${deviceID}`;
IDLabel.dataset.device = deviceID;
IDLabel.dataset.values = deviceDetails;
IDLabel.dataset.name = deviceName;
IDLabel.style.overflowX = "hidden";
IDLabel.style.whiteSpace = "nowrap";
field.appendChild(IDLabel);
const deviceLabel = document.createElement("p");
deviceLabel.innerText = deviceName;
deviceLabel.dataset.device = deviceID;
deviceLabel.dataset.values = deviceDetails;
deviceLabel.dataset.name = deviceName;
deviceLabel.style.overflowX = "hidden";
deviceLabel.style.whiteSpace = "nowrap";
field.appendChild(deviceLabel);
const actionDiv = document.createElement("div");
const configBtn = document.createElementNS("http://www.w3.org/2000/svg", "svg");
configBtn.classList.add("clickable");
setSVGSrc(configBtn, "images/actions/device/config.svg");
setSVGAlt(configBtn, "Config Device");
configBtn.addEventListener("click", handleDeviceConfig);
configBtn.dataset.device = deviceID;
configBtn.dataset.values = deviceDetails;
configBtn.dataset.name = deviceName;
actionDiv.appendChild(configBtn);
const deleteBtn = document.createElementNS("http://www.w3.org/2000/svg", "svg");
deleteBtn.classList.add("clickable");
setSVGSrc(deleteBtn, "images/actions/delete-active.svg");
setSVGAlt(deleteBtn, "Delete Device");
deleteBtn.addEventListener("click", handleDeviceDelete);
deleteBtn.dataset.device = deviceID;
deleteBtn.dataset.values = deviceDetails;
deleteBtn.dataset.name = deviceName;
actionDiv.appendChild(deleteBtn);
field.appendChild(actionDiv);
}
async function handleDeviceConfig () {
async handleDeviceConfig () {
const deviceID = this.dataset.device;
const deviceDetails = this.dataset.values;
const deviceDetails = this.dataset.value;
const deviceName = this.dataset.name;
const header = `Edit Expansion Card ${deviceID}`;
const body = `
@@ -654,46 +442,67 @@ async function handleDeviceConfig () {
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-device="${deviceID}"]`), "images/status/loading.svg");
this.setStatusLoading();
const body = {
device: form.get("device"),
pcie: form.get("pcie") ? 1 : 0
};
const device = `hostpci${deviceID}`;
const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/modify`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to add ${device} but got: ${result.error}`);
}
await getConfig();
populateDevices();
refreshDevices();
}
});
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
for (const availDevice of availDevices) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_id));
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
}
}
async function handleDeviceDelete () {
async handleDeviceDelete () {
const deviceID = this.dataset.device;
const header = `Remove Expansion Card ${deviceID}`;
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-device="${deviceID}"]`), "images/status/loading.svg");
const device = `hostpci${deviceID}`;
this.setStatusLoading();
const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE");
if (result.status !== 200) {
alert(`Attempted to delete ${device} but got: ${result.error}`);
}
await getConfig();
populateDevices();
refreshDevices();
}
});
}
}
customElements.define("device-action", DeviceAction);
async function initDevices () {
if (type === "qemu") {
document.querySelector("#device-add").addEventListener("click", handleDeviceAdd);
}
}
async function refreshDevices () {
let devices = await requestDash(`/config/devices?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (devices.status !== 200) {
alert("Error fetching instance devices.");
}
else {
devices = devices.data;
const container = document.querySelector("#devices");
container.setHTMLUnsafe(devices);
}
initDevices();
}
async function handleDeviceAdd () {
@@ -713,119 +522,31 @@ async function handleDeviceAdd () {
device: form.get("device"),
pcie: form.get("pcie") ? 1 : 0
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/hostpci${hostpci}/create`, "POST", body);
const deviceID = `hostpci${hostpci}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to add ${body.device} but got: ${result.error}`);
}
await getConfig();
populateDevices();
refreshDevices();
}
});
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
for (const availDevice of availDevices) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_id));
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = true;
}
async function populateBoot () {
if (type === "qemu") {
document.querySelector("#boot-card").classList.remove("none");
document.querySelector("#enabled").title = "Enabled";
document.querySelector("#disabled").title = "Disabled";
let order = [];
if (config.data.boot.startsWith("order=")) {
order = config.data.boot.replace("order=", "").split(";");
}
const bootable = { disabled: [] };
const eligible = bootMetaData.eligiblePrefixes;
for (let i = 0; i < order.length; i++) {
const element = order[i];
const prefix = eligible.find((pref) => order[i].startsWith(pref));
const detail = config.data[element];
const num = element.replace(prefix, "");
if (!isNaN(num)) {
bootable[i] = { id: element, value: element, prefix, detail };
}
}
Object.keys(config.data).forEach((element) => {
const prefix = eligible.find((pref) => element.startsWith(pref));
const detail = config.data[element];
const num = element.replace(prefix, "");
if (prefix && !order.includes(element) && !isNaN(num)) {
bootable.disabled.push({ id: element, value: element, prefix, detail });
}
});
Object.keys(bootable).sort();
Object.keys(bootable).forEach((element) => {
if (element !== "disabled") {
addBootLine("enabled", bootable[element], document.querySelector("#enabled-spacer"));
async function refreshBoot () {
let boot = await requestDash(`/config/boot?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (boot.status !== 200) {
alert("Error fetching instance boot order.");
}
else {
bootable.disabled.forEach((item) => {
addBootLine("disabled", item, document.querySelector("#disabled-spacer"));
});
}
});
}
}
function addBootLine (container, data, before = null) {
const item = document.createElement("draggable-item");
item.data = data;
item.innerHTML = `
<div style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg id="drag" role="application" aria-label="drag icon"><title>drag icon</title><use href="images/actions/drag.svg#symb"></use></svg>
<svg role="application" aria-label="${bootMetaData[data.prefix].alt}"><title>${bootMetaData[data.prefix].alt}</title><use href="${bootMetaData[data.prefix].icon}#symb"></use></svg>
<p style="margin: 0px;">${data.id}</p>
<p style="margin: 0px; overflow-x: hidden; white-space: nowrap;">${data.detail}</p>
</div>
`;
item.id = `boot-${data.id}`;
if (before) {
document.querySelector(`#${container}`).insertBefore(item, before);
}
else {
document.querySelector(`#${container}`).append(item);
}
item.container = container;
item.value = data.value;
}
function deleteBootLine (id) {
const query = `#${id}`;
const enabled = document.querySelector("#enabled");
const disabled = document.querySelector("#disabled");
const inEnabled = enabled.querySelector(query);
const inDisabled = disabled.querySelector(query);
if (inEnabled) {
enabled.removeChild(inEnabled);
}
if (inDisabled) {
disabled.removeChild(inDisabled);
}
}
function updateBootLine (id, newData) {
const enabled = document.querySelector("#enabled");
const disabled = document.querySelector("#disabled");
let element = null;
if (enabled.querySelector(`#${id}`)) {
element = enabled.querySelector(`#${id}`);
}
if (disabled.querySelector(`#${id}`)) {
element = disabled.querySelector(`#${id}`);
}
if (element) {
const container = element.container;
const before = element.nextSibling;
deleteBootLine(id);
addBootLine(container, newData, before);
return true;
}
else {
return false;
boot = boot.data;
const order = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot);
}
}
@@ -843,7 +564,7 @@ async function handleFormExit () {
}
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/resources`, "POST", body);
if (result.status === 200) {
goToPage("index.html");
goToPage("index");
}
else {
alert(`Attempted to set basic resources but got: ${result.error}`);

View File

@@ -1,28 +1,16 @@
const blank = document.createElement("img");
class DraggableContainer extends HTMLElement {
shadowRoot = null;
constructor () {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
draggable-item.ghost::part(wrapper) {
border: 1px dashed var(--main-text-color);
border-radius: 5px;
margin: -1px;
}
draggable-item::part(wrapper) {
cursor: grab;
}
</style>
<label id="title"></label>
<div id="wrapper" style="padding-bottom: 1em;"></div>
`;
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
this.content = this.shadowRoot.querySelector("#wrapper");
this.titleElem = this.shadowRoot.querySelector("#title");
window.Sortable.create(this.content, {
group: "boot",
group: this.dataset.group,
ghostClass: "ghost",
setData: function (dataTransfer, dragEl) {
dataTransfer.setDragImage(blank, 0, 0);
@@ -30,14 +18,6 @@ class DraggableContainer extends HTMLElement {
});
}
get title () {
return this.titleElem.innerText;
}
set title (title) {
this.titleElem.innerText = title;
}
append (newNode) {
this.content.appendChild(newNode, this.bottom);
}
@@ -50,6 +30,10 @@ class DraggableContainer extends HTMLElement {
return this.content.querySelector(query);
}
hasChildNodes (query) {
return this.querySelector(query) !== null;
}
removeChild (node) {
if (node && this.content.contains(node)) {
this.content.removeChild(node);
@@ -65,54 +49,12 @@ class DraggableContainer extends HTMLElement {
get value () {
const value = [];
this.content.childNodes.forEach((element) => {
if (element.value) {
value.push(element.value);
if (element.dataset.value) {
value.push(element.dataset.value);
}
});
return value;
}
}
class DraggableItem extends HTMLElement {
#value = null;
uuid = null;
constructor () {
super();
this.attachShadow({ mode: "open" });
// for whatever reason, only grid layout seems to respect the parent's content bounds
this.shadowRoot.innerHTML = `
<style>
img, svg {
height: 1em;
width: 1em;
}
* {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
</style>
<div id="wrapper" part="wrapper"></div>
`;
this.content = this.shadowRoot.querySelector("#wrapper");
}
get innerHTML () {
return this.content.innerHTML;
}
set innerHTML (innerHTML) {
this.content.innerHTML = innerHTML;
}
get value () {
return this.#value;
}
set value (value) {
this.#value = value;
}
}
customElements.define("draggable-container", DraggableContainer);
customElements.define("draggable-item", DraggableItem);

View File

@@ -0,0 +1,304 @@
{{define "proctype-input"}}
<svg aria-label="CPU Type"><use href="images/resources/cpu.svg#symb"></svg>
<label for="proctype">CPU Type</label>
{{template "select" .}}
<div></div>
{{end}}
{{define "cores-input"}}
<svg aria-label="CPU Amount"><use href="images/resources/cpu.svg#symb"></svg>
<label for="cores">CPU Amount</label>
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
<p>Cores</p>
{{end}}
{{define "memory-input"}}
<svg aria-label="Memory Amount"><use href="images/resources/ram.svg#symb"></svg>
<label for="ram">Memory</label>
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p>
{{end}}
{{define "swap-input"}}
<svg aria-label="Swap Amount"><use href="images/resources/swap.svg#symb"></svg>
<label for="swap">Swap</label>
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p>
{{end}}
{{define "volumes"}}
{{range $k,$v := .}}
{{if eq $v.Type "rootfs"}}
{{ template "volume-rootfs" Map "Name" $k "Volume" $v}}
{{else if eq $v.Type "mp"}}
{{ template "volume-mp" Map "Name" $k "Volume" $v}}
{{else if eq $v.Type "ide"}}
{{ template "volume-ide" Map "Name" $k "Volume" $v}}
{{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}}
{{ template "volume-scsi" Map "Name" $k "Volume" $v}}
{{else if eq $v.Type "unused"}}
{{ template "volume-unused" Map "Name" $k "Volume" $v}}
{{else}}
{{end}}
{{end}}
{{end}}
{{define "volume-rootfs"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
{{template "volume-action-move" .}}
{{template "volume-action-resize" .}}
{{template "volume-action-none" .}}
{{template "volume-action-none" .}}
</div>
{{end}}
{{define "volume-mp"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
{{template "volume-action-move" .}}
{{template "volume-action-resize" .}}
{{template "volume-action-detach" .}}
{{template "volume-action-delete-inactive" .}}
</div>
{{end}}
{{define "volume-ide"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
{{template "volume-action-none" .}}
{{template "volume-action-none" .}}
{{template "volume-action-none" .}}
{{template "volume-action-delete" .}}
</div>
{{end}}
{{define "volume-scsi"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
{{template "volume-action-move" .}}
{{template "volume-action-resize" .}}
{{template "volume-action-detach" .}}
{{template "volume-action-delete-inactive" .}}
</div>
{{end}}
{{define "volume-unused"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
{{template "volume-action-move-inactive" .}}
{{template "volume-action-resize-inactive" .}}
{{template "volume-action-attach" .}}
{{template "volume-action-delete" .}}
</div>
{{end}}
{{define "volume-action-move"}}
<volume-action data-type="move" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-move-inactive"}}
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/move-inactive.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-resize"}}
<volume-action data-type="resize" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-resize-inactive"}}
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/resize-inactive.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-delete"}}
<volume-action data-type="delete" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-delete-inactive"}}
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/delete-inactive.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-attach"}}
<volume-action data-type="attach" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-detach"}}
<volume-action data-type="detach" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg>
</template>
</volume-action>
{{end}}
{{define "volume-action-none"}}
<volume-action data-type="none">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""></svg>
</template>
</volume-action>
{{end}}
{{define "nets"}}
{{range $k,$v := .}}
{{template "net" $v}}
{{end}}
{{end}}
{{define "net"}}
<svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg>
<p>{{.Net_ID}}</p>
<p>{{.Value}}</p>
<div>
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg>
</template>
</network-action>
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg>
</template>
</network-action>
</div>
{{end}}
{{define "devices"}}
{{range $k,$v := .}}
{{template "device" $v}}
{{end}}
{{end}}
{{define "device"}}
<svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg>
<p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p>
<div>
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg>
</template>
</device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg>
</template>
</device-action>
</div>
{{end}}
{{define "boot"}}
<draggable-container id="enabled" data-group="boot">
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Enabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Enabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
<hr style="padding: 0; margin: 0;">
<draggable-container id="disabled" data-group="boot">
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Disabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Disabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
{{end}}
{{define "boot-style"}}
<style>
div.draggable-item.ghost {
border: 1px dashed var(--main-text-color);
border-radius: 5px;
margin: -1px;
}
div.draggable-item {
cursor: grab;
}
div.draggable-item svg {
height: 1em;
width: 1em;
}
* {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
</style>
{{end}}
{{define "boot-target"}}
{{if .volume_id}}
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
<svg aria-label="Volume"><use href="images/resources/drive.svg#symb"></use></svg>
<p style="margin: 0px;">{{.volume_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
</div>
{{else if .net_id}}
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
<svg aria-label="Net"><use href="images/resources/network.svg#symb"></use></svg>
<p style="margin: 0px;">{{.net_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
</div>
{{else}}
{{end}}
{{end}}

View File

@@ -1,15 +0,0 @@
{{define "svg"}}
{{if .ID}}
{{if .Clickable}}
<svg id={{.ID}} aria-label="{{.Alt}}" class="clickable"><use href="{{.Src}}#symb"></svg>
{{else}}
<svg id={{.ID}} aria-label="{{.Alt}}"><use href="{{.Src}}#symb"></svg>
{{end}}
{{else}}
{{if .Clickable}}
<svg aria-label="{{.Alt}}" class="clickable"><use href="{{.Src}}#symb"></svg>
{{else}}
<svg aria-label="{{.Alt}}"><use href="{{.Src}}#symb"></svg>
{{end}}
{{end}}
{{end}}