1 Commits
Author SHA1 Message Date
alu 196cf900cc update readme 2026-07-06 23:28:36 +00:00
32 changed files with 404 additions and 331 deletions
+2
View File
@@ -1,3 +1,5 @@
**/config.json
**/package-lock.json
**/node_modules
dist/*
go.sum
+9 -16
View File
@@ -1,27 +1,20 @@
.PHONY: build build-web build-wfa-js clean clean-wfa-js ensure-dist workflow-init
.PHONY: build test clean wfa-js
build: clean ensure-dist build-web build-wfa-js
build: clean 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 -tags release -ldflags="-s -w" -v -o dist/ .
build-web: ensure-dist
cp -rL web/ dist/web/
build-wfa-js: ensure-dist
wfa-js:
$(MAKE) -C WFA-JS
cp -f WFA-JS/dist/* web/modules
clean: clean-wfa-js
test: clean
go run .
clean:
@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/. .
+5 -5
View File
@@ -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`
+1 -1
Submodule WFA-JS updated: f53f344fb3...f8f8636cc3
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"log"
"proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes"
"web" // go will complain here until the first build
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2"
+4
View File
@@ -36,6 +36,10 @@ var MimeTypes = map[string]MimeType{
Type: "image/svg+xml",
Minifier: nil,
},
"png": {
Type: "image/png",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: nil,
+4
View File
@@ -40,6 +40,10 @@ 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,
+146 -103
View File
@@ -11,9 +11,64 @@ import (
"github.com/go-viper/mapstructure/v2"
)
type Pool struct {
paas.Pool `mapstructure:",squash"`
ResourceCharts map[string]map[string]any
type Account struct {
paas.User
Pools map[string]paas.Pool
}
// numerical constraint
type Constraint struct {
Max int64
Used int64
Avail int64
}
// match constraint
type Match struct {
Name string
Match string
Max int64
Used int64
Avail int64
}
type NumericResource struct {
Type string
Name string
Multiplier int64
Base uint64
Compact bool
Unit string
Display bool
Global Constraint
Nodes map[string]Constraint
Total Constraint
Category string
}
type StorageResource struct {
Type string
Name string
Multiplier int64
Base uint64
Compact bool
Unit string
Display bool
Disks []string
Global Constraint
Nodes map[string]Constraint
Total Constraint
Category string
}
type ListResource struct {
Type string
Whitelist bool
Display bool
Global []Match
Nodes map[string][]Match
Total []Match
Category string
}
type ResourceChart struct {
@@ -43,7 +98,8 @@ var Green = color.RGB{
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
if err == nil {
user, err := GetUserBasic(auth)
account, err := GetUser(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -55,33 +111,97 @@ func HandleGETAccount(c *gin.Context) {
return
}
for poolname, pool := range pools {
// for each resource category
for category := range pool.Resources {
// for each resource in each category
for resource, v := range pool.Resources[category].(map[string]any) {
// create a resource chart for resource depending on resource type
switch t := v.(type) {
case NumericResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case StorageResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
avail := fmt.Sprintf("%d", r.Avail)
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: avail, // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
pools[poolname].Resources[category].(map[string]any)[resource] = l
}
}
}
}
account.Pools = pools
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"user": user,
"pools": pools,
"global": common.Global,
"page": "account",
"account": account,
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func GetUserBasic(auth paas.Auth) (paas.User, error) {
user := paas.User{}
func GetUser(auth paas.Auth) (Account, error) {
account := Account{}
body := map[string]any{}
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
if err != nil {
return user, err
return account, err
}
if code != 200 {
return user, fmt.Errorf("request to /access/pools resulted in %+v", res)
return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["user"], &user)
return user, err
err = mapstructure.Decode(body, &account)
return account, err
}
func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
pools := map[string]Pool{}
func GetUserPools(auth paas.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
// get all pools
body := map[string]any{}
@@ -112,10 +232,6 @@ func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
// for each pool
for poolname, pool := range pools {
// for each resource in pool data
// create pool charts map
pool.ResourceCharts = make(map[string]map[string]any)
for k, v := range pool.Resources {
m := meta[k].(map[string]any)
t := m["type"].(string)
@@ -123,122 +239,49 @@ func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
category := m["category"].(string)
// create a category if it does not already exist
if _, ok := pool.ResourceCharts[category]; !ok {
pool.ResourceCharts[category] = map[string]any{}
if _, ok := pool.Resources[category]; !ok {
pool.Resources[category] = map[string]any{}
}
// depending on type, decode the apool data into the corresponding resource type
// depending on type, decode the pool data into the corresponding resource type
switch t {
case "numeric":
n := paas.NumericResource{}
n := NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
pools[poolname].Resources[category].(map[string]any)[k] = n
case "storage":
n := paas.StorageResource{}
n := StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
pools[poolname].Resources[category].(map[string]any)[k] = n
case "list":
n := paas.ListResource{}
n := ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pool.ResourceCharts[category][k] = n
pools[poolname].Resources[category].(map[string]any)[k] = n
}
// delete the old entry, only categories should be left at the end of the loop
//delete(pools[poolname].Resources, k)
delete(pools[poolname].Resources, k)
}
pools[poolname] = pool
}
err = FormatPoolResourceCharts(&pools)
if err != nil {
return pools, err
}
return pools, nil
}
func FormatPoolResourceCharts(pools *map[string]Pool) error {
for poolname, pool := range *pools {
// for each resource category
for categoryname, category := range pool.ResourceCharts {
// for each resource in each category
for resourcename, resource := range category {
// create a resource chart for resource depending on resource type
switch t := resource.(type) {
case paas.NumericResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case paas.StorageResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case paas.ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
avail := fmt.Sprintf("%d", r.Avail)
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: avail, // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = l
}
}
}
}
return nil
}
// interpolate between min and max by normalized (0 - 1) val
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
minhsl := min.ToHSL()
+7 -5
View File
@@ -12,6 +12,8 @@ import (
"github.com/go-viper/mapstructure/v2"
)
// imported types from fabric
type InstanceConfig struct {
paas.Instance `mapstructure:",squash"`
// overrides
@@ -26,8 +28,8 @@ type GlobalConfig struct {
type PoolConfig struct {
CPU struct {
Global []paas.Match
Nodes map[string][]paas.Match
Global []paas.MatchLimit
Nodes map[string][]paas.MatchLimit
}
}
@@ -241,7 +243,7 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
}
// use node specific rules if present, otherwise use global rules
var userCPU []paas.Match
var userCPU []paas.MatchLimit
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
} else {
@@ -267,7 +269,7 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []paas.Match
data []paas.MatchLimit
}{}
err = mapstructure.Decode(body, supported)
if err != nil {
@@ -276,7 +278,7 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c paas.Match) bool {
contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
return c.Name == cpu.Name
})
if !contains {
+5
View File
@@ -9,6 +9,11 @@ import (
"github.com/go-viper/mapstructure/v2"
)
// used when requesting GET /access/domains
type GetRealmsBody struct {
Data []Realm `json:"data"`
}
// stores each realm's data
type Realm struct {
Default int `json:"default"`
-3
View File
@@ -1,3 +0,0 @@
web/modules/*
WFA-JS/*
dist/*
-3
View File
@@ -1,3 +0,0 @@
web/modules/*
WFA-JS/*
dist/*
+25 -29
View File
@@ -1,33 +1,29 @@
import { defineConfig, globalIgnores } from "eslint/config";
import { defineConfig } from "eslint/config";
import globals from "globals";
import js from "@eslint/js";
export default defineConfig([
js.configs.recommended,
globalIgnores(["dist/", "web/modules", "WFA-JS"]),
{
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: "latest",
sourceType: "module",
export default defineConfig([js.configs.recommended,{
languageOptions: {
globals: {
...globals.browser,
},
rules: {
"no-tabs": ["error", {
allowIndentationTabs: true,
}],
indent: ["error", "tab"],
"linebreak-style": ["error", "unix"],
quotes: ["error", "double"],
semi: ["error", "always"],
"brace-style": ["error", "stroustrup", { allowSingleLine: false }],
"no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}],
"prefer-const": ["error"]
},
}
]);
ecmaVersion: "latest",
sourceType: "module",
},
rules: {
"no-tabs": ["error", {
allowIndentationTabs: true,
}],
indent: ["error", "tab"],
"linebreak-style": ["error", "unix"],
quotes: ["error", "double"],
semi: ["error", "always"],
"brace-style": ["error", "stroustrup", { allowSingleLine: false }],
"no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}],
"prefer-const": ["error"]
},
}]);
+10 -12
View File
@@ -1,6 +1,6 @@
module proxmoxaas-dashboard
go 1.26.5
go 1.26.4
require (
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
@@ -8,11 +8,9 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/tdewolff/minify/v2 v2.24.13
proxmoxaas-common-lib v0.0.0
web v0.0.0
)
replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
replace web => ./dist/web
require (
github.com/bytedance/gopkg v0.1.4 // indirect
@@ -27,22 +25,22 @@ require (
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
golang.org/x/arch v0.29.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.1 // indirect
golang.org/x/arch v0.28.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+19
View File
@@ -0,0 +1,19 @@
{
"name": "proxmoxaas-dashboard",
"version": "1.0.0",
"description": "Front-end for ProxmoxAAS",
"type": "module",
"scripts": {
"lint": "html-validate --config dev_config/.htmlvalidate.json web/html/*; stylelint --config dev_config/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config dev_config/eslint.config.mjs --fix web/scripts/",
"update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"html-validate": "^9.4.0",
"stylelint": "^15.9.0",
"stylelint-config-standard": "^33.0.0"
}
}
-13
View File
@@ -130,19 +130,6 @@ hr {
align-items: center;
}
.row-reverse {
flex-direction: row-reverse;
row-gap: 10px;
align-items: center;
}
.column {
flex-direction: column;
row-gap: 10px;
align-items: center;
}
.column-reverse {
flex-direction: column-reverse;
row-gap: 10px;
-3
View File
@@ -1,3 +0,0 @@
module web
go 1.26.0
+5 -5
View File
@@ -39,11 +39,11 @@
<h2>Account</h2>
<section class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username: {{.user.Username.UserID}}@{{.user.Username.Realm}}</p>
<p id="email">Email: {{.user.Mail}}</p>
<p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
<p id="email">Email: {{.account.Mail}}</p>
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
</section>
{{range $poolname, $pool := .pools}}
{{range $poolname, $pool := .account.Pools}}
{{template "pool-resources" $pool}}
{{end}}
</main>
@@ -58,9 +58,9 @@
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" autocomplete="new-password" required>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" autocomplete="new-password" required>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
</form>
</div>
<div id="controls" class="w3-center w3-container">
+2 -2
View File
@@ -114,9 +114,9 @@
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" autocomplete="new-password" required disabled>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" autocomplete="new-password" required disabled>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
</form>
</div>
<div id="controls" class="w3-center w3-container">
+1 -7
View File
@@ -20,12 +20,6 @@
p:last-child {
margin-bottom: 0;
}
#save.disabled {
display: none;
}
#save.enabled {
display: unset;
}
</style>
</head>
<body>
@@ -76,7 +70,7 @@
</fieldset>
</section>
<div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin disabled" id="save" type="submit">SAVE</button>
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button>
</div>
</form>
</main>
+1 -1
View File
@@ -560,7 +560,7 @@ export default function init (path) {
const wasm = obj.instance;
global.wfa = wasm
go.run(wasm);
res(wasm)
res()
}
if ('instantiateStreaming' in WebAssembly) {
WebAssembly.instantiateStreaming(fetch(path), go.importObject).then(function (obj) {
+2 -2
View File
@@ -1,5 +1,5 @@
import { requestAPI, setAppearance } from "./utils.js";
import { dialog, error } from "./dialog.js";
import { dialog } 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) {
error(`Attempted to change password but got: ${result.error}`);
alert(`Attempted to change password but got: ${result.error}`);
}
}
});
+11 -7
View File
@@ -1,5 +1,5 @@
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
import { error, dialog } from "./dialog.js";
import { alert, 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) {
error(`Attempted to edit backup but got: ${result.error}`);
alert(`Attempted to edit backup but got: ${result.error}`);
}
refreshBackups();
}
@@ -90,7 +90,7 @@ class BackupCard extends HTMLElement {
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
if (result.status !== 200) {
error(`Attempted to delete backup but got: ${result.error}`);
alert(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
@@ -106,7 +106,7 @@ class BackupCard extends HTMLElement {
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
if (result.status !== 200) {
error(`Attempted to delete backup but got: ${result.error}`);
alert(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
@@ -116,10 +116,14 @@ 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 requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
let backups = await getBackupsFragment();
if (backups.status !== 200) {
error("Error fetching backups.");
alert("Error fetching backups.");
}
else {
backups = backups.data;
@@ -137,7 +141,7 @@ async function handleBackupAddButton () {
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
if (result.status !== 200) {
error(`Attempted to create backup but got: ${result.error}`);
alert(`Attempted to create backup but got: ${result.error}`);
}
refreshBackups();
}
+4 -6
View File
@@ -1,9 +1,7 @@
import { getSetting, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
import { getSyncSettings, requestAPI } from "./utils.js";
export async function setupClientSync (callback) {
const scheme = getSetting("sync-scheme");
const rate = getSetting("sync-rate");
const { scheme, rate } = getSyncSettings();
if (scheme === "never") {
return;
}
@@ -32,12 +30,12 @@ export async function setupClientSync (callback) {
callback();
}
else {
error("clientsync: recieved unexpected message from server, closing socket.");
console.error("clientsync: recieved unexpected message from server, closing socket.");
socket.close();
}
});
}
else {
error(`clientsync: unsupported scheme ${scheme} selected.`);
console.error(`clientsync: unsupported scheme ${scheme} selected.`);
}
}
+21 -21
View File
@@ -1,5 +1,5 @@
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js";
import { error, dialog } from "./dialog.js";
import { alert, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
@@ -59,7 +59,7 @@ class VolumeAction extends HTMLElement {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
if (result.status !== 200) {
error(`Attempted to detach ${disk} but got: ${result.error}`);
alert(`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) {
error(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`);
alert(`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) {
error(`Attempted to resize ${disk} but got: ${result.error}`);
alert(`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) {
error(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
alert(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
}
refreshVolumes();
refreshBoot();
@@ -141,7 +141,7 @@ class VolumeAction extends HTMLElement {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${disk} but got: ${result.error}`);
alert(`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) {
error("Error fetching instance volumes.");
alert("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) {
error(`Attempted to create ${disk} but got: ${result.error}`);
alert(`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) {
error(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
alert(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
}
refreshVolumes();
refreshBoot();
@@ -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) {
error(`Attempted to change ${net} but got: ${result.error}`);
alert(`Attempted to change ${net} but got: ${result.error}`);
}
refreshNetworks();
refreshBoot();
@@ -281,7 +281,7 @@ class NetworkAction extends HTMLElement {
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${net} but got: ${result.error}`);
alert(`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) {
error("Error fetching instance nets.");
alert("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) {
error(`Attempted to create ${net} but got: ${result.error}`);
alert(`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) {
error(`Attempted to add ${device} but got: ${result.error}`);
alert(`Attempted to add ${device} but got: ${result.error}`);
}
refreshDevices();
}
@@ -389,7 +389,7 @@ class DeviceAction extends HTMLElement {
const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${device} but got: ${result.error}`);
alert(`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) {
error("Error fetching instance devices.");
alert("Error fetching instance devices.");
}
else {
devices = devices.data;
@@ -431,7 +431,7 @@ async function handleDeviceAdd () {
const deviceID = `hostpci${hostpci}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body);
if (result.status !== 200) {
error(`Attempted to add ${body.device} but got: ${result.error}`);
alert(`Attempted to add ${body.device} but got: ${result.error}`);
}
refreshDevices();
}
@@ -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) {
error("Error fetching instance boot order.");
alert("Error fetching instance boot order.");
}
else if (type === "qemu") {
boot = boot.data;
const container = document.querySelector("#boot-order");
container.setHTMLUnsafe(boot);
const order = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot);
}
}
@@ -474,6 +474,6 @@ async function handleFormExit (event) {
goToPage("index");
}
else {
error(`Attempted to set basic resources but got: ${result.error}`);
alert(`Attempted to set basic resources but got: ${result.error}`);
}
}
+31 -6
View File
@@ -41,6 +41,34 @@ 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 = `
<form method="dialog">
<p class="w3-large" id="prompt">Alert</p>
<p class="w3-center" style="margin-bottom: 0;">${message}</p>
<div class="w3-center">
<button class="w3-button w3-margin" id="submit">OK</button>
</div>
</form>
`;
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;
@@ -59,18 +87,15 @@ class ErrorDialog extends HTMLElement {
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
padding-left: 12px;
padding-right: 12px;
}
#errors * {
margin: 0;
width: 100%;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt">Error</p>
<div id="errors" class="flex column"></div>
<div id="errors" class="flex column-reverse"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
@@ -96,7 +121,7 @@ class ErrorDialog extends HTMLElement {
}
navigator.clipboard.writeText(errors);
}
this.dialog.close();
this.parentElement.removeChild(this);
});
}
@@ -116,7 +141,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);
+14 -12
View File
@@ -1,16 +1,14 @@
import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt } from "./utils.js";
import { dialog, error } from "./dialog.js";
import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js";
import { alert, 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();
wfa = await wfaInit("modules/wfa.wasm");
wfaInit("modules/wfa.wasm");
initInstances();
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
@@ -177,7 +175,7 @@ class InstanceCard extends HTMLElement {
break;
}
else if (taskStatus.data.status === "stopped") { // task stopped but was not successful
error(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`);
alert(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`);
break;
}
else { // task has not stopped
@@ -205,7 +203,7 @@ class InstanceCard extends HTMLElement {
const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE");
if (result.status !== 200) {
error(`Attempted to delete ${this.vmid} but got: ${result.error}`);
alert(`Attempted to delete ${this.vmid} but got: ${result.error}`);
}
this.actionLock = false;
@@ -218,8 +216,12 @@ class InstanceCard extends HTMLElement {
customElements.define("instance-card", InstanceCard);
async function getInstancesFragment () {
return await requestDash("/index/instances", "GET");
}
async function refreshInstances () {
let instances = await requestDash("/index/instances", "GET");
let instances = await getInstancesFragment();
if (instances.status !== 200) {
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
}
@@ -241,7 +243,7 @@ function initInstances () {
}
function sortInstances () {
const searchCriteria = getSetting("search-criteria");
const searchCriteria = getSearchSettings();
const searchQuery = document.querySelector("#search").value || null;
let criteria;
if (!searchQuery) {
@@ -274,8 +276,8 @@ function sortInstances () {
};
criteria = (item, query) => {
// lower is better
const { score, CIGAR } = wfa.wfAlign(query, item, penalties, true);
const alignment = wfa.DecodeCIGAR(CIGAR);
const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
const alignment = global.wfa.DecodeCIGAR(CIGAR);
return { score: score / item.length, alignment };
};
}
@@ -335,7 +337,7 @@ async function handleInstanceAddButton () {
refreshInstances();
}
else {
error(`Attempted to create new instance ${vmid} but got: ${result.error}`);
alert(`Attempted to create new instance ${vmid} but got: ${result.error}`);
refreshInstances();
}
}
+6 -5
View File
@@ -1,12 +1,11 @@
import { goToPage, setAppearance, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
import { alert } 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();
@@ -20,16 +19,18 @@ async function init () {
goToPage("index");
}
else if (ticket.status === 401) {
error("Authenticaton failed.");
alert("Authenticaton failed.");
formSubmitButton.innerText = "LOGIN";
}
else if (ticket.status === 408) {
error("Network error.");
alert("Network error.");
formSubmitButton.innerText = "LOGIN";
}
else {
error(`An error occured: ${JSON.stringify(ticket)}`);
alert("An error occured.");
console.error(ticket);
formSubmitButton.innerText = "LOGIN";
console.error(ticket.error);
}
});
}
+19 -45
View File
@@ -1,62 +1,36 @@
import { setAppearance, getSetting, setSetting } from "./utils.js";
import { setAppearance, getSyncSettings, getSearchSettings, getThemeSettings, setSyncSettings, setSearchSettings, setThemeSettings } from "./utils.js";
window.addEventListener("DOMContentLoaded", init);
function init () {
setAppearance();
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 { scheme, rate } = getSyncSettings();
if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true;
}
if (rate) {
document.querySelector("#sync-rate").value = rate;
}
document.querySelectorAll("[id^=search-]").forEach((v) => {
v.addEventListener("change", handleSettingsChange);
});
document.querySelector(`#search-${getSetting("search-criteria")}`).checked = true;
const search = getSearchSettings();
if (search) {
document.querySelector(`#search-${search}`).checked = true;
}
document.querySelector("#appearance-theme").addEventListener("change", handleSettingsChange);
document.querySelector("#appearance-theme").value = getSetting("appearance-theme");
const theme = getThemeSettings();
if (theme) {
document.querySelector("#appearance-theme").value = 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"));
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");
setSyncSettings(form.get("sync-scheme"), form.get("sync-rate"));
setSearchSettings(form.get("search-criteria"));
setThemeSettings(form.get("appearance-theme"));
init();
}
+47 -16
View File
@@ -125,30 +125,60 @@ export function getURIData () {
return Object.fromEntries(url.searchParams);
}
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"}
const settingsDefault = {
"sync-scheme": "always",
"sync-rate": 5,
"search-criteria": "fuzzy",
"appearance-theme": "auto"
};
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);
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);
}
return meta.type(value);
if (!rate) {
rate = settingsDefault["sync-rate"];
localStorage.setItem("sync-rate", rate);
}
return { scheme, rate };
}
export function setSetting (key, value) {
localStorage.setItem(key, 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 setAppearance () {
const theme = getSetting("appearance-theme");
const theme = getThemeSettings();
if (theme === "auto") {
document.querySelector(":root").classList.remove("dark-theme", "light-theme");
}
@@ -162,6 +192,7 @@ export function setAppearance () {
}
}
// assumes href is path to svg, and id to grab is #symb
export function setIconSrc (icon, path) {
icon.setAttribute("src", path);
}
+1 -1
View File
@@ -5,7 +5,7 @@
<p id="nodes">Nodes: {{MapKeys .AllowedNodes ", "}}</p>
<p id="backups">Max Backups Per Instance: {{.AllowedBackups.MaxPerInstance}} Max Backups Total: {{.AllowedBackups.MaxTotal}}</p>
<div>
{{range $category, $v := .ResourceCharts}}
{{range $category, $v := .Resources}}
{{if eq $category ""}}
<h4>Generic</h4>
{{else}}