diff --git a/.gitignore b/.gitignore index 0b57df7..85caad2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ **/config.json -**/package-lock.json -**/node_modules dist/* go.sum \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index 69f71d5..41fb0f1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "proxmoxaas-common-lib"] path = proxmoxaas-common-lib url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib +[submodule "WFA-JS"] + path = WFA-JS + url = https://git.tronnet.net/alu/WFA-JS diff --git a/Makefile b/Makefile index a014114..2f66934 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,27 @@ -.PHONY: build test clean +.PHONY: build build-web build-wfa-js clean clean-wfa-js ensure-dist workflow-init -build: clean +build: clean ensure-dist build-web build-wfa-js @echo "======================== Building Binary =======================" # resolve symbolic links in web by copying it into dist/web/ + CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ . + +build-web: ensure-dist cp -rL web/ dist/web/ - CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ . -test: clean - go run . +build-wfa-js: ensure-dist + $(MAKE) -C WFA-JS + cp -f WFA-JS/dist/* web/modules -clean: +clean: clean-wfa-js @echo "======================== Cleaning Project ======================" go clean rm -rf dist/* + +clean-wfa-js: + $(MAKE) clean -C WFA-JS + +ensure-dist: + mkdir -p dist + +workflow-init: ensure-dist build-web + cp -r dev_config/. . \ No newline at end of file diff --git a/README.md b/README.md index 36ff4be..cbae84d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service ## ProxmoxAAS System Installation Overview -The ProxmoxAAS project is large and is split into multiple components. There are three required components, the Dashboard, API, and Fabric. There is also an optional LDAP component for organizations that want to use LDAP as their authentication backend. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process. +The ProxmoxAAS project is large and is split into multiple components. There are four required components, the Dashboard, API, Fabric, and access-manager-api. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process. The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs. @@ -25,7 +25,7 @@ We will assume different hosts for each component which are accessible by unique | ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net | | ProxmoxAAS-API | api.local | paas.domain.net/api/ | | ProxmoxAAS-Fabric | fabric.local | N/A | -| ProxmoxAAS-LDAP | ldap.local | N/A| +| access-manager-api | access.local | N/A| ## Prerequisites - Dashboard - Proxmox VE Cluster (v7.0+) @@ -36,11 +36,11 @@ We will assume different hosts for each component which are accessible by unique 1. Initialize any host, which will be the `ProxmoxAAS-Dashboard` component host 2. Download `proxmoxaas-dashboard` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases) Rename `template.config.json` to `config.json` and modify: - - listenPort: port for PAAS-Dashboard to bind and listen on - - organization: name of your org which is displayed on the top left corner - - dashurl: url for the dashboard, ie. `https://paas.domain.net` - - apiurl: url for PAAS-API, ie. `https://paas.domain.net/api` - - pveurl: url for the Proxmox endpoint, ie. `https://pve.domain.net` + - `listenPort`: port for PAAS-Dashboard to bind and listen on + - `organization`: name of your org which is displayed on the top left corner + - `dashurl`: url for the dashboard, ie. `https://paas.domain.net` + - `apiurl`: url for PAAS-API, ie. `https://paas.domain.net/api` + - `pveurl`: url for the Proxmox endpoint, ie. `https://pve.domain.net` 3. Execute the binary or additionally download `proxmoxaas-dashboard.service` from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases) to run using systemd After this step, the Dashboard should be available on the `ProxmoxAAS-Dashboard` host at the configured `listenPort` @@ -53,9 +53,9 @@ To install the API component, go to [ProxmoxAAS-API](https://git.tronnet.net/tro To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function. The Fabric installation will also have steps for setting up the reverse proxy server. -## Installation - LDAP +## Installation - access-manager-api -To install the LDAP component, go to [ProxmoxAAS-LDAP](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP).This is an optional component which adds a lightweight REST API server ontop of a simplified LDAP environment. It is only used by the API as a potential authentication backend. +To install the access-manager-api component, go to [access-manager-api](https://git.tronnet.net/tronnet/access-manager-api). This is required for the app to function. The access-manager-api installation will also have steps for setting up the reverse proxy server. ## Installation - Reverse Proxy 1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. The configuration should include at least the following, ensuring that the configured ports are adjusted appropriately: diff --git a/WFA-JS b/WFA-JS new file mode 160000 index 0000000..f53f344 --- /dev/null +++ b/WFA-JS @@ -0,0 +1 @@ +Subproject commit f53f344fb35dc118d55a2135123ddf64801d0cd3 diff --git a/app/app.go b/app/app.go index 81d97b7..aabd32e 100644 --- a/app/app.go +++ b/app/app.go @@ -1,17 +1,21 @@ package app import ( + "flag" "fmt" "log" "proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/routes" - "proxmoxaas-dashboard/dist/web" // go will complain here until the first build + "web" // go will complain here until the first build "github.com/gin-gonic/gin" "github.com/tdewolff/minify/v2" ) -func Run(configPath *string) { +func Run() { + configPath := flag.String("config", "config.json", "path to config.json file") + flag.Parse() + common.Global = common.GetConfig(*configPath) // setup static resources @@ -38,7 +42,7 @@ func Run(configPath *string) { router.GET("/settings", routes.HandleGETSettings) // run on all interfaces with port - log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port))) + log.Fatal("[ERR ] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port))) } // setup static resources under web (css, images, modules, scripts) diff --git a/app/common/meta_debug.go b/app/common/meta_debug.go new file mode 100644 index 0000000..e31203b --- /dev/null +++ b/app/common/meta_debug.go @@ -0,0 +1,51 @@ +//go:build !release + +package common + +import ( + "io" + + "github.com/tdewolff/minify/v2" +) + +// defines mime type and associated minifier +type MimeType struct { + Type string + Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error +} + +// debug mime types +var MimeTypes = map[string]MimeType{ + "css": { + Type: "text/css", + Minifier: nil, + }, + "html": { + Type: "text/html", + Minifier: nil, + }, + "tmpl": { + Type: "text/plain", + Minifier: nil, + }, + "frag": { + Type: "text/plain", + Minifier: nil, + }, + "svg": { + Type: "image/svg+xml", + Minifier: nil, + }, + "js": { + Type: "application/javascript", + Minifier: nil, + }, + "wasm": { + Type: "application/wasm", + Minifier: nil, + }, + "*": { + Type: "text/plain", + Minifier: nil, + }, +} diff --git a/app/common/meta.go b/app/common/meta_release.go similarity index 60% rename from app/common/meta.go rename to app/common/meta_release.go index 5b77bb2..fd736bd 100644 --- a/app/common/meta.go +++ b/app/common/meta_release.go @@ -1,3 +1,5 @@ +//go:build release + package common import ( @@ -38,10 +40,6 @@ var MimeTypes = map[string]MimeType{ Type: "image/svg+xml", Minifier: svg.Minify, }, - "png": { - Type: "image/png", - Minifier: nil, - }, "js": { Type: "application/javascript", Minifier: js.Minify, @@ -55,45 +53,3 @@ var MimeTypes = map[string]MimeType{ Minifier: nil, }, } - -// debug mime types -/* -var MimeTypes = map[string]MimeType{ - "css": { - Type: "text/css", - Minifier: nil, - }, - "html": { - Type: "text/html", - Minifier: nil, - }, - "tmpl": { - Type: "text/plain", - Minifier: nil, - }, - "frag": { - Type: "text/plain", - Minifier: nil, - }, - "svg": { - Type: "image/svg+xml", - Minifier: nil, - }, - "png": { - Type: "image/png", - Minifier: nil, - }, - "js": { - Type: "application/javascript", - Minifier: nil, - }, - "wasm": { - Type: "application/wasm", - Minifier: nil, - }, - "*": { - Type: "text/plain", - Minifier: nil, - }, -} -*/ diff --git a/app/common/types.go b/app/common/types.go index f38ad5c..a03a53b 100644 --- a/app/common/types.go +++ b/app/common/types.go @@ -22,13 +22,6 @@ type StaticFile struct { MimeType MimeType } -// parsed vmpath data (ie node/type/vmid) -type VMPath struct { - Node string - Type string - VMID string -} - // type used for templated @@ -84,7 +82,7 @@ diff --git a/web/html/login.html b/web/html/login.html index e5045e6..05796f6 100644 --- a/web/html/login.html +++ b/web/html/login.html @@ -7,9 +7,7 @@ -
- {{template "header" .}} -
+ {{template "header" .}}

{{.global.Organization}} Login

diff --git a/web/html/settings.html b/web/html/settings.html index 3c431b0..3aaa1b3 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -5,30 +5,31 @@ -
- {{template "header" .}} -
+ {{template "header" .}}

Settings

@@ -42,6 +43,8 @@

App will periodically check for updates and synchronize only if needed. Medium resource usage.

App will react to changes and synchronize when changes are made. Low resource usage.

+ +

App will never automatically sync. Reload the page to sync the latest cluster state.

App Sync Frequency @@ -73,7 +76,7 @@
- +
diff --git a/web/images/actions/instance/config-inactive.svg b/web/images/actions/instance/config-inactive.svg deleted file mode 100644 index 5b313f5..0000000 --- a/web/images/actions/instance/config-inactive.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/web/images/actions/instance/config-inactive.svg b/web/images/actions/instance/config-inactive.svg new file mode 120000 index 0000000..56a64eb --- /dev/null +++ b/web/images/actions/instance/config-inactive.svg @@ -0,0 +1 @@ +../../common/config-inactive.svg \ No newline at end of file diff --git a/web/images/common/config-inactive.svg b/web/images/common/config-inactive.svg new file mode 100644 index 0000000..5b313f5 --- /dev/null +++ b/web/images/common/config-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/modules/wfa.js b/web/modules/wfa.js index 282f463..67bae2c 100644 --- a/web/modules/wfa.js +++ b/web/modules/wfa.js @@ -1,2 +1,579 @@ -(()=>{if(typeof global!="undefined");else if(typeof window!="undefined")window.global=window;else if(typeof self!="undefined")self.global=self;else throw new Error("cannot export Go (neither global, window nor self is defined)");!global.require&&typeof require!="undefined"&&(global.require=require),!global.fs&&global.require&&(global.fs=require("node:fs"));const e=()=>{const e=new Error("not implemented");return e.code="ENOSYS",e};if(!global.fs){let t="";global.fs={constants:{O_WRONLY:-1,O_RDWR:-1,O_CREAT:-1,O_TRUNC:-1,O_APPEND:-1,O_EXCL:-1},writeSync(e,n){t+=s.decode(n);const o=t.lastIndexOf(` -`);return o!=-1&&(console.log(t.substr(0,o)),t=t.substr(o+1)),n.length},write(t,n,s,o,i,a){if(s!==0||o!==n.length||i!==null){a(e());return}const r=this.writeSync(t,n);a(null,r)},chmod(t,n,s){s(e())},chown(t,n,s,o){o(e())},close(t,n){n(e())},fchmod(t,n,s){s(e())},fchown(t,n,s,o){o(e())},fstat(t,n){n(e())},fsync(e,t){t(null)},ftruncate(t,n,s){s(e())},lchown(t,n,s,o){o(e())},link(t,n,s){s(e())},lstat(t,n){n(e())},mkdir(t,n,s){s(e())},open(t,n,s,o){o(e())},read(t,n,s,o,i,a){a(e())},readdir(t,n){n(e())},readlink(t,n){n(e())},rename(t,n,s){s(e())},rmdir(t,n){n(e())},stat(t,n){n(e())},symlink(t,n,s){s(e())},truncate(t,n,s){s(e())},unlink(t,n){n(e())},utimes(t,n,s,o){o(e())}}}if(global.process||(global.process={getuid(){return-1},getgid(){return-1},geteuid(){return-1},getegid(){return-1},getgroups(){throw e()},pid:-1,ppid:-1,umask(){throw e()},cwd(){throw e()},chdir(){throw e()}}),!global.crypto){const e=require("node:crypto");global.crypto={getRandomValues(t){e.randomFillSync(t)}}}global.performance||(global.performance={now(){const[e,t]=process.hrtime();return e*1e3+t/1e6}}),global.TextEncoder||(global.TextEncoder=require("node:util").TextEncoder),global.TextDecoder||(global.TextDecoder=require("node:util").TextDecoder);const i=new TextEncoder("utf-8"),s=new TextDecoder("utf-8");let t=new DataView(new ArrayBuffer(8));var o=[];const n={};if(global.Go=class{constructor(){this._callbackTimeouts=new Map,this._nextCallbackTimeoutID=1;const e=()=>new DataView(this._inst.exports.memory.buffer),a=e=>{t.setBigInt64(0,e,!0);const n=t.getFloat64(0,!0);if(n===0)return void 0;if(!isNaN(n))return n;const s=e&4294967295n;return this._values[s]},h=t=>{let n=e().getBigUint64(t,!0);return a(n)},l=e=>{const s=2146959360n;if(typeof e=="number")return isNaN(e)?s<<32n:e===0?s<<32n|1n:(t.setFloat64(0,e,!0),t.getBigInt64(0,!0));switch(e){case void 0:return 0n;case null:return s<<32n|2n;case!0:return s<<32n|3n;case!1:return s<<32n|4n}let n=this._ids.get(e);n===void 0&&(n=this._idPool.pop(),n===void 0&&(n=BigInt(this._values.length)),this._values[n]=e,this._goRefCounts[n]=0,this._ids.set(e,n)),this._goRefCounts[n]++;let o=1n;switch(typeof e){case"string":o=2n;break;case"symbol":o=3n;break;case"function":o=4n;break}return n|(s|o)<<32n},r=(t,n)=>{let s=l(n);e().setBigUint64(t,s,!0)},d=(e,t)=>new Uint8Array(this._inst.exports.memory.buffer,e,t),u=(e,t)=>{const s=new Array(t);for(let n=0;ns.decode(new DataView(this._inst.exports.memory.buffer,e,t)),m=Date.now()-performance.now();this.importObject={wasi_snapshot_preview1:{fd_write:function(t,n,i,a){let r=0;if(t==1)for(let t=0;t0,fd_fdstat_get:()=>0,fd_seek:()=>0,proc_exit:e=>{throw this.exited=!0,this.exitCode=e,this._resolveExitPromise(),n},random_get:(e,t)=>(crypto.getRandomValues(d(e,t)),0)},gojs:{"runtime.ticks":()=>BigInt((m+performance.now())*1e6),"runtime.sleepTicks":e=>{setTimeout(()=>{if(this.exited)return;try{this._inst.exports.go_scheduler()}catch(e){if(e!==n)throw e}},Number(e)/1e6)},"syscall/js.finalizeRef":e=>{const t=e&4294967295n;if(this._goRefCounts?.[t]!==void 0){if(this._goRefCounts[t]--,this._goRefCounts[t]===0){const e=this._values[t];this._values[t]=null,this._ids.delete(e),this._idPool.push(t)}}else console.error("syscall/js.finalizeRef: unknown id",t)},"syscall/js.stringVal":(e,t)=>{e>>>=0;const n=c(e,t);return l(n)},"syscall/js.valueGet":(e,t,n)=>{let s=c(t,n),o=a(e),i=Reflect.get(o,s);return l(i)},"syscall/js.valueSet":(e,t,n,s)=>{const o=a(e),i=c(t,n),r=a(s);Reflect.set(o,i,r)},"syscall/js.valueDelete":(e,t,n)=>{const s=a(e),o=c(t,n);Reflect.deleteProperty(s,o)},"syscall/js.valueIndex":(e,t)=>l(Reflect.get(a(e),t)),"syscall/js.valueSetIndex":(e,t,n)=>{Reflect.set(a(e),t,a(n))},"syscall/js.valueCall":(t,n,s,o,i,l,d)=>{const h=a(n),m=c(s,o),f=u(i,l,d);try{const n=Reflect.get(h,m);r(t,Reflect.apply(n,h,f)),e().setUint8(t+8,1)}catch(n){r(t,n),e().setUint8(t+8,0)}},"syscall/js.valueInvoke":(t,n,s,o,i)=>{try{const c=a(n),l=u(s,o,i);r(t,Reflect.apply(c,void 0,l)),e().setUint8(t+8,1)}catch(n){r(t,n),e().setUint8(t+8,0)}},"syscall/js.valueNew":(t,n,s,o,i)=>{const c=a(n),l=u(s,o,i);try{r(t,Reflect.construct(c,l)),e().setUint8(t+8,1)}catch(n){r(t,n),e().setUint8(t+8,0)}},"syscall/js.valueLength":e=>a(e).length,"syscall/js.valuePrepareString":(t,n)=>{const o=String(a(n)),s=i.encode(o);r(t,s),e().setInt32(t+8,s.length,!0)},"syscall/js.valueLoadString":(e,t,n,s)=>{const o=a(e);d(t,n,s).set(o)},"syscall/js.valueInstanceOf":(e,t)=>a(e)instanceof a(t),"syscall/js.copyBytesToGo":(t,n,s,o,i)=>{let h=t,c=t+4;const l=d(n,s),r=a(i);if(!(r instanceof Uint8Array||r instanceof Uint8ClampedArray)){e().setUint8(c,0);return}const u=r.subarray(0,l.length);l.set(u),e().setUint32(h,u.length,!0),e().setUint8(c,1)},"syscall/js.copyBytesToJS":(t,n,s,o)=>{let u=t,c=t+4;const r=a(n),h=d(s,o);if(!(r instanceof Uint8Array||r instanceof Uint8ClampedArray)){e().setUint8(c,0);return}const l=h.subarray(0,r.length);r.set(l),e().setUint32(u,l.length,!0),e().setUint8(c,1)}}},this.importObject.env=this.importObject.gojs}async run(e){if(this._inst=e,this._values=[NaN,0,null,!0,!1,global,this],this._goRefCounts=[],this._ids=new Map,this._idPool=[],this.exited=!1,this.exitCode=0,this._inst.exports._start){let e=new Promise((e)=>{this._resolveExitPromise=e});try{this._inst.exports._start()}catch(e){if(e!==n)throw e}return await e,this.exitCode}this._inst.exports._initialize()}_resume(){if(this.exited)throw new Error("Go program has already exited");try{this._inst.exports.resume()}catch(e){if(e!==n)throw e}this.exited&&this._resolveExitPromise()}_makeFuncWrapper(e){const t=this;return function(){const n={id:e,this:this,args:arguments};return t._pendingEvent=n,t._resume(),n.result}}},global.require&&global.require.main===module&&global.process&&global.process.versions&&!global.process.versions.electron){process.argv.length!=3&&(console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"),process.exit(1));const e=new Go;WebAssembly.instantiate(fs.readFileSync(process.argv[2]),e.importObject).then(async t=>{let n=await e.run(t.instance);process.exit(n)}).catch(e=>{console.error(e),process.exit(1)})}})();export default function(e){return new Promise(t=>{const n=new Go,s=e=>{const s=e.instance;global.wfa=s,n.run(s),t()};"instantiateStreaming"in WebAssembly?WebAssembly.instantiateStreaming(fetch(e),n.importObject).then(function(e){s(e)}):fetch(e).then(e=>e.arrayBuffer()).then(e=>WebAssembly.instantiate(e,n.importObject).then(function(e){s(e)}))})} \ No newline at end of file +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// This file has been modified for use by the TinyGo compiler. + +(() => { + // Map multiple JavaScript environments to a single common API, + // preferring web standards over Node.js API. + // + // Environments considered: + // - Browsers + // - Node.js + // - Electron + // - Parcel + + if (typeof global !== "undefined") { + // global already exists + } else if (typeof window !== "undefined") { + window.global = window; + } else if (typeof self !== "undefined") { + self.global = self; + } else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); + } + + if (!global.require && typeof require !== "undefined") { + global.require = require; + } + + if (!global.fs && global.require) { + global.fs = require("node:fs"); + } + + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!global.fs) { + let outputBuf = ""; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!global.process) { + global.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!global.crypto) { + const nodeCrypto = require("node:crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + } + + if (!global.performance) { + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + } + + if (!global.TextEncoder) { + global.TextEncoder = require("node:util").TextEncoder; + } + + if (!global.TextDecoder) { + global.TextDecoder = require("node:util").TextDecoder; + } + + // End of polyfills for common API. + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + let reinterpretBuf = new DataView(new ArrayBuffer(8)); + var logLine = []; + const wasmExit = {}; // thrown to exit via proc_exit (not an error) + + global.Go = class { + constructor() { + this._callbackTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.memory.buffer); + } + + const unboxValue = (v_ref) => { + reinterpretBuf.setBigInt64(0, v_ref, true); + const f = reinterpretBuf.getFloat64(0, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = v_ref & 0xffffffffn; + return this._values[id]; + } + + + const loadValue = (addr) => { + let v_ref = mem().getBigUint64(addr, true); + return unboxValue(v_ref); + } + + const boxValue = (v) => { + const nanHead = 0x7FF80000n; + + if (typeof v === "number") { + if (isNaN(v)) { + return nanHead << 32n; + } + if (v === 0) { + return (nanHead << 32n) | 1n; + } + reinterpretBuf.setFloat64(0, v, true); + return reinterpretBuf.getBigInt64(0, true); + } + + switch (v) { + case undefined: + return 0n; + case null: + return (nanHead << 32n) | 2n; + case true: + return (nanHead << 32n) | 3n; + case false: + return (nanHead << 32n) | 4n; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = BigInt(this._values.length); + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 1n; + switch (typeof v) { + case "string": + typeFlag = 2n; + break; + case "symbol": + typeFlag = 3n; + break; + case "function": + typeFlag = 4n; + break; + } + return id | ((nanHead | typeFlag) << 32n); + } + + const storeValue = (addr, v) => { + let v_ref = boxValue(v); + mem().setBigUint64(addr, v_ref, true); + } + + const loadSlice = (array, len, cap) => { + return new Uint8Array(this._inst.exports.memory.buffer, array, len); + } + + const loadSliceOfValues = (array, len, cap) => { + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (ptr, len) => { + return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + wasi_snapshot_preview1: { + // https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_write + fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) { + let nwritten = 0; + if (fd == 1) { + for (let iovs_i=0; iovs_i 0, // dummy + fd_fdstat_get: () => 0, // dummy + fd_seek: () => 0, // dummy + proc_exit: (code) => { + this.exited = true; + this.exitCode = code; + this._resolveExitPromise(); + throw wasmExit; + }, + random_get: (bufPtr, bufLen) => { + crypto.getRandomValues(loadSlice(bufPtr, bufLen)); + return 0; + }, + }, + gojs: { + // func ticks() int64 + "runtime.ticks": () => { + return BigInt((timeOrigin + performance.now()) * 1e6); + }, + + // func sleepTicks(timeout int64) + "runtime.sleepTicks": (timeout) => { + // Do not sleep, only reactivate scheduler after the given timeout. + setTimeout(() => { + if (this.exited) return; + try { + this._inst.exports.go_scheduler(); + } catch (e) { + if (e !== wasmExit) throw e; + } + }, Number(timeout)/1e6); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (v_ref) => { + // Note: TinyGo does not support finalizers so this is only called + // for one specific case, by js.go:jsString. and can/might leak memory. + const id = v_ref & 0xffffffffn; + if (this._goRefCounts?.[id] !== undefined) { + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + } else { + console.error("syscall/js.finalizeRef: unknown id", id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (value_ptr, value_len) => { + value_ptr >>>= 0; + const s = loadString(value_ptr, value_len); + return boxValue(s); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (v_ref, p_ptr, p_len) => { + let prop = loadString(p_ptr, p_len); + let v = unboxValue(v_ref); + let result = Reflect.get(v, prop); + return boxValue(result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => { + const v = unboxValue(v_ref); + const p = loadString(p_ptr, p_len); + const x = unboxValue(x_ref); + Reflect.set(v, p, x); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (v_ref, p_ptr, p_len) => { + const v = unboxValue(v_ref); + const p = loadString(p_ptr, p_len); + Reflect.deleteProperty(v, p); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (v_ref, i) => { + return boxValue(Reflect.get(unboxValue(v_ref), i)); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (v_ref, i, x_ref) => { + Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => { + const v = unboxValue(v_ref); + const name = loadString(m_ptr, m_len); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + const m = Reflect.get(v, name); + storeValue(ret_addr, Reflect.apply(m, v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { + try { + const v = unboxValue(v_ref); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + storeValue(ret_addr, Reflect.apply(v, undefined, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => { + const v = unboxValue(v_ref); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + storeValue(ret_addr, Reflect.construct(v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr+ 8, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (v_ref) => { + return unboxValue(v_ref).length; + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (ret_addr, v_ref) => { + const s = String(unboxValue(v_ref)); + const str = encoder.encode(s); + storeValue(ret_addr, str); + mem().setInt32(ret_addr + 8, str.length, true); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => { + const str = unboxValue(v_ref); + loadSlice(slice_ptr, slice_len, slice_cap).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (v_ref, t_ref) => { + return unboxValue(v_ref) instanceof unboxValue(t_ref); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = loadSlice(dest_addr, dest_len); + const src = unboxValue(src_ref); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + mem().setUint32(num_bytes_copied_addr, toCopy.length, true); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + + // copyBytesToJS(dst ref, src []byte) (int, bool) + // Originally copied from upstream Go project, then modified: + // https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416 + "syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = unboxValue(dst_ref); + const src = loadSlice(src_addr, src_len); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + mem().setUint32(num_bytes_copied_addr, toCopy.length, true); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + } + }; + + // Go 1.20 uses 'env'. Go 1.21 uses 'gojs'. + // For compatibility, we use both as long as Go 1.20 is supported. + this.importObject.env = this.importObject.gojs; + } + + async run(instance) { + this._inst = instance; + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map(); // mapping from JS values to reference ids + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + this.exitCode = 0; + + if (this._inst.exports._start) { + let exitPromise = new Promise((resolve, reject) => { + this._resolveExitPromise = resolve; + }); + + // Run program, but catch the wasmExit exception that's thrown + // to return back here. + try { + this._inst.exports._start(); + } catch (e) { + if (e !== wasmExit) throw e; + } + + await exitPromise; + return this.exitCode; + } else { + this._inst.exports._initialize(); + } + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + try { + this._inst.exports.resume(); + } catch (e) { + if (e !== wasmExit) throw e; + } + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } + + if ( + global.require && + global.require.main === module && + global.process && + global.process.versions && + !global.process.versions.electron + ) { + if (process.argv.length != 3) { + console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); + process.exit(1); + } + + const go = new Go(); + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => { + let exitCode = await go.run(result.instance); + process.exit(exitCode); + }).catch((err) => { + console.error(err); + process.exit(1); + }); + } +})(); + +// wasm setup code +export default function init (path) { + return new Promise ((res) => { + const go = new Go(); + const run = (obj) => { + const wasm = obj.instance; + global.wfa = wasm + go.run(wasm); + res(wasm) + } + if ('instantiateStreaming' in WebAssembly) { + WebAssembly.instantiateStreaming(fetch(path), go.importObject).then(function (obj) { + run(obj) + }) + } else { + fetch(path).then(resp => + resp.arrayBuffer() + ).then(bytes => + WebAssembly.instantiate(bytes, go.importObject).then(function (obj) { + run(obj) + }) + ) + } + }) +} \ No newline at end of file diff --git a/web/modules/wfa.wasm b/web/modules/wfa.wasm index acf82b3..86d4df1 100644 Binary files a/web/modules/wfa.wasm and b/web/modules/wfa.wasm differ diff --git a/web/scripts/account.js b/web/scripts/account.js index 638e4f9..f1d3cca 100644 --- a/web/scripts/account.js +++ b/web/scripts/account.js @@ -1,5 +1,5 @@ import { requestAPI, setAppearance } from "./utils.js"; -import { dialog } from "./dialog.js"; +import { dialog, error } from "./dialog.js"; window.addEventListener("DOMContentLoaded", init); @@ -15,7 +15,7 @@ function handlePasswordChangeButton () { if (result === "confirm") { const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") }); if (result.status !== 200) { - alert(`Attempted to change password but got: ${result.error}`); + error(`Attempted to change password but got: ${result.error}`); } } }); diff --git a/web/scripts/backups.js b/web/scripts/backups.js index 1fc805b..e84a7ef 100644 --- a/web/scripts/backups.js +++ b/web/scripts/backups.js @@ -1,5 +1,5 @@ import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js"; -import { alert, dialog } from "./dialog.js"; +import { error, dialog } from "./dialog.js"; window.addEventListener("DOMContentLoaded", init); @@ -74,7 +74,7 @@ class BackupCard extends HTMLElement { }; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body); if (result.status !== 200) { - alert(`Attempted to edit backup but got: ${result.error}`); + error(`Attempted to edit backup but got: ${result.error}`); } refreshBackups(); } @@ -83,14 +83,14 @@ class BackupCard extends HTMLElement { async handleDeleteButton () { const template = this.shadowRoot.querySelector("#delete-dialog"); - dialog(template, async (result, form) => { + dialog(template, async (result, _form) => { if (result === "confirm") { const body = { volid: this.volid }; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body); if (result.status !== 200) { - alert(`Attempted to delete backup but got: ${result.error}`); + error(`Attempted to delete backup but got: ${result.error}`); } refreshBackups(); } @@ -99,14 +99,14 @@ class BackupCard extends HTMLElement { async handleRestoreButton () { const template = this.shadowRoot.querySelector("#restore-dialog"); - dialog(template, async (result, form) => { + dialog(template, async (result, _form) => { if (result === "confirm") { const body = { volid: this.volid }; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body); if (result.status !== 200) { - alert(`Attempted to delete backup but got: ${result.error}`); + error(`Attempted to delete backup but got: ${result.error}`); } refreshBackups(); } @@ -116,14 +116,10 @@ class BackupCard extends HTMLElement { customElements.define("backup-card", BackupCard); -async function getBackupsFragment () { - return await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET"); -} - async function refreshBackups () { - let backups = await getBackupsFragment(); + let backups = await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET"); if (backups.status !== 200) { - alert("Error fetching backups."); + error("Error fetching backups."); } else { backups = backups.data; @@ -141,7 +137,7 @@ async function handleBackupAddButton () { }; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body); if (result.status !== 200) { - alert(`Attempted to create backup but got: ${result.error}`); + error(`Attempted to create backup but got: ${result.error}`); } refreshBackups(); } diff --git a/web/scripts/clientsync.js b/web/scripts/clientsync.js index 58d39a4..1a8fd91 100644 --- a/web/scripts/clientsync.js +++ b/web/scripts/clientsync.js @@ -1,9 +1,13 @@ -import { getSyncSettings, requestAPI } from "./utils.js"; +import { getSetting, requestAPI } from "./utils.js"; +import { error } from "./dialog.js"; export async function setupClientSync (callback) { - const { scheme, rate } = getSyncSettings(); - - if (scheme === "always") { + const scheme = getSetting("sync-scheme"); + const rate = getSetting("sync-rate"); + if (scheme === "never") { + return; + } + else if (scheme === "always") { window.setInterval(callback, rate * 1000); } else if (scheme === "hash") { @@ -19,7 +23,7 @@ export async function setupClientSync (callback) { } else if (scheme === "interrupt") { const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`); - socket.addEventListener("open", (event) => { + socket.addEventListener("open", (_event) => { socket.send(`rate ${rate}`); }); socket.addEventListener("message", (event) => { @@ -28,12 +32,12 @@ export async function setupClientSync (callback) { callback(); } else { - console.error("clientsync: recieved unexpected message from server, closing socket."); + error("clientsync: recieved unexpected message from server, closing socket."); socket.close(); } }); } else { - console.error(`clientsync: unsupported scheme ${scheme} selected.`); + error(`clientsync: unsupported scheme ${scheme} selected.`); } } diff --git a/web/scripts/config.js b/web/scripts/config.js index 53be948..0005863 100644 --- a/web/scripts/config.js +++ b/web/scripts/config.js @@ -1,5 +1,5 @@ import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js"; -import { alert, dialog } from "./dialog.js"; +import { error, dialog } from "./dialog.js"; window.addEventListener("DOMContentLoaded", init); @@ -54,12 +54,12 @@ class VolumeAction extends HTMLElement { async handleDiskDetach () { const disk = this.dataset.volume; - dialog(this.template, async (result, form) => { + dialog(this.template, 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}`); + error(`Attempted to detach ${disk} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -80,7 +80,7 @@ class VolumeAction extends HTMLElement { 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}`); + error(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -98,7 +98,7 @@ class VolumeAction extends HTMLElement { }; 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}`); + error(`Attempted to resize ${disk} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -117,7 +117,7 @@ class VolumeAction extends HTMLElement { }; 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}`); + error(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -136,12 +136,12 @@ class VolumeAction extends HTMLElement { async handleDiskDelete () { const disk = this.dataset.volume; - dialog(this.template, async (result, form) => { + dialog(this.template, 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}`); + error(`Attempted to delete ${disk} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -162,7 +162,7 @@ async function initVolumes () { 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."); + error("Error fetching instance volumes."); } else { volumes = volumes.data; @@ -186,7 +186,7 @@ async function handleDiskAdd () { const disk = `${prefix}${id}`; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body); if (result.status !== 200) { - alert(`Attempted to create ${disk} but got: ${result.error}`); + error(`Attempted to create ${disk} but got: ${result.error}`); } refreshVolumes(); refreshBoot(); @@ -214,7 +214,7 @@ async function handleCDAdd () { const disk = `ide${form.get("device")}`; const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body); if (result.status !== 200) { - alert(`Attempted to mount ${body.iso} to ${disk} but got: result.error`); + error(`Attempted to mount ${body.iso} to ${disk} but got: result.error`); } refreshVolumes(); refreshBoot(); @@ -224,7 +224,7 @@ async function handleCDAdd () { const isos = await requestAPI("/user/vm-isos", "GET"); const select = d.querySelector("#iso-select"); - for (const iso of isos) { + for (const iso of isos.data) { select.add(new Option(iso.name, iso.volid)); } select.selectedIndex = -1; @@ -263,7 +263,7 @@ class NetworkAction extends HTMLElement { 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}`); + error(`Attempted to change ${net} but got: ${result.error}`); } refreshNetworks(); refreshBoot(); @@ -275,13 +275,13 @@ class NetworkAction extends HTMLElement { async handleNetworkDelete () { const netID = this.dataset.network; - dialog(this.template, async (result, form) => { + dialog(this.template, async (result, _form) => { if (result === "confirm") { setIconSrc(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}`); + error(`Attempted to delete ${net} but got: ${result.error}`); } refreshNetworks(); refreshBoot(); @@ -299,7 +299,7 @@ async function initNetworks () { 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."); + error("Error fetching instance nets."); } else { nets = nets.data; @@ -324,7 +324,7 @@ async function handleNetworkAdd () { 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}`); + error(`Attempted to create ${net} but got: ${result.error}`); } refreshNetworks(); refreshBoot(); @@ -367,7 +367,7 @@ class DeviceAction extends HTMLElement { 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}`); + error(`Attempted to add ${device} but got: ${result.error}`); } refreshDevices(); } @@ -375,7 +375,7 @@ class DeviceAction extends HTMLElement { const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0])); - for (const availDevice of availDevices) { + for (const availDevice of availDevices.data) { d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus)); } d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1"); @@ -383,13 +383,13 @@ class DeviceAction extends HTMLElement { async handleDeviceDelete () { const deviceID = this.dataset.device; - dialog(this.template, async (result, form) => { + dialog(this.template, 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}`); + error(`Attempted to delete ${device} but got: ${result.error}`); } refreshDevices(); } @@ -408,7 +408,7 @@ async function initDevices () { 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."); + error("Error fetching instance devices."); } else { devices = devices.data; @@ -431,14 +431,14 @@ async function handleDeviceAdd () { 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}`); + error(`Attempted to add ${body.device} but got: ${result.error}`); } refreshDevices(); } }); - const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); - for (const availDevice of availDevices) { + const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET"); + for (const availDevice of availDevices.data) { d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus)); } d.querySelector("#pcie").checked = true; @@ -447,12 +447,12 @@ async function handleDeviceAdd () { 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."); + error("Error fetching instance boot order."); } else if (type === "qemu") { boot = boot.data; - const order = document.querySelector("#boot-order"); - order.setHTMLUnsafe(boot); + const container = document.querySelector("#boot-order"); + container.setHTMLUnsafe(boot); } } @@ -474,6 +474,6 @@ async function handleFormExit (event) { goToPage("index"); } else { - alert(`Attempted to set basic resources but got: ${result.error}`); + error(`Attempted to set basic resources but got: ${result.error}`); } } diff --git a/web/scripts/dialog.js b/web/scripts/dialog.js index 24660c5..b634c91 100644 --- a/web/scripts/dialog.js +++ b/web/scripts/dialog.js @@ -17,7 +17,7 @@ * body contains an optional form or other information, * and controls contains a series of buttons which controls the form */ -export function dialog (template, onclose = async (result, form) => { }) { +export function dialog (template, onclose = async (_result, _form) => { }) { const dialog = template.content.querySelector("dialog").cloneNode(true); document.body.append(dialog); dialog.addEventListener("close", async () => { @@ -41,33 +41,6 @@ export function dialog (template, onclose = async (result, form) => { }) { return dialog; } -export function alert (message) { - const dialog = document.querySelector("#alert-dialog"); - if (dialog == null) { - const dialog = document.createElement("dialog"); - dialog.id = "alert-dialog"; - dialog.innerHTML = ` -
-

${message}

-
- -
-
- `; - dialog.className = "w3-container w3-card w3-border-0"; - document.body.append(dialog); - dialog.showModal(); - dialog.addEventListener("close", () => { - dialog.parentElement.removeChild(dialog); - }); - return dialog; - } - else { - console.error("Attempted to create a new alert while one already exists!"); - return null; - } -} - class ErrorDialog extends HTMLElement { shadowRoot = null; dialog = null; @@ -82,19 +55,22 @@ class ErrorDialog extends HTMLElement {
-

Error

-
+

Error

+
@@ -120,7 +96,7 @@ class ErrorDialog extends HTMLElement { } navigator.clipboard.writeText(errors); } - this.parentElement.removeChild(this); + this.dialog.close(); }); } @@ -140,7 +116,7 @@ customElements.define("error-dialog", ErrorDialog); export function error (message) { let dialog = document.querySelector("error-dialog"); - if (dialog == null) { + if (dialog === null) { dialog = document.createElement("error-dialog"); document.body.append(dialog); dialog.appendError(message); diff --git a/web/scripts/draggable.js b/web/scripts/draggable.js index bf21541..b0fcf10 100644 --- a/web/scripts/draggable.js +++ b/web/scripts/draggable.js @@ -13,7 +13,7 @@ class DraggableContainer extends HTMLElement { window.Sortable.create(this.content, { group: this.dataset.group, ghostClass: "ghost", - setData: function (dataTransfer, dragEl) { + setData: function (dataTransfer, _dragEl) { dataTransfer.setDragImage(blank, 0, 0); } }); diff --git a/web/scripts/index.js b/web/scripts/index.js index 0de2e33..a989c11 100644 --- a/web/scripts/index.js +++ b/web/scripts/index.js @@ -1,14 +1,16 @@ -import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js"; -import { alert, dialog, error } from "./dialog.js"; +import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt } from "./utils.js"; +import { dialog, error } from "./dialog.js"; import { setupClientSync } from "./clientsync.js"; import wfaInit from "../modules/wfa.js"; window.addEventListener("DOMContentLoaded", init); +var wfa; + async function init () { setAppearance(); - wfaInit("modules/wfa.wasm"); + wfa = await wfaInit("modules/wfa.wasm"); initInstances(); document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton); @@ -159,7 +161,7 @@ class InstanceCard extends HTMLElement { async handlePowerButton () { if (!this.actionLock) { const template = this.shadowRoot.querySelector("#power-dialog"); - dialog(template, async (result, form) => { + dialog(template, async (result, _form) => { if (result === "confirm") { this.actionLock = true; const targetAction = this.status === "running" ? "stop" : "start"; @@ -175,7 +177,7 @@ class InstanceCard extends HTMLElement { break; } else if (taskStatus.data.status === "stopped") { // task stopped but was not successful - alert(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`); + error(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`); break; } else { // task has not stopped @@ -193,7 +195,7 @@ class InstanceCard extends HTMLElement { handleDeleteButton () { if (!this.actionLock && this.status === "stopped") { const template = this.shadowRoot.querySelector("#delete-dialog"); - dialog(template, async (result, form) => { + dialog(template, async (result, _form) => { if (result === "confirm") { this.actionLock = true; @@ -203,7 +205,7 @@ class InstanceCard extends HTMLElement { const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE"); if (result.status !== 200) { - alert(`Attempted to delete ${this.vmid} but got: ${result.error}`); + error(`Attempted to delete ${this.vmid} but got: ${result.error}`); } this.actionLock = false; @@ -216,12 +218,8 @@ class InstanceCard extends HTMLElement { customElements.define("instance-card", InstanceCard); -async function getInstancesFragment () { - return await requestDash("/index/instances", "GET"); -} - async function refreshInstances () { - let instances = await getInstancesFragment(); + let instances = await requestDash("/index/instances", "GET"); if (instances.status !== 200) { error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`); } @@ -243,11 +241,11 @@ function initInstances () { } function sortInstances () { - const searchCriteria = getSearchSettings(); + const searchCriteria = getSetting("search-criteria"); const searchQuery = document.querySelector("#search").value || null; let criteria; if (!searchQuery) { - criteria = (item, query = null) => { + criteria = (item, _query = null) => { return { score: item.vmid, alignment: null }; }; } @@ -276,8 +274,8 @@ function sortInstances () { }; criteria = (item, query) => { // lower is better - const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true); - const alignment = global.wfa.DecodeCIGAR(CIGAR); + const { score, CIGAR } = wfa.wfAlign(query, item, penalties, true); + const alignment = wfa.DecodeCIGAR(CIGAR); return { score: score / item.length, alignment }; }; } @@ -337,16 +335,16 @@ async function handleInstanceAddButton () { refreshInstances(); } else { - alert(`Attempted to create new instance ${vmid} but got: ${result.error}`); + error(`Attempted to create new instance ${vmid} but got: ${result.error}`); refreshInstances(); } } }); - - const templates = await requestAPI("/user/ct-templates", "GET"); - + + // setup type select const typeSelect = d.querySelector("#type"); typeSelect.selectedIndex = -1; + // on type change, reveal or hide the container specific section typeSelect.addEventListener("change", () => { if (typeSelect.value === "qemu") { d.querySelectorAll(".container-specific").forEach((element) => { @@ -366,66 +364,62 @@ async function handleInstanceAddButton () { element.disabled = true; }); - const rootfsContent = "rootdir"; - const rootfsStorage = d.querySelector("#rootfs-storage"); - rootfsStorage.selectedIndex = -1; - - const userResources = await requestAPI("/user/dynamic/resources", "GET"); - const userCluster = await requestAPI("/user/config/cluster", "GET"); - - const nodeSelect = d.querySelector("#node"); - nodeSelect.innerHTML = ""; - const clusterNodes = await requestPVE("/nodes", "GET"); - const allowedNodes = Object.keys(userCluster.nodes); - clusterNodes.data.forEach((element) => { - if (element.status === "online" && allowedNodes.includes(element.node)) { - nodeSelect.add(new Option(element.node)); - } + // setup pool select + const poolSelect = d.querySelector("#pool"); + poolSelect.innerHTML = ""; + // add user pools to selector + const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools); + userPools.forEach((element) => { + poolSelect.add(new Option(element)); }); + poolSelect.selectedIndex = -1; + // on pool change, get the allowed nodes for that pool, then repopulate the node selector + poolSelect.addEventListener("change", async () => { + const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool; + + const nodeSelect = d.querySelector("#node"); + nodeSelect.innerHTML = ""; + const clusterNodes = (await requestPVE("/nodes", "GET")).data; + const allowedNodes = Object.keys(pool["nodes-allowed"]); + clusterNodes.forEach((element) => { + if (element.status === "online" && allowedNodes.includes(element.node)) { + nodeSelect.add(new Option(element.node)); + } + }); + nodeSelect.selectedIndex = -1; + + // set vmid min/max + d.querySelector("#vmid").min = pool["vmid-allowed"].min; + d.querySelector("#vmid").max = pool["vmid-allowed"].max; + }); + + // setup node select + const nodeSelect = d.querySelector("#node"); nodeSelect.selectedIndex = -1; + // on node change, get the available storages and repopulate the storage selector nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node const node = nodeSelect.value; - const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); + const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data; rootfsStorage.innerHTML = ""; - storage.data.forEach((element) => { + storage.forEach((element) => { if (element.content.includes(rootfsContent)) { rootfsStorage.add(new Option(element.storage)); } }); rootfsStorage.selectedIndex = -1; - - // set core and memory min/max depending on node selected - if (node in userResources.cores.nodes) { - d.querySelector("#cores").max = userResources.cores.nodes[node].avail; - } - else { - d.querySelector("#cores").max = userResources.cores.global.avail; - } - - if (node in userResources.memory.nodes) { - d.querySelector("#memory").max = userResources.memory.nodes[node].avail; - } - else { - d.querySelector("#memory").max = userResources.memory.global.avail; - } }); - // set vmid min/max - d.querySelector("#vmid").min = userCluster.vmid.min; - d.querySelector("#vmid").max = userCluster.vmid.max; - - // add user pools to selector - const poolSelect = d.querySelector("#pool"); - poolSelect.innerHTML = ""; - const userPools = Object.keys(userCluster.pools); - userPools.forEach((element) => { - poolSelect.add(new Option(element)); - }); - poolSelect.selectedIndex = -1; + // setup root dir select + const rootfsStorage = d.querySelector("#rootfs-storage"); + rootfsStorage.selectedIndex = -1; + // set rootfs content type (rootdir) + const rootfsContent = "rootdir"; + // setup templateImage depending on selected image storage + const templateImage = d.querySelector("#template-image"); // add template images to selector - const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage - for (const template of templates) { + const templates = await requestAPI("/user/ct-templates", "GET"); + for (const template of templates.data) { templateImage.append(new Option(template.name, template.volid)); } templateImage.selectedIndex = -1; diff --git a/web/scripts/login.js b/web/scripts/login.js index 764b031..afa57c2 100644 --- a/web/scripts/login.js +++ b/web/scripts/login.js @@ -1,11 +1,12 @@ import { goToPage, setAppearance, requestAPI } from "./utils.js"; -import { alert } from "./dialog.js"; +import { error } from "./dialog.js"; window.addEventListener("DOMContentLoaded", init); async function init () { await deleteAllCookies(); setAppearance(); + const formSubmitButton = document.querySelector("#submit"); formSubmitButton.addEventListener("click", async (e) => { e.preventDefault(); @@ -19,18 +20,16 @@ async function init () { goToPage("index"); } else if (ticket.status === 401) { - alert("Authenticaton failed."); + error("Authenticaton failed."); formSubmitButton.innerText = "LOGIN"; } else if (ticket.status === 408) { - alert("Network error."); + error("Network error."); formSubmitButton.innerText = "LOGIN"; } else { - alert("An error occured."); - console.error(ticket); + error(`An error occured: ${JSON.stringify(ticket)}`); formSubmitButton.innerText = "LOGIN"; - console.error(ticket.error); } }); } diff --git a/web/scripts/settings.js b/web/scripts/settings.js index 3ddfc5c..345760a 100644 --- a/web/scripts/settings.js +++ b/web/scripts/settings.js @@ -1,35 +1,62 @@ -import { setAppearance, getSyncSettings, getSearchSettings, getThemeSettings, setSyncSettings, setSearchSettings, setThemeSettings } from "./utils.js"; +import { setAppearance, getSetting, setSetting } from "./utils.js"; window.addEventListener("DOMContentLoaded", init); function init () { setAppearance(); - const { scheme, rate } = getSyncSettings(); - if (scheme) { - document.querySelector(`#sync-${scheme}`).checked = true; - } - if (rate) { - document.querySelector("#sync-rate").value = rate; - } - const search = getSearchSettings(); - if (search) { - document.querySelector(`#search-${search}`).checked = true; - } + document.querySelectorAll("[id^=sync-]").forEach((v) => { + v.addEventListener("change", handleSettingsChange); + }); + document.querySelector(`#sync-${getSetting("sync-scheme")}`).checked = true; + document.querySelector("#sync-rate").value = getSetting("sync-rate"); - const theme = getThemeSettings(); - if (theme) { - document.querySelector("#appearance-theme").value = theme; - } + document.querySelectorAll("[id^=search-]").forEach((v) => { + v.addEventListener("change", handleSettingsChange); + }); + document.querySelector(`#search-${getSetting("search-criteria")}`).checked = true; + + document.querySelector("#appearance-theme").addEventListener("change", handleSettingsChange); + document.querySelector("#appearance-theme").value = getSetting("appearance-theme"); document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false); } +function handleSettingsChange (event) { + event.preventDefault(); + const form = new FormData(document.querySelector("#settings")); + const saveBtn = document.querySelector("#save"); + if (getSetting("sync-scheme") !== form.get("sync-scheme")) { + saveBtn.classList.remove("disabled"); + saveBtn.classList.add("enabled"); + } + else if (getSetting("sync-rate") !== Number(form.get("sync-rate"))) { + saveBtn.classList.remove("disabled"); + saveBtn.classList.add("enabled"); + } + else if (getSetting("search-criteria") !== form.get("search-criteria")) { + saveBtn.classList.remove("disabled"); + saveBtn.classList.add("enabled"); + } + else if (getSetting("appearance-theme") !== form.get("appearance-theme")) { + saveBtn.classList.remove("disabled"); + saveBtn.classList.add("enabled"); + } + else { + saveBtn.classList.remove("enabled"); + saveBtn.classList.add("disabled"); + } +} + function handleSaveSettings (event) { event.preventDefault(); const form = new FormData(document.querySelector("#settings")); - setSyncSettings(form.get("sync-scheme"), form.get("sync-rate")); - setSearchSettings(form.get("search-criteria")); - setThemeSettings(form.get("appearance-theme")); + const saveBtn = document.querySelector("#save"); + setSetting("sync-scheme", form.get("sync-scheme")); + setSetting("sync-rate", Number(form.get("sync-rate"))); + setSetting("search-criteria", form.get("search-criteria")); + setSetting("appearance-theme", form.get("appearance-theme")); + saveBtn.classList.remove("enabled"); + saveBtn.classList.add("disabled"); init(); } diff --git a/web/scripts/utils.js b/web/scripts/utils.js index 41b102c..051316a 100644 --- a/web/scripts/utils.js +++ b/web/scripts/utils.js @@ -80,33 +80,34 @@ async function request (url, content) { try { const response = await fetch(url, content); const contentType = response.headers.get("Content-Type"); - let data = null; + const res = {}; if (contentType === null) { - data = {}; + res.data = null; + res.status = response.status; } else if (contentType.includes("application/json")) { - data = await response.json(); - data.status = response.status; + res.data = await response.json(); + res.status = response.status; } else if (contentType.includes("text/html")) { - data = { data: await response.text() }; - data.status = response.status; + res.data = await response.text(); + res.status = response.status; } else if (contentType.includes("text/plain")) { - data = { data: await response.text() }; - data.status = response.status; + res.data = await response.text(); + res.status = response.status; } else { - data = {}; + res.data = null; + res.status = response.status; } - + if (!response.ok) { - return { status: response.status, error: data ? data.error : response.status }; + return { status: response.status, error: res.data ? res.data.error : response.status }; } else { - data.status = response.status; - return data || response; + return res; } } catch (error) { @@ -124,60 +125,30 @@ export function getURIData () { return Object.fromEntries(url.searchParams); } -const settingsDefault = { - "sync-scheme": "always", - "sync-rate": 5, - "search-criteria": "fuzzy", - "appearance-theme": "auto" +const settings = { + "sync-scheme": {"type": String, "default": "always"}, + "sync-rate": {"type": Number, "default": 5}, + "search-criteria": {"type": String, "default": "fuzzy"}, + "appearance-theme": {"type": String, "default": "auto"} }; -export function getSyncSettings () { - let scheme = localStorage.getItem("sync-scheme"); - let rate = Number(localStorage.getItem("sync-rate")); - if (!scheme) { - scheme = settingsDefault["sync-scheme"]; - localStorage.setItem("sync-scheme", scheme); +export function getSetting (key) { + const meta = settings[key]; + let value = localStorage.getItem(key); + if (value === null || meta === null) { + value = meta.default; + localStorage.setItem(key, meta.default); } - if (!rate) { - rate = settingsDefault["sync-rate"]; - localStorage.setItem("sync-rate", rate); - } - return { scheme, rate }; + + return meta.type(value); } -export function getSearchSettings () { - let searchCriteria = localStorage.getItem("search-criteria"); - if (!searchCriteria) { - searchCriteria = settingsDefault["search-criteria"]; - localStorage.setItem("search-criteria", searchCriteria); - } - return searchCriteria; -} - -export function getThemeSettings () { - let theme = localStorage.getItem("appearance-theme"); - if (!theme) { - theme = settingsDefault["appearance-theme"]; - localStorage.setItem("appearance-theme", theme); - } - return theme; -} - -export function setSyncSettings (scheme, rate) { - localStorage.setItem("sync-scheme", scheme); - localStorage.setItem("sync-rate", rate); -} - -export function setSearchSettings (criteria) { - localStorage.setItem("search-criteria", criteria); -} - -export function setThemeSettings (theme) { - localStorage.setItem("appearance-theme", theme); +export function setSetting (key, value) { + localStorage.setItem(key, value); } export function setAppearance () { - const theme = getThemeSettings(); + const theme = getSetting("appearance-theme"); if (theme === "auto") { document.querySelector(":root").classList.remove("dark-theme", "light-theme"); } @@ -191,7 +162,6 @@ export function setAppearance () { } } -// assumes href is path to svg, and id to grab is #symb export function setIconSrc (icon, path) { icon.setAttribute("src", path); } diff --git a/web/templates/backups.go.tmpl b/web/templates/backups.go.tmpl index a2418ec..ca8eb58 100644 --- a/web/templates/backups.go.tmpl +++ b/web/templates/backups.go.tmpl @@ -10,8 +10,8 @@ a { height: 1em; width: 1em; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; }
@@ -29,7 +29,7 @@ -

+

Edit Backup

@@ -39,8 +39,8 @@
- - + +
@@ -49,7 +49,7 @@ -

+

Delete Backup

@@ -60,8 +60,8 @@
- - + +
@@ -70,7 +70,7 @@ -

+

Restore From Backup?

@@ -84,8 +84,8 @@
- - + +
@@ -103,7 +103,7 @@ -

+

Create Backup

@@ -113,8 +113,8 @@
- - + +
diff --git a/web/templates/base.go.tmpl b/web/templates/base.go.tmpl index 7d360f1..3d2be7f 100644 --- a/web/templates/base.go.tmpl +++ b/web/templates/base.go.tmpl @@ -1,3 +1,4 @@ +{{/* common across all pages*/}} {{define "head"}} @@ -14,18 +15,21 @@ {{end}} +{{/*
common across all pages*/}} {{define "header"}} -

{{.global.Organization}}

- - - +
+

{{.global.Organization}}

+ + + +
{{end}} \ No newline at end of file diff --git a/web/templates/config.go.tmpl b/web/templates/config.go.tmpl index 6c46289..9dd56ed 100644 --- a/web/templates/config.go.tmpl +++ b/web/templates/config.go.tmpl @@ -50,7 +50,7 @@ @@ -79,7 +79,7 @@ @@ -163,7 +163,7 @@ Move {{.Name}} @@ -198,7 +198,7 @@ Resize {{.Name}} @@ -233,7 +233,7 @@ Delete {{.Name}} @@ -267,7 +267,7 @@ Attach {{.Name}} @@ -300,7 +300,7 @@ Detach {{.Name}} @@ -340,7 +340,7 @@ @@ -371,7 +371,7 @@ Configure Net {{.Net_ID}} @@ -393,7 +393,7 @@ Delete Net {{.Net_ID}} @@ -425,7 +425,7 @@ @@ -448,13 +448,13 @@

{{.Device_ID}}

{{.Device_Name}}

- + - + @@ -144,7 +144,7 @@ -

+

Delete {{.VMID}}

@@ -153,8 +153,8 @@
- - + +
diff --git a/web/templates/pool-resources.go.tmpl b/web/templates/pool-resources.go.tmpl new file mode 100644 index 0000000..06372e1 --- /dev/null +++ b/web/templates/pool-resources.go.tmpl @@ -0,0 +1,34 @@ +{{define "pool-resources"}} +
+

Pool: {{.PoolID}}

+

VMID Range: {{.AllowedVMIDRange.Min}} - {{.AllowedVMIDRange.Max}}

+

Nodes: {{MapKeys .AllowedNodes ", "}}

+

Max Backups Per Instance: {{.AllowedBackups.MaxPerInstance}} Max Backups Total: {{.AllowedBackups.MaxTotal}}

+
+ {{range $category, $v := .ResourceCharts}} + {{if eq $category ""}} +

Generic

+ {{else}} +

{{$category}}

+ {{end}} +
+ {{range $v}} + {{if .Display}} + {{if eq .Type "numeric"}} + {{template "resource-chart" .}} + {{end}} + {{if eq .Type "storage"}} + {{template "resource-chart" .}} + {{end}} + {{if eq .Type "list"}} + {{range .Resources}} + {{template "resource-chart" .}} + {{end}} + {{end}} + {{end}} + {{end}} +
+ {{end}} +
+
+{{end}} \ No newline at end of file diff --git a/web/templates/resource-chart.go.tmpl b/web/templates/resource-chart.go.tmpl index c444cb5..d5f8add 100644 --- a/web/templates/resource-chart.go.tmpl +++ b/web/templates/resource-chart.go.tmpl @@ -8,8 +8,7 @@ margin: 0; width: 100%; height: fit-content; - padding: 10px; - border-radius: 5px; + padding: 0.5em; } progress { width: 100%; @@ -19,7 +18,7 @@ } #caption { text-align: center; - margin-top: 10px; + margin-top: 0.5em; display: flex; flex-direction: column; } @@ -37,7 +36,7 @@
diff --git a/web/templates/select.go.tmpl b/web/templates/select.go.tmpl index aa9d046..32ed5b7 100644 --- a/web/templates/select.go.tmpl +++ b/web/templates/select.go.tmpl @@ -1,11 +1,27 @@ +{{/* + Select: generic data driven -{{range .Options}} + {{range .Options}} + {{template "option" .}} + {{end}} + +{{end}} + +{{/* + Options: generic data driven {{else}} {{end}} -{{end}} - {{end}} \ No newline at end of file