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..307a4dc 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,25 @@ -.PHONY: build test clean +.PHONY: build test clean wfa-js -build: clean +build: clean build-wfa-js @echo "======================== Building Binary =======================" + mkdir -p dist # resolve symbolic links in web by copying it into dist/web/ cp -rL web/ dist/web/ - CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ . + CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ . -test: clean - go run . +build-wfa-js: + $(MAKE) -C WFA-JS + cp -f WFA-JS/dist/* web/modules -clean: +test: clean test-wfa-js + +test-wfa-js: + $(MAKE) test -C WFA-JS + +clean: clean-wfa-js @echo "======================== Cleaning Project ======================" go clean rm -rf dist/* + +clean-wfa-js: + $(MAKE) clean -C WFA-JS 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..f8f8636 --- /dev/null +++ b/WFA-JS @@ -0,0 +1 @@ +Subproject commit f8f8636cc3f00e3fa0c379b490b7df05b9df3d2c diff --git a/app/app.go b/app/app.go index 81d97b7..8643ce8 100644 --- a/app/app.go +++ b/app/app.go @@ -1,6 +1,7 @@ package app import ( + "flag" "fmt" "log" "proxmoxaas-dashboard/app/common" @@ -11,7 +12,10 @@ import ( "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..cfb3c2a --- /dev/null +++ b/app/common/meta_debug.go @@ -0,0 +1,55 @@ +//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, + }, + "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/meta.go b/app/common/meta_release.go similarity index 63% rename from app/common/meta.go rename to app/common/meta_release.go index 5b77bb2..baa4124 100644 --- a/app/common/meta.go +++ b/app/common/meta_release.go @@ -1,3 +1,5 @@ +//go:build release + package common import ( @@ -55,45 +57,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..f99b95d 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() + } + 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/backups.js b/web/scripts/backups.js index 1fc805b..0d71c07 100644 --- a/web/scripts/backups.js +++ b/web/scripts/backups.js @@ -83,7 +83,7 @@ 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 @@ -99,7 +99,7 @@ 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 diff --git a/web/scripts/clientsync.js b/web/scripts/clientsync.js index 58d39a4..4d7f333 100644 --- a/web/scripts/clientsync.js +++ b/web/scripts/clientsync.js @@ -1,9 +1,12 @@ -import { getSyncSettings, requestAPI } from "./utils.js"; +import { getSetting, requestAPI } from "./utils.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 +22,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) => { diff --git a/web/scripts/config.js b/web/scripts/config.js index 53be948..50e8a13 100644 --- a/web/scripts/config.js +++ b/web/scripts/config.js @@ -54,7 +54,7 @@ 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"); @@ -136,7 +136,7 @@ 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"); @@ -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; @@ -275,7 +275,7 @@ 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}`; @@ -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,7 +383,7 @@ 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}`; @@ -437,8 +437,8 @@ async function handleDeviceAdd () { } }); - 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; diff --git a/web/scripts/dialog.js b/web/scripts/dialog.js index 24660c5..4453b32 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 () => { @@ -48,7 +48,8 @@ export function alert (message) { dialog.id = "alert-dialog"; dialog.innerHTML = `
-

${message}

+

Alert

+

${message}

@@ -82,18 +83,18 @@ class ErrorDialog extends HTMLElement { -

Error

+

Error

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..c58433e 100644 --- a/web/scripts/index.js +++ b/web/scripts/index.js @@ -1,4 +1,4 @@ -import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js"; +import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt } from "./utils.js"; import { alert, dialog, error } from "./dialog.js"; import { setupClientSync } from "./clientsync.js"; import wfaInit from "../modules/wfa.js"; @@ -159,7 +159,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"; @@ -193,7 +193,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; @@ -243,11 +243,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 }; }; } @@ -342,11 +342,11 @@ async function handleInstanceAddButton () { } } }); - - 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 +366,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/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..8ff6485 --- /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 := .Resources}} + {{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