From 8530b50f9a221380f9858aa5b422545e5e572590 Mon Sep 17 00:00:00 2001 From: Arthur Lu Date: Tue, 22 Apr 2025 17:09:27 +0000 Subject: [PATCH] implement SSR for instance config page --- app/app.go | 3 + app/common/meta.go | 3 +- app/common/utils.go | 15 + app/routes/config.go | 331 +++++- web/html/config-boot.frag | 1 + web/html/config-devices.frag | 1 + web/html/config-nets.frag | 1 + web/html/config-volumes.frag | 1 + web/html/config.html | 45 +- web/images/actions/device/delete-active.svg | 1 + web/images/actions/device/delete-inactive.svg | 1 + web/images/actions/group/add.svg | 1 - web/images/actions/group/config.svg | 1 - web/images/actions/network/delete-active.svg | 1 + .../actions/network/delete-inactive.svg | 1 + web/images/actions/user/add.svg | 1 - web/images/actions/user/config.svg | 1 - web/scripts/config.js | 993 +++++++----------- web/scripts/draggable.js | 80 +- web/templates/{base.tmpl => base.go.tmpl} | 0 web/templates/config.go.tmpl | 304 ++++++ ...stance-card.tmpl => instance-card.go.tmpl} | 0 ...urce-chart.tmpl => resource-chart.go.tmpl} | 0 web/templates/{select.tmpl => select.go.tmpl} | 0 web/templates/svg.tmpl | 15 - 25 files changed, 1062 insertions(+), 739 deletions(-) create mode 100644 web/html/config-boot.frag create mode 100644 web/html/config-devices.frag create mode 100644 web/html/config-nets.frag create mode 100644 web/html/config-volumes.frag create mode 120000 web/images/actions/device/delete-active.svg create mode 120000 web/images/actions/device/delete-inactive.svg delete mode 120000 web/images/actions/group/add.svg delete mode 120000 web/images/actions/group/config.svg create mode 120000 web/images/actions/network/delete-active.svg create mode 120000 web/images/actions/network/delete-inactive.svg delete mode 120000 web/images/actions/user/add.svg delete mode 120000 web/images/actions/user/config.svg rename web/templates/{base.tmpl => base.go.tmpl} (100%) create mode 100644 web/templates/config.go.tmpl rename web/templates/{instance-card.tmpl => instance-card.go.tmpl} (100%) rename web/templates/{resource-chart.tmpl => resource-chart.go.tmpl} (100%) rename web/templates/{select.tmpl => select.go.tmpl} (100%) delete mode 100644 web/templates/svg.tmpl diff --git a/app/app.go b/app/app.go index 15df015..d64dd57 100644 --- a/app/app.go +++ b/app/app.go @@ -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))) } diff --git a/app/common/meta.go b/app/common/meta.go index 6d36b65..50f539e 100644 --- a/app/common/meta.go +++ b/app/common/meta.go @@ -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", diff --git a/app/common/utils.go b/app/common/utils.go index 2a337c7..db0bdb1 100644 --- a/app/common/utils.go +++ b/app/common/utils.go @@ -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) diff --git a/app/routes/config.go b/app/routes/config.go index 361e9dc..f958b3d 100644 --- a/app/routes/config.go +++ b/app/routes/config.go @@ -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 +} diff --git a/web/html/config-boot.frag b/web/html/config-boot.frag new file mode 100644 index 0000000..5b954f9 --- /dev/null +++ b/web/html/config-boot.frag @@ -0,0 +1 @@ +{{template "boot" .config.Boot}} \ No newline at end of file diff --git a/web/html/config-devices.frag b/web/html/config-devices.frag new file mode 100644 index 0000000..256fec1 --- /dev/null +++ b/web/html/config-devices.frag @@ -0,0 +1 @@ +{{template "devices" .config.Devices}} \ No newline at end of file diff --git a/web/html/config-nets.frag b/web/html/config-nets.frag new file mode 100644 index 0000000..3c09374 --- /dev/null +++ b/web/html/config-nets.frag @@ -0,0 +1 @@ +{{template "nets" .config.Nets}} \ No newline at end of file diff --git a/web/html/config-volumes.frag b/web/html/config-volumes.frag new file mode 100644 index 0000000..8c2ff0d --- /dev/null +++ b/web/html/config-volumes.frag @@ -0,0 +1 @@ +{{template "volumes" .config.Volumes}} \ No newline at end of file diff --git a/web/html/config.html b/web/html/config.html index dfad024..4e9df58 100644 --- a/web/html/config.html +++ b/web/html/config.html @@ -14,6 +14,8 @@ margin-bottom: 0; padding-top: 0; padding-bottom: 0; + overflow: hidden; + white-space: nowrap; } @@ -23,29 +25,44 @@
-

Instances / %{vmname}

+

Instances / {{.config.Name}}

Resources -
+
+ {{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}} +
- Disks -
+ Volumes +
+ {{template "volumes" .config.Volumes}} +
- + {{end}}
Network Interfaces -
+
+ {{template "nets" .config.Nets}} +
-
+ {{if eq .config.Type "VM"}} +
PCIe Devices -
+
+ {{template "devices" .config.Devices}} +
-
+
Boot Order - -
- +
+ {{template "boot" .config.Boot}} +
+ {{end}}
diff --git a/web/images/actions/device/delete-active.svg b/web/images/actions/device/delete-active.svg new file mode 120000 index 0000000..edf683e --- /dev/null +++ b/web/images/actions/device/delete-active.svg @@ -0,0 +1 @@ +../../common/delete-active.svg \ No newline at end of file diff --git a/web/images/actions/device/delete-inactive.svg b/web/images/actions/device/delete-inactive.svg new file mode 120000 index 0000000..9596a07 --- /dev/null +++ b/web/images/actions/device/delete-inactive.svg @@ -0,0 +1 @@ +../../common/delete-inactive.svg \ No newline at end of file diff --git a/web/images/actions/group/add.svg b/web/images/actions/group/add.svg deleted file mode 120000 index 589a49a..0000000 --- a/web/images/actions/group/add.svg +++ /dev/null @@ -1 +0,0 @@ -../../common/add.svg \ No newline at end of file diff --git a/web/images/actions/group/config.svg b/web/images/actions/group/config.svg deleted file mode 120000 index a575c90..0000000 --- a/web/images/actions/group/config.svg +++ /dev/null @@ -1 +0,0 @@ -../../common/config.svg \ No newline at end of file diff --git a/web/images/actions/network/delete-active.svg b/web/images/actions/network/delete-active.svg new file mode 120000 index 0000000..edf683e --- /dev/null +++ b/web/images/actions/network/delete-active.svg @@ -0,0 +1 @@ +../../common/delete-active.svg \ No newline at end of file diff --git a/web/images/actions/network/delete-inactive.svg b/web/images/actions/network/delete-inactive.svg new file mode 120000 index 0000000..9596a07 --- /dev/null +++ b/web/images/actions/network/delete-inactive.svg @@ -0,0 +1 @@ +../../common/delete-inactive.svg \ No newline at end of file diff --git a/web/images/actions/user/add.svg b/web/images/actions/user/add.svg deleted file mode 120000 index 589a49a..0000000 --- a/web/images/actions/user/add.svg +++ /dev/null @@ -1 +0,0 @@ -../../common/add.svg \ No newline at end of file diff --git a/web/images/actions/user/config.svg b/web/images/actions/user/config.svg deleted file mode 120000 index a575c90..0000000 --- a/web/images/actions/user/config.svg +++ /dev/null @@ -1 +0,0 @@ -../../common/config.svg \ No newline at end of file diff --git a/web/scripts/config.js b/web/scripts/config.js index 08d11f8..cf4324f 100644 --- a/web/scripts/config.js +++ b/web/scripts/config.js @@ -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,291 +21,195 @@ 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); - }); +class VolumeAction extends HTMLElement { + shadowRoot = null; + + constructor () { + super(); + const internals = this.attachInternals(); + this.shadowRoot = internals.shadowRoot; + if (this.dataset.type === "move") { + this.addEventListener("click", this.handleDiskMove); } - 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); - }); + 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); } - 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 }); + + async setStatusLoading () { + const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`); + setSVGSrc(svg, "images/status/loading.svg"); + } + + async handleDiskDetach () { + const disk = this.dataset.volume; + const header = `Detach ${disk}`; + const body = `

Are you sure you want to detach disk ${disk}

`; + dialog(header, body, async (result, form) => { + if (result === "confirm") { + 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}`); + } + refreshVolumes(); + refreshBoot(); + } + }); + } + + async handleDiskAttach () { + const header = `Attach ${this.dataset.volume}`; + const body = ` + + + + + `; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + const device = form.get("device"); + this.setStatusLoading(); + const body = { + 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.volume} to ${disk} but got: ${result.error}`); + } + refreshVolumes(); + refreshBoot(); + } + }); + } + + async handleDiskResize () { + const header = `Resize ${this.dataset.volume}`; + const body = ` +
+ + +
+ `; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + const disk = this.dataset.volume; + this.setStatusLoading(); + const body = { + size: form.get("size-increment") + }; + const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body); + if (result.status !== 200) { + alert(`Attempted to resize ${disk} but got: ${result.error}`); + } + refreshVolumes(); + refreshBoot(); + } + }); + } + + async handleDiskMove () { + const content = type === "qemu" ? "images" : "rootdir"; + const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); + + const header = `Move ${this.dataset.volume}`; + + let options = ""; + storage.data.forEach((element) => { + if (element.content.includes(content)) { + options += `"`; + } + }); + const select = ``; + + const body = ` +
+ ${select} + +
+ `; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + const disk = this.dataset.volume; + this.setStatusLoading(); + const body = { + storage: form.get("storage-select"), + delete: form.get("delete-check") === "on" ? "1" : "0" + }; + const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body); + if (result.status !== 200) { + alert(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`); + } + refreshVolumes(); + refreshBoot(); + } + }); + } + + async handleDiskDelete () { + const disk = this.dataset.volume; + const header = `Delete ${disk}`; + const body = `

Are you sure you want to delete disk ${disk}

`; + dialog(header, body, async (result, form) => { + if (result === "confirm") { + 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}`); + } + refreshVolumes(); + refreshBoot(); + } + }); } } -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]; - } - }); - const orderedKeys = getOrdered(disks); - orderedKeys.forEach((element) => { - const disk = disks[element]; - addDiskLine("disks", prefix, busName, element, disk); - }); - } - document.querySelector("#disk-add").addEventListener("click", handleDiskAdd); +customElements.define("volume-action", VolumeAction); +async function initVolumes () { + document.querySelector("#disk-add").addEventListener("click", handleDiskAdd); if (type === "qemu") { - document.querySelector("#cd-add").classList.remove("none"); document.querySelector("#cd-add").addEventListener("click", handleCDAdd); } } -function addDiskLine (fieldset, busPrefix, busName, device, diskDetails) { - const field = document.querySelector(`#${fieldset}`); +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); + } - 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; - const header = `Detach ${disk}`; - const body = `

Are you sure you want to detach disk ${disk}

`; - dialog(header, body, async (result, form) => { - if (result === "confirm") { - setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg"); - 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}`); - } - }); -} - -async function handleDiskAttach () { - const header = `Attach ${this.dataset.disk}`; - const body = ` -
- - -
- `; - - 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"); - const body = { - source: this.dataset.disk.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}`); - } - await getConfig(); - populateDisk(); - addBootLine("disabled", { id: disk, prefix, value: disk, detail: config.data[disk] }); - } - }); -} - -async function handleDiskResize () { - const header = `Resize ${this.dataset.disk}`; - const body = ` -
- - -
- `; - - 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 body = { - size: form.get("size-increment") - }; - const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body); - 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] }); - } - }); -} - -async function handleDiskMove () { - const content = type === "qemu" ? "images" : "rootdir"; - const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); - - const header = `Move ${this.dataset.disk}`; - - let options = ""; - storage.data.forEach((element) => { - if (element.content.includes(content)) { - options += `"`; - } - }); - const select = ``; - - const body = ` -
- ${select} - -
- `; - - 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 body = { - storage: form.get("storage-select"), - delete: form.get("delete-check") === "on" ? "1" : "0" - }; - const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body); - 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] }); - } - }); -} - -async function handleDiskDelete () { - const disk = this.dataset.disk; - const header = `Delete ${disk}`; - const body = `

Are you sure you want to delete disk${disk}

`; - dialog(header, body, async (result, form) => { - if (result === "confirm") { - setSVGSrc(document.querySelector(`svg[data-disk="${disk}"]`), "images/status/loading.svg"); - 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}`); - } - }); + 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,118 +288,93 @@ 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]; - } - }); - const orderedKeys = getOrdered(networks); - orderedKeys.forEach((element) => { - addNetworkLine("networks", prefix, element, networks[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); + } + } + + async setStatusLoading () { + const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`); + setSVGSrc(svg, "images/status/loading.svg"); + } + + async handleNetworkConfig () { + const netID = this.dataset.network; + const netDetails = this.dataset.value; + const header = `Edit ${netID}`; + const body = ` +
+ +
+ `; + + const d = dialog(header, body, async (result, form) => { + if (result === "confirm") { + this.setStatusLoading(); + const body = { + rate: form.get("rate") + }; + 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}`); + } + refreshNetworks(); + refreshBoot(); + } + }); + + d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0]; + } + + async handleNetworkDelete () { + const netID = this.dataset.network; + 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 = `${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}`); + } + refreshNetworks(); + refreshBoot(); + } + }); + } +} + +customElements.define("network-action", NetworkAction); + +async function initNetworks () { document.querySelector("#network-add").addEventListener("click", handleNetworkAdd); } -function addNetworkLine (fieldset, prefix, netID, netDetails) { - const field = document.querySelector(`#${fieldset}`); +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); + } - 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 () { - const netID = this.dataset.network; - const netDetails = this.dataset.values; - const header = `Edit net${netID}`; - const body = ` -
- -
- `; - - const d = dialog(header, body, async (result, form) => { - if (result === "confirm") { - setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg"); - const body = { - rate: form.get("rate") - }; - const net = `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}`] }); - } - }); - - d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0]; -} - -async function handleNetworkDelete () { - const netID = this.dataset.network; - const header = `Delete net${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 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}`); - } - }); + initNetworks(); } async function handleNetworkAdd () { @@ -552,148 +397,112 @@ 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); + } + } + + async setStatusLoading () { + const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`); + setSVGSrc(svg, "images/status/loading.svg"); + } + + async handleDeviceConfig () { + const deviceID = this.dataset.device; + const deviceDetails = this.dataset.value; + const deviceName = this.dataset.name; + const header = `Edit Expansion Card ${deviceID}`; + const body = ` +
+ +
+ `; + + const d = dialog(header, body, async (result, form) => { + if (result === "confirm") { + this.setStatusLoading(); + const body = { + device: form.get("device"), + pcie: form.get("pcie") ? 1 : 0 + }; + 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}`); + } + refreshDevices(); } }); - 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); - }); + 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_bus)); + } + d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1"); + } + + async handleDeviceDelete () { + const deviceID = this.dataset.device; + const header = `Remove Expansion Card ${deviceID}`; + const body = ""; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + 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}`); + } + refreshDevices(); + } + }); + } +} + +customElements.define("device-action", DeviceAction); + +async function initDevices () { + if (type === "qemu") { document.querySelector("#device-add").addEventListener("click", handleDeviceAdd); } } -function addDeviceLine (fieldset, prefix, deviceID, deviceDetails, deviceName) { - const field = document.querySelector(`#${fieldset}`); - - 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 () { - const deviceID = this.dataset.device; - const deviceDetails = this.dataset.values; - const deviceName = this.dataset.name; - const header = `Edit Expansion Card ${deviceID}`; - const body = ` -
- -
- `; - - const d = dialog(header, body, async (result, form) => { - if (result === "confirm") { - setSVGSrc(document.querySelector(`svg[data-device="${deviceID}"]`), "images/status/loading.svg"); - const body = { - device: form.get("device"), - pcie: form.get("pcie") ? 1 : 0 - }; - const device = `hostpci${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(); - } - }); - - 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)); +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); } - d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1"); -} -async function 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}`; - 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(); - } - }); + 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")); - } - 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 = ` -
- drag icon - ${bootMetaData[data.prefix].alt} -

${data.id}

-

${data.detail}

-
- `; - item.id = `boot-${data.id}`; - if (before) { - document.querySelector(`#${container}`).insertBefore(item, before); +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 { - 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}`); diff --git a/web/scripts/draggable.js b/web/scripts/draggable.js index 8874fe2..c387803 100644 --- a/web/scripts/draggable.js +++ b/web/scripts/draggable.js @@ -1,28 +1,16 @@ const blank = document.createElement("img"); class DraggableContainer extends HTMLElement { + shadowRoot = null; + constructor () { super(); - this.attachShadow({ mode: "open" }); - this.shadowRoot.innerHTML = ` - - -
- `; + 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 = ` - -
- `; - 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); diff --git a/web/templates/base.tmpl b/web/templates/base.go.tmpl similarity index 100% rename from web/templates/base.tmpl rename to web/templates/base.go.tmpl diff --git a/web/templates/config.go.tmpl b/web/templates/config.go.tmpl new file mode 100644 index 0000000..68c6950 --- /dev/null +++ b/web/templates/config.go.tmpl @@ -0,0 +1,304 @@ +{{define "proctype-input"}} + + +{{template "select" .}} +
+{{end}} + +{{define "cores-input"}} + + + +

Cores

+{{end}} + +{{define "memory-input"}} + + + +

MiB

+{{end}} + +{{define "swap-input"}} + + + +

MiB

+{{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"}} + +

{{.Name}}

+

{{.Volume.File}}

+
+ {{template "volume-action-move" .}} + {{template "volume-action-resize" .}} + {{template "volume-action-none" .}} + {{template "volume-action-none" .}} +
+{{end}} + +{{define "volume-mp"}} + +

{{.Name}}

+

{{.Volume.File}}

+
+ {{template "volume-action-move" .}} + {{template "volume-action-resize" .}} + {{template "volume-action-detach" .}} + {{template "volume-action-delete-inactive" .}} +
+{{end}} + +{{define "volume-ide"}} + +

{{.Name}}

+

{{.Volume.File}}

+
+ {{template "volume-action-none" .}} + {{template "volume-action-none" .}} + {{template "volume-action-none" .}} + {{template "volume-action-delete" .}} +
+{{end}} + +{{define "volume-scsi"}} + +

{{.Name}}

+

{{.Volume.File}}

+
+ {{template "volume-action-move" .}} + {{template "volume-action-resize" .}} + {{template "volume-action-detach" .}} + {{template "volume-action-delete-inactive" .}} +
+{{end}} + +{{define "volume-unused"}} + +

{{.Name}}

+

{{.Volume.File}}

+
+ {{template "volume-action-move-inactive" .}} + {{template "volume-action-resize-inactive" .}} + {{template "volume-action-attach" .}} + {{template "volume-action-delete" .}} +
+{{end}} + +{{define "volume-action-move"}} + + + +{{end}} + +{{define "volume-action-move-inactive"}} + + + +{{end}} + +{{define "volume-action-resize"}} + + + +{{end}} + +{{define "volume-action-resize-inactive"}} + + + +{{end}} + +{{define "volume-action-delete"}} + + + +{{end}} + +{{define "volume-action-delete-inactive"}} + + + +{{end}} + +{{define "volume-action-attach"}} + + + +{{end}} + +{{define "volume-action-detach"}} + + + +{{end}} + +{{define "volume-action-none"}} + + + +{{end}} + +{{define "nets"}} + {{range $k,$v := .}} + {{template "net" $v}} + {{end}} +{{end}} + +{{define "net"}} + +

{{.Net_ID}}

+

{{.Value}}

+
+ + + + + + +
+{{end}} + +{{define "devices"}} + {{range $k,$v := .}} + {{template "device" $v}} + {{end}} +{{end}} + +{{define "device"}} + +

{{.Device_ID}}

+

{{.Device_Name}}

+
+ + + + + + +
+{{end}} + +{{define "boot"}} + + + +
+ + + +{{end}} + +{{define "boot-style"}} + +{{end}} + +{{define "boot-target"}} +{{if .volume_id}} +
+ + +

{{.volume_id}}

+

{{.file}}

+
+{{else if .net_id}} +
+ + +

{{.net_id}}

+

{{.value}}

+
+{{else}} +{{end}} +{{end}} \ No newline at end of file diff --git a/web/templates/instance-card.tmpl b/web/templates/instance-card.go.tmpl similarity index 100% rename from web/templates/instance-card.tmpl rename to web/templates/instance-card.go.tmpl diff --git a/web/templates/resource-chart.tmpl b/web/templates/resource-chart.go.tmpl similarity index 100% rename from web/templates/resource-chart.tmpl rename to web/templates/resource-chart.go.tmpl diff --git a/web/templates/select.tmpl b/web/templates/select.go.tmpl similarity index 100% rename from web/templates/select.tmpl rename to web/templates/select.go.tmpl diff --git a/web/templates/svg.tmpl b/web/templates/svg.tmpl deleted file mode 100644 index 90881f4..0000000 --- a/web/templates/svg.tmpl +++ /dev/null @@ -1,15 +0,0 @@ -{{define "svg"}} - {{if .ID}} - {{if .Clickable}} - - {{else}} - - {{end}} - {{else}} - {{if .Clickable}} - - {{else}} - - {{end}} - {{end}} -{{end}} \ No newline at end of file