From 59d12d2e995df3c37020451c0c6c3e1e8bee8a3f Mon Sep 17 00:00:00 2001 From: Arthur Lu Date: Wed, 12 Mar 2025 20:16:51 +0000 Subject: [PATCH] implement partial SSR of instances --- app/app.go | 212 +++++++++++++++++++++------------- app/meta.go | 161 ++++++++++++++++++++++++++ app/types.go | 80 +++++++------ app/utils.go | 121 +++++++++++++++++--- configs/.htmlvalidate.json | 10 +- configs/template.config.json | 1 + go.mod | 1 + web/html/index.html | 6 +- web/scripts/clientsync.js | 3 - web/scripts/index.js | 215 +++++++++++++++-------------------- web/scripts/utils.js | 19 ++++ web/templates/base.tmpl | 1 + web/templates/instance.tmpl | 35 ++++++ web/templates/instances.frag | 3 + web/templates/svg.tmpl | 7 ++ 15 files changed, 611 insertions(+), 264 deletions(-) create mode 100644 app/meta.go create mode 100644 web/templates/instance.tmpl create mode 100644 web/templates/instances.frag create mode 100644 web/templates/svg.tmpl diff --git a/app/app.go b/app/app.go index fd78ed3..bb8119a 100644 --- a/app/app.go +++ b/app/app.go @@ -1,18 +1,46 @@ package app import ( - "encoding/json" "flag" "fmt" - "io" + "html/template" "log" "net/http" "proxmoxaas-dashboard/dist/web" // go will complain here until the first build "github.com/gin-gonic/gin" + "github.com/go-viper/mapstructure/v2" "github.com/tdewolff/minify" ) +var tmpl *template.Template +var global Config + +func Run() { + gin.SetMode(gin.ReleaseMode) + + configPath := flag.String("config", "config.json", "path to config.json file") + flag.Parse() + global = GetConfig(*configPath) + + router := gin.Default() + m := InitMinify() + ServeStatic(router, m) + html := MinifyStatic(m, web.Templates) + tmpl = LoadHTMLToGin(router, html) + + router.GET("/account.html", handle_GET_Account) + router.GET("/", handle_GET_Index) + router.GET("/index.html", handle_GET_Index) + router.GET("/instance.html", handle_GET_Instance) + router.GET("/login.html", handle_GET_Login) + router.GET("/settings.html", handle_GET_Settings) + + router.GET("/instances_fragment", handle_GET_Instances_Fragment) + + log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", global.Port))) +} + func ServeStatic(router *gin.Engine, m *minify.M) { css := MinifyStatic(m, web.CSS_fs) router.GET("/css/*css", func(c *gin.Context) { @@ -40,84 +68,106 @@ func ServeStatic(router *gin.Engine, m *minify.M) { }) } -func Run() { - gin.SetMode(gin.ReleaseMode) - - configPath := flag.String("config", "config.json", "path to config.json file") - flag.Parse() - - global := GetConfig(*configPath) - - router := gin.Default() - m := InitMinify() - - ServeStatic(router, m) - - html := MinifyStatic(m, web.Templates) - LoadHTMLToGin(router, html) - - router.GET("/account.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/account.html", gin.H{ - "global": global, - "page": "account", - }) +func handle_GET_Account(c *gin.Context) { + c.HTML(http.StatusOK, "html/account.html", gin.H{ + "global": global, + "page": "account", + }) +} + +func handle_GET_Index(c *gin.Context) { + _, err := c.Cookie("auth") + token, _ := c.Cookie("PVEAuthCookie") + csrf, _ := c.Cookie("CSRFPreventionToken") + if err == nil { // user should be authed, try to return index with population + instances, _, err := get_API_resources(token, csrf) + if err != nil { + HandleNonFatalError(c, err) + } + c.HTML(http.StatusOK, "html/index.html", gin.H{ + "global": global, + "page": "index", + "instances": instances, + }) + } else { // return index without populating + c.HTML(http.StatusOK, "html/index.html", gin.H{ + "global": global, + "page": "index", + }) + } +} + +func handle_GET_Instances_Fragment(c *gin.Context) { + _, err := c.Cookie("auth") + token, _ := c.Cookie("PVEAuthCookie") + csrf, _ := c.Cookie("CSRFPreventionToken") + if err == nil { // user should be authed, try to return index with population + instances, _, err := get_API_resources(token, csrf) + if err != nil { + HandleNonFatalError(c, err) + } + c.Header("Content-Type", "text/plain") + tmpl.ExecuteTemplate(c.Writer, "templates/instances.frag", gin.H{ + "instances": instances, + }) + c.Status(http.StatusOK) + } else { // return index without populating + c.Status(http.StatusUnauthorized) + } + +} + +func handle_GET_Instance(c *gin.Context) { + c.HTML(http.StatusOK, "html/instance.html", gin.H{ + "global": global, + "page": "instance", + }) +} + +func handle_GET_Login(c *gin.Context) { + ctx := RequestContext{ + Cookies: nil, + Body: map[string]interface{}{}, + } + res, err := RequestGetAPI("/proxmox/access/domains", ctx) + if err != nil { + HandleNonFatalError(c, err) + return + } + if res.StatusCode != 200 { // we expect /access/domains to always be avaliable + HandleNonFatalError(c, err) + return + } + + realms := Select{ + ID: "realm", + Name: "realm", + } + + for _, v := range ctx.Body["data"].([]interface{}) { + v = v.(map[string]interface{}) + realm := Realm{} + err := mapstructure.Decode(v, &realm) + if err != nil { + HandleNonFatalError(c, err) + } + realms.Options = append(realms.Options, Option{ + Selected: realm.Default != 0, + Value: realm.Realm, + Display: realm.Comment, + }) + } + + c.HTML(http.StatusOK, "html/login.html", gin.H{ + "global": global, + "page": "login", + "realms": realms, + }) +} + +func handle_GET_Settings(c *gin.Context) { + c.HTML(http.StatusOK, "html/settings.html", gin.H{ + "global": global, + "page": "settings", }) - - router.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/index.html", gin.H{ - "global": global, - "page": "index", - }) - }) - - router.GET("/index.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/index.html", gin.H{ - "global": global, - "page": "index", - }) - }) - - router.GET("/instance.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/instance.html", gin.H{ - "global": global, - "page": "instance", - }) - }) - - router.GET("/login.html", func(c *gin.Context) { - response, err := http.Get(global.API + "/proxmox/access/domains") - if err != nil { - log.Fatal(err.Error()) - } - data, err := io.ReadAll(response.Body) - response.Body.Close() - body := GetRealmsBody{} - json.Unmarshal(data, &body) - realms := Select{ - ID: "realm", - Name: "realm", - } - for _, realm := range body.Data { - realms.Options = append(realms.Options, Option{ - Selected: realm.Default != 0, - Value: realm.Realm, - Display: realm.Comment, - }) - } - - c.HTML(http.StatusOK, "html/login.html", gin.H{ - "global": global, - "page": "login", - "realms": realms, - }) - }) - - router.GET("/settings.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/settings.html", gin.H{ - "global": global, - "page": "settings", - }) - }) - - router.Run(fmt.Sprintf("0.0.0.0:%d", global.Port)) } diff --git a/app/meta.go b/app/meta.go new file mode 100644 index 0000000..6c754f0 --- /dev/null +++ b/app/meta.go @@ -0,0 +1,161 @@ +package app + +import ( + "io" + + "github.com/tdewolff/minify" + "github.com/tdewolff/minify/css" + "github.com/tdewolff/minify/html" +) + +type MimeType struct { + Type string + Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error +} + +var MimeTypes = map[string]MimeType{ + "css": { + Type: "text/css", + Minifier: css.Minify, + }, + "html": { + Type: "text/html", + Minifier: html.Minify, + }, + "tmpl": { + Type: "text/plain", + Minifier: TemplateMinifier, + }, + "frag": { + Type: "text/plain", + Minifier: TemplateMinifier, + }, + "svg": { + Type: "image/svg+xml", + Minifier: nil, + }, + "js": { + Type: "application/javascript", + Minifier: nil, + }, + "wasm": { + Type: "application/wasm", + Minifier: nil, + }, + "*": { + Type: "text/plain", + Minifier: nil, + }, +} + +type Icon struct { + ID string + Src string + Alt string + Clickable bool +} + +var Icons = map[string]map[string]Icon{ + "running": { + "status": { + Src: "images/status/active.svg", + Alt: "Instance is running", + Clickable: false, + }, + "power": { + Src: "images/actions/instance/stop.svg", + Alt: "Shutdown Instance", + Clickable: true, + }, + "config": { + Src: "images/actions/instance/config-inactive.svg", + Alt: "Change Configuration (Inactive)", + Clickable: false, + }, + "console": { + Src: "images/actions/instance/console-active.svg", + Alt: "Open Console", + Clickable: true, + }, + "delete": { + Src: "images/actions/delete-inactive.svg", + Alt: "Delete Instance (Inactive)", + Clickable: false, + }, + }, + "stopped": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Instance is stopped", + Clickable: false, + }, + "power": { + Src: "images/actions/instance/start.svg", + Alt: "Start Instance", + Clickable: true, + }, + "config": { + Src: "images/actions/instance/config-active.svg", + Alt: "Change Configuration", + Clickable: true, + }, + "console": { + Src: "images/actions/instance/console-inactive.svg", + Alt: "Open Console (Inactive)", + Clickable: false, + }, + "delete": { + Src: "images/actions/delete-active.svg", + Alt: "Delete Instance", + Clickable: true, + }, + }, + "loading": { + "status": { + Src: "images/status/loading.svg", + Alt: "Instance is loading", + Clickable: false, + }, + "power": { + Src: "images/status/loading.svg", + Alt: "Loading Instance", + Clickable: false, + }, + "config": { + Src: "images/actions/instance/config-inactive.svg", + Alt: "Change Configuration (Inactive)", + Clickable: false, + }, + "console": { + Src: "images/actions/instance/console-inactive.svg", + Alt: "Open Console (Inactive)", + Clickable: false, + }, + "delete": { + Src: "images/actions/delete-inactive.svg", + Alt: "Delete Instance (Inactive)", + Clickable: false, + }, + }, + "online": { + "status": { + Src: "images/status/active.svg", + Alt: "Node is online", + Clickable: false, + }, + }, + "offline": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Node is offline", + Clickable: false, + }, + }, + "uknown": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Node is offline", + Clickable: false, + }, + }, +} diff --git a/app/types.go b/app/types.go index ed64ab5..1a72cda 100644 --- a/app/types.go +++ b/app/types.go @@ -1,49 +1,16 @@ package app -import ( - "io" - - "github.com/tdewolff/minify" - "github.com/tdewolff/minify/css" - "github.com/tdewolff/minify/html" - "github.com/tdewolff/minify/js" -) - -type MimeType struct { - Type string - Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error +type Config struct { + Port int `json:"listenPort"` + Organization string `json:"organization"` + DASH string `json:"dashurl"` + PVE string `json:"pveurl"` + API string `json:"apiurl"` } -var PlainTextMimeType = MimeType{ - Type: "text/plain", - Minifier: nil, -} - -var MimeTypes = map[string]MimeType{ - "css": { - Type: "text/css", - Minifier: css.Minify, - }, - "html": { - Type: "text/html", - Minifier: html.Minify, - }, - "tmpl": { - Type: "text/plain", - Minifier: TemplateMinifier, - }, - "svg": { - Type: "image/svg+xml", - Minifier: nil, - }, - "js": { - Type: "application/javascript", - Minifier: js.Minify, - }, - "wasm": { - Type: "application/wasm", - Minifier: nil, - }, +type StaticFile struct { + Data string + MimeType MimeType } // used when requesting GET /access/domains @@ -71,3 +38,32 @@ type Option struct { Value string Display string } + +type RequestType int + +type RequestContext struct { + Cookies map[string]string + Body map[string]interface{} +} + +// used in constructing instance cards in index +type Node struct { + Node string `json:"node"` + Status string `json:"status"` +} + +// used in constructing instance cards in index +type Instance struct { + VMID uint + Name string + Type string + Status string + Node string + StatusIcon Icon + NodeStatus string + NodeStatusIcon Icon + PowerBtnIcon Icon + ConsoleBtnIcon Icon + ConfigureBtnIcon Icon + DeleteBtnIcon Icon +} diff --git a/app/utils.go b/app/utils.go index c42acab..8b958d9 100644 --- a/app/utils.go +++ b/app/utils.go @@ -4,24 +4,20 @@ import ( "bufio" "embed" "encoding/json" + "fmt" "html/template" "io" "io/fs" "log" + "net/http" "os" "strings" "github.com/gin-gonic/gin" + "github.com/go-viper/mapstructure/v2" "github.com/tdewolff/minify" ) -type Config struct { - Port int `json:"listenPort"` - Organization string `json:"organization"` - PVE string `json:"pveurl"` - API string `json:"apiurl"` -} - func GetConfig(configPath string) Config { content, err := os.ReadFile(configPath) if err != nil { @@ -45,11 +41,6 @@ func InitMinify() *minify.M { return m } -type StaticFile struct { - Data string - MimeType MimeType -} - func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { minified := make(map[string]StaticFile) fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { @@ -80,9 +71,10 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { } } } else { // if the file has no extension, skip minify + mimetype := MimeTypes["*"] minified[path] = StaticFile{ Data: string(v), - MimeType: PlainTextMimeType, + MimeType: mimetype, } } } @@ -91,10 +83,11 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { return minified } -func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) { +func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template { root := template.New("") tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html)) engine.SetHTMLTemplate(tmpl) + return tmpl } func LoadAndAddToRoot(FuncMap template.FuncMap, root *template.Template, html map[string]StaticFile) error { @@ -128,3 +121,103 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string } return nil } + +func HandleNonFatalError(c *gin.Context, err error) { + log.Printf("[Error] encountered an error: %s", err.Error()) + c.Status(http.StatusInternalServerError) +} + +func RequestGetAPI(path string, context RequestContext) (*http.Response, error) { + req, err := http.NewRequest("GET", global.API+path, nil) + if err != nil { + return nil, err + } + for k, v := range context.Cookies { + req.AddCookie(&http.Cookie{Name: k, Value: v}) + } + + client := &http.Client{} + response, err := client.Do(req) + if err != nil { + return nil, err + } else if response.StatusCode != 200 { + return response, nil + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(data, &context.Body) + if err != nil { + return nil, err + } + + return response, nil + +} + +func get_API_resources(token string, csrf string) (map[uint]Instance, map[string]Node, error) { + ctx := RequestContext{ + Cookies: map[string]string{ + "PVEAuthCookie": token, + "CSRFPreventionToken": csrf, + }, + Body: map[string]interface{}{}, + } + res, err := RequestGetAPI("/proxmox/cluster/resources", ctx) + if err != nil { + return nil, nil, err + } + + instances := map[uint]Instance{} + nodes := map[string]Node{} + + if res.StatusCode == 200 { // if we successfully retrieved the resources, then process it and return index + for _, v := range ctx.Body["data"].([]interface{}) { + m := v.(map[string]interface{}) + if m["type"] == "node" { + node := Node{} + err := mapstructure.Decode(v, &node) + if err != nil { + return nil, nil, err + } + nodes[node.Node] = node + } else if m["type"] == "lxc" || m["type"] == "qemu" { + instance := Instance{} + err := mapstructure.Decode(v, &instance) + if err != nil { + return nil, nil, err + } + instances[instance.VMID] = instance + } + } + for vmid, instance := range instances { + status := instance.Status + icons := Icons[status] + instance.StatusIcon = icons["status"] + instance.PowerBtnIcon = icons["power"] + instance.PowerBtnIcon.ID = "power-btn" + instance.ConfigureBtnIcon = icons["config"] + instance.ConfigureBtnIcon.ID = "configure-btn" + instance.ConsoleBtnIcon = icons["console"] + instance.ConsoleBtnIcon.ID = "console-btn" + instance.DeleteBtnIcon = icons["delete"] + instance.DeleteBtnIcon.ID = "delete-btn" + nodestatus := nodes[instance.Node].Status + icons = Icons[nodestatus] + instance.NodeStatus = nodestatus + instance.NodeStatusIcon = icons["status"] + instances[vmid] = instance + } + return instances, nodes, nil + } else { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow + return nil, nil, fmt.Errorf("request to /cluster/resources/ resulted in %+v", res) + } +} diff --git a/configs/.htmlvalidate.json b/configs/.htmlvalidate.json index 830dceb..6db1c12 100644 --- a/configs/.htmlvalidate.json +++ b/configs/.htmlvalidate.json @@ -4,5 +4,13 @@ ], "rules": { "no-inline-style": "off" - } + }, + "elements": [ + "html5", + { + "head": { + "requiredContent": [] + } + } + ] } \ No newline at end of file diff --git a/configs/template.config.json b/configs/template.config.json index 24fb653..f8a0bac 100644 --- a/configs/template.config.json +++ b/configs/template.config.json @@ -1,6 +1,7 @@ { "listenPort": 8080, "organization": "myorg", + "dashurl": "https://paas.mydomain.example", "apiurl": "https://paas.mydomain.example/api", "pveurl": "https://pve.mydomain.example" } \ No newline at end of file diff --git a/go.mod b/go.mod index 1a19365..ca22a17 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/gin-gonic/gin v1.10.0 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/tdewolff/minify v2.3.6+incompatible ) diff --git a/web/html/index.html b/web/html/index.html index 339c943..1ee8113 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -54,7 +54,11 @@

Host Status

Actions

-
+
+ {{range .instances}} + {{template "instance" .}} + {{end}} +
diff --git a/web/scripts/clientsync.js b/web/scripts/clientsync.js index ca84e59..58d39a4 100644 --- a/web/scripts/clientsync.js +++ b/web/scripts/clientsync.js @@ -4,13 +4,11 @@ export async function setupClientSync (callback) { const { scheme, rate } = getSyncSettings(); if (scheme === "always") { - callback(); window.setInterval(callback, rate * 1000); } else if (scheme === "hash") { const newHash = (await requestAPI("/sync/hash")).data; localStorage.setItem("sync-current-hash", newHash); - callback(); window.setInterval(async () => { const newHash = (await requestAPI("/sync/hash")).data; if (localStorage.getItem("sync-current-hash") !== newHash) { @@ -20,7 +18,6 @@ export async function setupClientSync (callback) { }, rate * 1000); } else if (scheme === "interrupt") { - callback(); const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`); socket.addEventListener("open", (event) => { socket.send(`rate ${rate}`); diff --git a/web/scripts/index.js b/web/scripts/index.js index a57ba98..25db0fb 100644 --- a/web/scripts/index.js +++ b/web/scripts/index.js @@ -1,75 +1,74 @@ -import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, instancesConfig, nodesConfig, setSVGSrc, setSVGAlt } from "./utils.js"; +import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, getInstancesFragment } from "./utils.js"; import { alert, dialog } from "./dialog.js"; import { setupClientSync } from "./clientsync.js"; import wfaInit from "../modules/wfa.js"; class InstanceCard extends HTMLElement { + actionLock = false; + shadowRoot = null; + constructor () { super(); - this.attachShadow({ mode: "open" }); - this.shadowRoot.innerHTML = ` - - - - -
-
-

-

-

-
- -

-
-

-
- -

-
-
- - - - -
-
- `; + const internals = this.attachInternals(); + this.shadowRoot = internals.shadowRoot; this.actionLock = false; } - get data () { + get type () { + return this.dataset.type; + } + + set type (type) { + this.dataset.type = type; + } + + get status () { + return this.dataset.status; + } + + set status (status) { + this.dataset.status = status; + } + + get vmid () { + return this.dataset.vmid; + } + + set vmid (vmid) { + this.dataset.vmid = vmid; + } + + get name () { + return this.dataset.name; + } + + set name (name) { + this.dataset.name = name; + } + + get node () { return { - type: this.type, - status: this.status, - vmid: this.status, - name: this.name, - node: this.node, - searchQuery: this.searchQuery + name: this.dataset.node, + status: this.dataset.nodestatus }; } - set data (data) { - if (data.status === "unknown") { - data.status = "stopped"; - } - this.type = data.type; - this.status = data.status; - this.vmid = data.vmid; - this.name = data.name; - this.node = data.node; - this.searchQueryResult = data.searchQueryResult; - this.update(); + set node (node) { + this.dataset.node = node.name; + this.dataset.nodetsatus = node.status; + } + + set searchQueryResult (result) { + this.dataset.searchqueryresult = JSON.stringify(result); + } + + get searchQueryResult () { + return JSON.parse(!this.dataset.searchqueryresult ? "{}" : this.dataset.searchqueryresult); } update () { - const vmidParagraph = this.shadowRoot.querySelector("#instance-id"); - vmidParagraph.innerText = this.vmid; - const nameParagraph = this.shadowRoot.querySelector("#instance-name"); + nameParagraph.innerText = ""; if (this.searchQueryResult.alignment) { let i = 0; // name index let c = 0; // alignment index @@ -106,55 +105,24 @@ class InstanceCard extends HTMLElement { nameParagraph.innerHTML = this.name ? this.name : " "; } - const typeParagraph = this.shadowRoot.querySelector("#instance-type"); - typeParagraph.innerText = this.type; - - const statusParagraph = this.shadowRoot.querySelector("#instance-status"); - statusParagraph.innerText = this.status; - - const statusIcon = this.shadowRoot.querySelector("#instance-status-icon"); - setSVGSrc(statusIcon, instancesConfig[this.status].status.src); - setSVGAlt(statusIcon, instancesConfig[this.status].status.alt); - - const nodeNameParagraph = this.shadowRoot.querySelector("#node-name"); - nodeNameParagraph.innerText = this.node.name; - - const nodeStatusParagraph = this.shadowRoot.querySelector("#node-status"); - nodeStatusParagraph.innerText = this.node.status; - - const nodeStatusIcon = this.shadowRoot.querySelector("#node-status-icon"); - setSVGSrc(nodeStatusIcon, nodesConfig[this.node.status].status.src); - setSVGAlt(nodeStatusIcon, nodesConfig[this.node.status].status.alt); - const powerButton = this.shadowRoot.querySelector("#power-btn"); - setSVGSrc(powerButton, instancesConfig[this.status].power.src); - setSVGAlt(powerButton, instancesConfig[this.status].power.alt); - if (instancesConfig[this.status].power.clickable) { - powerButton.classList.add("clickable"); + if (powerButton.classList.contains("clickable")) { powerButton.onclick = this.handlePowerButton.bind(this); } const configButton = this.shadowRoot.querySelector("#configure-btn"); - setSVGSrc(configButton, instancesConfig[this.status].config.src); - setSVGAlt(configButton, instancesConfig[this.status].config.alt); - if (instancesConfig[this.status].config.clickable) { - configButton.classList.add("clickable"); + if (configButton.classList.contains("clickable")) { configButton.onclick = this.handleConfigButton.bind(this); } const consoleButton = this.shadowRoot.querySelector("#console-btn"); - setSVGSrc(consoleButton, instancesConfig[this.status].console.src); - setSVGAlt(consoleButton, instancesConfig[this.status].console.alt); - if (instancesConfig[this.status].console.clickable) { + if (consoleButton.classList.contains("clickable")) { consoleButton.classList.add("clickable"); consoleButton.onclick = this.handleConsoleButton.bind(this); } const deleteButton = this.shadowRoot.querySelector("#delete-btn"); - setSVGSrc(deleteButton, instancesConfig[this.status].delete.src); - setSVGAlt(deleteButton, instancesConfig[this.status].delete.alt); - if (instancesConfig[this.status].delete.clickable) { - deleteButton.classList.add("clickable"); + if (deleteButton.classList.contains("clickable")) { deleteButton.onclick = this.handleDeleteButton.bind(this); } @@ -217,7 +185,7 @@ class InstanceCard extends HTMLElement { handleConsoleButton () { if (!this.actionLock && this.status === "running") { - const data = { console: `${this.type === "qemu" ? "kvm" : "lxc"}`, vmid: this.vmid, vmname: this.name, node: this.node.name, resize: "off", cmd: "" }; + const data = { console: `${this.type === "qemu" ? "kvm" : "lxc"}`, vmid: this.vmid, vmname: this.name, node: this.node, resize: "off", cmd: "" }; data[`${this.type === "qemu" ? "novnc" : "xtermjs"}`] = 1; goToURL(window.PVE, data, true); } @@ -260,8 +228,6 @@ customElements.define("instance-card", InstanceCard); window.addEventListener("DOMContentLoaded", init); -let instances = []; - async function init () { setAppearance(); const cookie = document.cookie; @@ -270,32 +236,37 @@ async function init () { } wfaInit("modules/wfa.wasm"); + initInstances(); document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd); - document.querySelector("#vm-search").addEventListener("input", populateInstances); + document.querySelector("#vm-search").addEventListener("input", sortInstances); setupClientSync(refreshInstances); } async function refreshInstances () { - await getInstances(); - await populateInstances(); + let instances = await getInstancesFragment(); + if (instances.status !== 200) { + alert("Error fetching instances."); + } + else { + instances = instances.data; + const container = document.querySelector("#instance-container"); + container.setHTMLUnsafe(instances); + sortInstances(); + } } -async function getInstances () { - const resources = await requestPVE("/cluster/resources", "GET"); - instances = []; - resources.data.forEach((element) => { - if (element.type === "lxc" || element.type === "qemu") { - const nodeName = element.node; - const nodeStatus = resources.data.find(item => item.node === nodeName && item.type === "node").status; - element.node = { name: nodeName, status: nodeStatus }; - instances.push(element); - } - }); +function initInstances () { + const container = document.querySelector("#instance-container"); + let instances = container.children; + instances = [].slice.call(instances); + for (let i = 0; i < instances.length; i++) { + instances[i].update(); + } } -async function populateInstances () { +function sortInstances () { const searchCriteria = getSearchSettings(); const searchQuery = document.querySelector("#search").value || null; let criteria; @@ -334,22 +305,16 @@ async function populateInstances () { return { score: score / item.length, alignment }; }; } - sortInstances(criteria, searchQuery); - const instanceContainer = document.querySelector("#instance-container"); - instanceContainer.innerHTML = ""; - for (let i = 0; i < instances.length; i++) { - const newInstance = document.createElement("instance-card"); - newInstance.data = instances[i]; - instanceContainer.append(newInstance); - } -} -function sortInstances (criteria, searchQuery) { + const container = document.querySelector("#instance-container"); + let instances = container.children; + instances = [].slice.call(instances); + for (let i = 0; i < instances.length; i++) { - if (!instances[i].name) { // if the instance has no name, assume its just empty string - instances[i].name = ""; + if (!instances[i].dataset.name) { // if the instance has no name, assume its just empty string + instances[i].dataset.name = ""; } - const { score, alignment } = criteria(instances[i].name.toLowerCase(), searchQuery ? searchQuery.toLowerCase() : ""); + const { score, alignment } = criteria(instances[i].dataset.name.toLowerCase(), searchQuery ? searchQuery.toLowerCase() : ""); instances[i].searchQueryResult = { score, alignment }; } const sortCriteria = (a, b) => { @@ -362,7 +327,13 @@ function sortInstances (criteria, searchQuery) { return aScore - bScore; } }; + instances.sort(sortCriteria); + + for (let i = 0; i < instances.length; i++) { + container.appendChild(instances[i]); + instances[i].update(); + } } async function handleInstanceAdd () { @@ -425,11 +396,11 @@ async function handleInstanceAdd () { const vmid = form.get("vmid"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/create`, "POST", body); if (result.status === 200) { - populateInstances(); + refreshInstances(); } else { alert(`Attempted to create new instance ${vmid} but got: ${result.error}`); - populateInstances(); + refreshInstances(); } } }); diff --git a/web/scripts/utils.js b/web/scripts/utils.js index 26bf46e..76c2402 100644 --- a/web/scripts/utils.js +++ b/web/scripts/utils.js @@ -247,6 +247,21 @@ export async function requestAPI (path, method, body = null) { return response; } +export async function getInstancesFragment () { + const content = { + method: "GET", + mode: "cors", + credentials: "include", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }; + content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken"); + + const response = await request(`${window.DASH}/instances_fragment`, content); + return response; +} + async function request (url, content) { try { const response = await fetch(url, content); @@ -260,6 +275,10 @@ async function request (url, content) { data = { data: await response.text() }; data.status = response.status; } + else if (contentType.includes("text/plain")) { + data = { data: await response.text() }; + data.status = response.status; + } else { data = response; } diff --git a/web/templates/base.tmpl b/web/templates/base.tmpl index a6b060b..7539198 100644 --- a/web/templates/base.tmpl +++ b/web/templates/base.tmpl @@ -8,6 +8,7 @@ diff --git a/web/templates/instance.tmpl b/web/templates/instance.tmpl new file mode 100644 index 0000000..db4c437 --- /dev/null +++ b/web/templates/instance.tmpl @@ -0,0 +1,35 @@ +{{define "instance"}} + + + +{{end}} \ No newline at end of file diff --git a/web/templates/instances.frag b/web/templates/instances.frag new file mode 100644 index 0000000..c3fffa2 --- /dev/null +++ b/web/templates/instances.frag @@ -0,0 +1,3 @@ +{{range .instances}} + {{template "instance" .}} +{{end}} \ No newline at end of file diff --git a/web/templates/svg.tmpl b/web/templates/svg.tmpl new file mode 100644 index 0000000..ffb6c8c --- /dev/null +++ b/web/templates/svg.tmpl @@ -0,0 +1,7 @@ +{{define "svg"}} + {{if .Clickable}} + + {{else}} + + {{end}} +{{end}} \ No newline at end of file