Merge pull request 'Dashboard v2.0.0' (#5) from v2.0.0 into main
tronnet/workflows: Static Analysis -- Golang / static-go (push) Successful in 44s
tronnet/workflows: Code Style -- Web Components / style-web (push) Failing after 16s

Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
alu
2026-08-10 21:10:15 +00:00
54 changed files with 1530 additions and 950 deletions
-2
View File
@@ -1,5 +1,3 @@
**/config.json **/config.json
**/package-lock.json
**/node_modules
dist/* dist/*
go.sum go.sum
+3
View File
@@ -1,3 +1,6 @@
[submodule "proxmoxaas-common-lib"] [submodule "proxmoxaas-common-lib"]
path = proxmoxaas-common-lib path = proxmoxaas-common-lib
url = https://git.tronnet.net/tronnet/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
+18 -6
View File
@@ -1,15 +1,27 @@
.PHONY: build test clean .PHONY: build build-web build-wfa-js clean clean-wfa-js ensure-dist workflow-init
build: clean build: clean ensure-dist build-web build-wfa-js
@echo "======================== Building Binary =======================" @echo "======================== Building Binary ======================="
# resolve symbolic links in web by copying it into dist/web/ # resolve symbolic links in web by copying it into dist/web/
CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
build-web: ensure-dist
cp -rL web/ dist/web/ cp -rL web/ dist/web/
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
test: clean build-wfa-js: ensure-dist
go run . $(MAKE) -C WFA-JS
cp -f WFA-JS/dist/* web/modules
clean: clean: clean-wfa-js
@echo "======================== Cleaning Project ======================" @echo "======================== Cleaning Project ======================"
go clean go clean
rm -rf dist/* 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/. .
+9 -9
View File
@@ -13,7 +13,7 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
## ProxmoxAAS System Installation Overview ## 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. 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-Dashboard | dashboard.local | paas.domain.net |
| ProxmoxAAS-API | api.local | paas.domain.net/api/ | | ProxmoxAAS-API | api.local | paas.domain.net/api/ |
| ProxmoxAAS-Fabric | fabric.local | N/A | | ProxmoxAAS-Fabric | fabric.local | N/A |
| ProxmoxAAS-LDAP | ldap.local | N/A| | access-manager-api | access.local | N/A|
## Prerequisites - Dashboard ## Prerequisites - Dashboard
- Proxmox VE Cluster (v7.0+) - 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 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) 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: Rename `template.config.json` to `config.json` and modify:
- listenPort: port for PAAS-Dashboard to bind and listen on - `listenPort`: port for PAAS-Dashboard to bind and listen on
- organization: name of your org which is displayed on the top left corner - `organization`: name of your org which is displayed on the top left corner
- dashurl: url for the dashboard, ie. `https://paas.domain.net` - `dashurl`: url for the dashboard, ie. `https://paas.domain.net`
- apiurl: url for PAAS-API, ie. `https://paas.domain.net/api` - `apiurl`: url for PAAS-API, ie. `https://paas.domain.net/api`
- pveurl: url for the Proxmox endpoint, ie. `https://pve.domain.net` - `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 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` 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. 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 ## 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: 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:
Submodule
+1
Submodule WFA-JS added at f53f344fb3
+7 -3
View File
@@ -1,17 +1,21 @@
package app package app
import ( import (
"flag"
"fmt" "fmt"
"log" "log"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes" "proxmoxaas-dashboard/app/routes"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build "web" // go will complain here until the first build
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2" "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) common.Global = common.GetConfig(*configPath)
// setup static resources // setup static resources
@@ -38,7 +42,7 @@ func Run(configPath *string) {
router.GET("/settings", routes.HandleGETSettings) router.GET("/settings", routes.HandleGETSettings)
// run on all interfaces with port // 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) // setup static resources under web (css, images, modules, scripts)
+51
View File
@@ -0,0 +1,51 @@
//go:build !release
package common
import (
"io"
"github.com/tdewolff/minify/v2"
)
// defines mime type and associated minifier
type MimeType struct {
Type string
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
}
// debug mime types
var MimeTypes = map[string]MimeType{
"css": {
Type: "text/css",
Minifier: nil,
},
"html": {
Type: "text/html",
Minifier: nil,
},
"tmpl": {
Type: "text/plain",
Minifier: nil,
},
"frag": {
Type: "text/plain",
Minifier: nil,
},
"svg": {
Type: "image/svg+xml",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: nil,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
@@ -1,3 +1,5 @@
//go:build release
package common package common
import ( import (
@@ -38,10 +40,6 @@ var MimeTypes = map[string]MimeType{
Type: "image/svg+xml", Type: "image/svg+xml",
Minifier: svg.Minify, Minifier: svg.Minify,
}, },
"png": {
Type: "image/png",
Minifier: nil,
},
"js": { "js": {
Type: "application/javascript", Type: "application/javascript",
Minifier: js.Minify, Minifier: js.Minify,
@@ -55,45 +53,3 @@ var MimeTypes = map[string]MimeType{
Minifier: nil, 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,
},
}
*/
-24
View File
@@ -22,13 +22,6 @@ type StaticFile struct {
MimeType MimeType MimeType MimeType
} }
// parsed vmpath data (ie node/type/vmid)
type VMPath struct {
Node string
Type string
VMID string
}
// type used for templated <select> // type used for templated <select>
type Select struct { type Select struct {
ID string ID string
@@ -42,20 +35,3 @@ type Option struct {
Value string Value string
Display string Display string
} }
type RequestContext struct {
Cookies map[string]string
}
type Auth struct {
Username string
Token string
CSRF string
}
type Icon struct {
ID string
Src string
Alt string
Clickable bool
}
+60 -46
View File
@@ -10,27 +10,37 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"math"
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
"strconv"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
paas "proxmoxaas-common-lib"
) )
// get config file from configPath // get config file from configPath
func GetConfig(configPath string) Config { func GetConfig(configPath string) Config {
content, err := os.ReadFile(configPath) root, err := os.OpenRoot(".")
if err != nil { if err != nil {
log.Fatal("Error when opening config file: ", err) log.Fatal("[ERR ] error opening root dir: ", err)
} }
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil {
log.Fatal("[ERR ] error opening config file: ", err)
}
var config Config var config Config
err = json.Unmarshal(content, &config) err = json.Unmarshal(content, &config)
if err != nil { if err != nil {
log.Fatal("Error during parsing config file: ", err) log.Fatal("[ERR ] error parsing config file: ", err)
} }
return config return config
} }
@@ -47,14 +57,14 @@ func InitMinify() *minify.M {
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
minified := make(map[string]StaticFile) minified := make(map[string]StaticFile)
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if !entry.IsDir() { if !entry.IsDir() {
v, err := files.ReadFile(path) v, err := files.ReadFile(path)
if err != nil { if err != nil {
log.Fatalf("error parsing template file %s: %s", path, err.Error()) log.Fatalf("[ERR ] error parsing template file %s: %s", path, err.Error())
} }
x := strings.Split(entry.Name(), ".") x := strings.Split(entry.Name(), ".")
if len(x) >= 2 { // file has extension if len(x) >= 2 { // file has extension
@@ -62,7 +72,7 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier
min, err := m.String(mimetype.Type, string(v)) // try to minify min, err := m.String(mimetype.Type, string(v)) // try to minify
if err != nil { if err != nil {
log.Fatalf("error minifying file %s: %s", path, err.Error()) log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
} }
minified[path] = StaticFile{ minified[path] = StaticFile{
Data: min, Data: min,
@@ -84,7 +94,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
} }
return nil return nil
}) })
return minified
if err != nil {
log.Printf("[ERR ] error in MinifyStatic: %s", err)
return nil
} else {
return minified
}
} }
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template { func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
@@ -93,20 +109,20 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
"MapKeys": func(x any, sep string) string { "MapKeys": func(x any, sep string) string {
v := reflect.ValueOf(x) v := reflect.ValueOf(x)
keys := v.MapKeys() keys := v.MapKeys()
s := "" var s strings.Builder
for i := 0; i < len(keys); i++ { for i := range keys {
if i != 0 { if i != 0 {
s += sep s.WriteString(sep)
} }
s += keys[i].String() s.WriteString(keys[i].String())
} }
return s return s.String()
}, },
"Map": func(values ...any) (map[string]any, error) { "Map": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 { if len(values)%2 != 0 {
return nil, errors.New("invalid dict call") return nil, errors.New("invalid dict call")
} }
dict := make(map[string]interface{}, len(values)/2) dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 { for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string) key, ok := values[i].(string)
if !ok { if !ok {
@@ -155,17 +171,20 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string
} }
func HandleNonFatalError(c *gin.Context, err error) { func HandleNonFatalError(c *gin.Context, err error) {
log.Printf("[Error] encountered an error: %s", err.Error()) log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
func RequestGetAPI(path string, context RequestContext, body any) (*http.Response, int, error) { func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil) req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
for k, v := range context.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v}) if auth != nil {
for k, v := range GetRequestContextFromCookies(*auth) {
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
}
} }
client := &http.Client{} client := &http.Client{}
@@ -185,7 +204,6 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
if err != nil { if err != nil {
return nil, response.StatusCode, err return nil, response.StatusCode, err
} }
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json) switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
case *map[string]any: case *map[string]any:
err = json.Unmarshal(data, &body) err = json.Unmarshal(data, &body)
@@ -203,49 +221,45 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
return response, response.StatusCode, nil return response, response.StatusCode, nil
} }
func GetAuth(c *gin.Context) (Auth, error) { func GetAuthFromRequest(c *gin.Context) (paas.Auth, error) {
_, errAuth := c.Cookie("auth") _, errAuth := c.Cookie("auth")
username, errUsername := c.Cookie("username") username, errUsername := c.Cookie("username")
token, errToken := c.Cookie("PVEAuthCookie") token, errToken := c.Cookie("PVEAuthCookie")
csrf, errCSRF := c.Cookie("CSRFPreventionToken") csrf, errCSRF := c.Cookie("CSRFPreventionToken")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil { access, errAccess := c.Cookie("PAASAccessManagerTicket")
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF) if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return paas.Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
} else { } else {
return Auth{username, token, csrf}, nil return paas.Auth{Username: username, Token: token, CSRF: csrf, AccessManagerTicket: access}, nil
} }
} }
func ExtractVMPath(c *gin.Context) (VMPath, error) { func GetInstancePathFromRequest(c *gin.Context) (paas.InstancePath, error) {
req_node := c.Query("node") req_node := c.Query("node")
req_type := c.Query("type") req_type := c.Query("type")
req_vmid := c.Query("vmid") req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" { if req_node == "" || req_type == "" || req_vmid == "" {
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid) return paas.InstancePath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
} }
vm_path := VMPath{
Node: req_node, vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
Type: req_type, if err != nil {
VMID: req_vmid, return paas.InstancePath{}, err
}
vm_path := paas.InstancePath{
NodeName: req_node,
InstanceType: paas.InstanceType(req_type),
InstanceID: paas.InstanceID(vmid_int),
} }
return vm_path, nil return vm_path, nil
} }
func FormatNumber(val int64, base int64) (float64, string) { func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
valf := float64(val) return map[string]string{
basef := float64(base) "username": auth.Username,
steps := 0 "PVEAuthCookie": auth.Token,
for math.Abs(valf) > basef && steps < 4 { "CSRFPreventionToken": auth.CSRF,
valf /= basef "PAASAccessManagerTicket": auth.AccessManagerTicket,
steps++
}
if base == 1000 {
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
return 0, ""
} }
} }
+149 -180
View File
@@ -3,6 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"net/http" "net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color" "github.com/gerow/go-color"
@@ -10,70 +11,9 @@ import (
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
type Account struct { type Pool struct {
Username string paas.Pool `mapstructure:",squash"`
Pools map[string]bool ResourceCharts map[string]map[string]any
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
}
// 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 int64
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 int64
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 { type ResourceChart struct {
@@ -82,7 +22,7 @@ type ResourceChart struct {
Name string Name string
Used int64 Used int64
Max int64 Max int64
Avail float64 Avail string
Prefix string Prefix string
Unit string Unit string
ColorHex string ColorHex string
@@ -101,21 +41,148 @@ var Green = color.RGB{
} }
func HandleGETAccount(c *gin.Context) { func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
account, err := GetUserAccount(auth) user, err := GetUserBasic(auth)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
} }
// for each resource category, create a resource chart pools, err := GetUserPools(auth)
for category, resources := range account.Resources { if err != nil {
for resource, v := range resources { common.HandleNonFatalError(c, err)
switch t := v.(type) { return
case NumericResource: }
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[category][resource] = ResourceChart{ c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"user": user,
"pools": pools,
})
} 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{}
body := map[string]any{}
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
if err != nil {
return user, err
}
if code != 200 {
return user, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["user"], &user)
return user, err
}
func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
pools := map[string]Pool{}
// get all pools
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
if err != nil {
return pools, err
}
if code != 200 {
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
if err != nil {
return pools, err
}
// get global config for resource type metadata
body = map[string]any{}
// get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", &auth, &body)
if err != nil {
return pools, err
}
if code != 200 {
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
}
meta := body["resources"].(map[string]any)
// 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)
r := v.(map[string]any)
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{}
}
// depending on type, decode the apool data into the corresponding resource type
switch t {
case "numeric":
n := paas.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
case "storage":
n := paas.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
case "list":
n := paas.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
}
// delete the old entry, only categories should be left at the end of the loop
//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, Type: t.Type,
Display: t.Display, Display: t.Display,
Name: t.Name, Name: t.Name,
@@ -126,9 +193,9 @@ func HandleGETAccount(c *gin.Context) {
Unit: t.Unit, Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(), ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
} }
case StorageResource: case paas.StorageResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base) avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
account.Resources[category][resource] = ResourceChart{ (*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
Type: t.Type, Type: t.Type,
Display: t.Display, Display: t.Display,
Name: t.Name, Name: t.Name,
@@ -139,7 +206,7 @@ func HandleGETAccount(c *gin.Context) {
Unit: t.Unit, Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(), ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
} }
case ListResource: case paas.ListResource:
l := struct { l := struct {
Type string Type string
Display bool Display bool
@@ -151,123 +218,25 @@ func HandleGETAccount(c *gin.Context) {
} }
for _, r := range t.Total { for _, r := range t.Total {
avail := fmt.Sprintf("%d", r.Avail)
l.Resources = append(l.Resources, ResourceChart{ l.Resources = append(l.Resources, ResourceChart{
Type: t.Type, Type: t.Type,
Display: t.Display, Display: t.Display,
Name: r.Name, Name: r.Name,
Used: r.Used, Used: r.Used,
Max: r.Max, Max: r.Max,
Avail: float64(r.Avail), // usually an int Avail: avail, // usually an int
Unit: "", Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(), ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
}) })
} }
account.Resources[category][resource] = l (*pools)[poolname].ResourceCharts[categoryname][resourcename] = l
} }
} }
} }
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"account": account,
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func GetUserAccount(auth common.Auth) (Account, error) {
account := Account{
Resources: map[string]map[string]any{},
} }
ctx := common.RequestContext{ return nil
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
// get user account basic data
body := map[string]any{}
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
}
err = mapstructure.Decode(body, &account)
if err != nil {
return account, err
} else {
account.Username = auth.Username
}
body = map[string]any{}
// get user resources
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
}
resources := body
body = map[string]any{}
// get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
}
meta := body["resources"].(map[string]any)
// build each resource by its meta type
for k, v := range meta {
m := v.(map[string]any)
t := m["type"].(string)
r := resources[k].(map[string]any)
category := m["category"].(string)
if _, ok := account.Resources[category]; !ok {
account.Resources[category] = map[string]any{}
}
if t == "numeric" {
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 account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
} else if t == "storage" {
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 account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
} else if t == "list" {
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 account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
}
}
return account, nil
} }
// interpolate between min and max by normalized (0 - 1) val // interpolate between min and max by normalized (0 - 1) val
+16 -21
View File
@@ -2,8 +2,8 @@ package routes
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"time" "time"
@@ -21,9 +21,9 @@ type InstanceBackup struct {
} }
func HandleGETBackups(c *gin.Context) { func HandleGETBackups(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -39,8 +39,6 @@ func HandleGETBackups(c *gin.Context) {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error())) common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
} }
log.Printf("%+v", backups)
c.HTML(http.StatusOK, "html/backups.html", gin.H{ c.HTML(http.StatusOK, "html/backups.html", gin.H{
"global": common.Global, "global": common.Global,
"page": "backups", "page": "backups",
@@ -53,9 +51,9 @@ func HandleGETBackups(c *gin.Context) {
} }
func HandleGETBackupsFragment(c *gin.Context) { func HandleGETBackupsFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population if err == nil { // user should be authed, try to return index with population
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -67,27 +65,24 @@ func HandleGETBackupsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
"backups": backups, "backups": backups,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401 } else { // return 401
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) { func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{} backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID) path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := []any{} body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &body) res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return backups, err return backups, err
} }
@@ -101,8 +96,8 @@ func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, e
} }
for i := range backups { for i := range backups {
size, prefix := common.FormatNumber(backups[i].Size, 1024) size, prefix := paas.FormatNumber(paas.SafeUint64(backups[i].Size), paas.Base1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix) backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0) t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05") backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
+61 -74
View File
@@ -12,19 +12,8 @@ import (
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
// imported types from fabric
type InstanceConfig struct { type InstanceConfig struct {
Type paas.InstanceType `json:"type"` paas.Instance `mapstructure:",squash"`
Name string `json:"name"`
CPU string `json:"cpu"`
Cores uint64 `json:"cores"`
Memory uint64 `json:"memory"`
Swap uint64 `json:"swap"`
Volumes map[string]*paas.Volume `json:"volumes"`
Nets map[string]*paas.Net `json:"nets"`
Devices map[string]*paas.Device `json:"devices"`
Boot paas.BootOrder `json:"boot"`
// overrides // overrides
ProctypeSelect common.Select ProctypeSelect common.Select
} }
@@ -35,21 +24,17 @@ type GlobalConfig struct {
} }
} }
type UserConfigResources struct { type PoolConfig struct {
CPU struct { CPU struct {
Global []CPUConfig Global []paas.Match
Nodes map[string][]CPUConfig Nodes map[string][]paas.Match
} }
} }
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) { func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -61,13 +46,13 @@ func HandleGETConfig(c *gin.Context) {
} }
if config.Type == "VM" { // if VM, fetch CPU types from node if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth) config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
if err != nil { if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error())) common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
} }
} }
for i, cpu := range config.ProctypeSelect.Options { for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.CPU { if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true config.ProctypeSelect.Options[i].Selected = true
} }
} }
@@ -83,9 +68,9 @@ func HandleGETConfig(c *gin.Context) {
} }
func HandleGETConfigVolumesFragment(c *gin.Context) { func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -97,19 +82,23 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func HandleGETConfigNetsFragment(c *gin.Context) { func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -121,19 +110,23 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func HandleGETConfigDevicesFragment(c *gin.Context) { func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -145,19 +138,23 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func HandleGETConfigBootFragment(c *gin.Context) { func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
vm_path, err := common.ExtractVMPath(c) vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return return
@@ -169,27 +166,24 @@ func HandleGETConfigBootFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) { func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
config := InstanceConfig{} config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID) path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{} body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body) res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return config, err return config, err
} }
@@ -208,60 +202,53 @@ func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, erro
return config, nil return config, nil
} }
func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) { func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
cputypes := common.Select{ cputypes := common.Select{
ID: "proctype", ID: "proctype",
Required: true, Required: true,
} }
// get global resource config // get global resource config
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{} body := map[string]any{}
path := "/global/config/resources" path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx, &body) res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
if code != 200 { if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
global := GlobalConfig{} globalConfig := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &global) err = mapstructure.Decode(body["resources"], &globalConfig)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// get user resource config // get pool resource config
body = map[string]any{} body = map[string]any{}
path = "/user/config/resources" path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, ctx, &body) res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
if code != 200 { if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
user := UserConfigResources{} poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(body, &user) err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// use node specific rules if present, otherwise use global rules // use node specific rules if present, otherwise use global rules
var userCPU []CPUConfig var userCPU []paas.Match
if _, ok := user.CPU.Nodes[vm.Node]; ok { if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
userCPU = user.CPU.Nodes[vm.Node] userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
} else { } else {
userCPU = user.CPU.Global userCPU = poolCPUConfig.CPU.Global
} }
if global.CPU.Whitelist { // cpu is a whitelist if globalConfig.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{ cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name, Display: cpu.Name,
@@ -271,8 +258,8 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
} else { // cpu is a blacklist } else { // cpu is a blacklist
// get the supported cpu types from the node // get the supported cpu types from the node
body = map[string]any{} body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node) path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
res, code, err = common.RequestGetAPI(path, ctx, &body) res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
@@ -280,7 +267,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
supported := struct { supported := struct {
data []CPUConfig data []paas.Match
}{} }{}
err = mapstructure.Decode(body, supported) err = mapstructure.Decode(body, supported)
if err != nil { if err != nil {
@@ -289,7 +276,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
// 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 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 { for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool { contains := slices.ContainsFunc(userCPU, func(c paas.Match) bool {
return c.Name == cpu.Name return c.Name == cpu.Name
}) })
if !contains { if !contains {
+37 -31
View File
@@ -3,6 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"net/http" "net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"strconv" "strconv"
@@ -45,7 +46,7 @@ type InstanceStatus struct {
} }
func HandleGETIndex(c *gin.Context) { func HandleGETIndex(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth) instances, _, err := GetClusterResources(auth)
if err != nil { if err != nil {
@@ -64,7 +65,7 @@ func HandleGETIndex(c *gin.Context) {
} }
func HandleGETInstancesFragment(c *gin.Context) { func HandleGETInstancesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth) instances, _, err := GetClusterResources(auth)
if err != nil { if err != nil {
@@ -72,25 +73,23 @@ func HandleGETInstancesFragment(c *gin.Context) {
return return
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
"instances": instances, "instances": instances,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401 } else { // return 401
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) { func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
ctx := common.RequestContext{ body := []any{}
Cookies: map[string]string{ res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -102,16 +101,17 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
nodes := map[string]Node{} nodes := map[string]Node{}
// parse /proxmox/cluster/resources to separate instances and nodes // parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range body["data"].([]any) { for _, v := range body {
m := v.(map[string]any) m := v.(map[string]any)
if m["type"] == "node" { // if type is node -> parse as Node object switch m["type"] {
case "node": // if type is node -> parse as Node object
node := Node{} node := Node{}
err := mapstructure.Decode(v, &node) err := mapstructure.Decode(v, &node)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
nodes[node.Node] = node nodes[node.Node] = node
} else if m["type"] == "lxc" || m["type"] == "qemu" { // if type is lxc or qemu -> parse as InstanceCard object case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
instance := InstanceCard{} instance := InstanceCard{}
err := mapstructure.Decode(v, &instance) err := mapstructure.Decode(v, &instance)
if err != nil { if err != nil {
@@ -127,9 +127,10 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
// set instance's config link path // set instance's config link path
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID) instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// set the instance's console link path // set the instance's console link path
if instance.Type == "qemu" { switch instance.Type {
case "qemu":
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node) instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
} else if instance.Type == "lxc" { case "lxc":
instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node) instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
} }
// set the instance's backups link path // set the instance's backups link path
@@ -138,8 +139,8 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
instances[vmid] = instance instances[vmid] = instance
} }
body = map[string]any{} body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body) res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -148,10 +149,10 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
} }
most_recent_task := map[uint]uint{} most_recent_task := map[uint]uint{}
expected_state := map[uint]string{} expected_states := map[uint]string{}
// iterate through recent user accessible tasks to find the task most recently made on an instance // iterate through recent user accessible tasks to find the task most recently made on an instance
for _, v := range body["data"].([]any) { for _, v := range body {
// parse task as Task object // parse task as Task object
task := Task{} task := Task{}
err := mapstructure.Decode(v, &task) err := mapstructure.Decode(v, &task)
@@ -179,24 +180,25 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
continue continue
} else { // recent task is a start or stop task for user instance which is running or "OK" } else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task most_recent_task[task.VMID] = task.EndTime // update the most recent task
if task.Type == "qmstart" || task.Type == "vzstart" { // if the task was a start task, update the expected state to running switch task.Type {
expected_state[task.VMID] = "running" case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
} else if task.Type == "qmstop" || task.Type == "vzstop" { // if the task was a stop task, update the expected state to stopped expected_states[task.VMID] = "running"
expected_state[task.VMID] = "stopped" case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
expected_states[task.VMID] = "stopped"
} }
} }
} }
} }
// iterate through the instances with recent tasks, refetch their state from a more reliable source // iterate through the instances with recent tasks, refetch their state from a more reliable source
for vmid, expected_state := range expected_state { // for the expected states from recent tasks for vmid, expected_state := range expected_states { // for the expected states from recent tasks
if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state
// get /status/current which is updated faster than /cluster/resources // get /status/current which is updated faster than /cluster/resources
instance := instances[vmid] instance := instances[vmid]
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID) path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
body = map[string]any{} body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body) res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -204,8 +206,12 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res) return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
// attempt to decode task status as instance status
status := InstanceStatus{} status := InstanceStatus{}
mapstructure.Decode(body["data"], &status) err = mapstructure.Decode(body, &status)
if err != nil { // did not successfully decode task status, just skip
continue
}
instance.Status = status.Status instance.Status = status.Status
instances[vmid] = instance instances[vmid] = instance
+3 -11
View File
@@ -9,11 +9,6 @@ import (
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
// used when requesting GET /access/domains
type GetRealmsBody struct {
Data []Realm `json:"data"`
}
// stores each realm's data // stores each realm's data
type Realm struct { type Realm struct {
Default int `json:"default"` Default int `json:"default"`
@@ -24,11 +19,8 @@ type Realm struct {
func GetLoginRealms() ([]Realm, error) { func GetLoginRealms() ([]Realm, error) {
realms := []Realm{} realms := []Realm{}
ctx := common.RequestContext{ body := []any{}
Cookies: nil, res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil { if err != nil {
return realms, err return realms, err
} }
@@ -36,7 +28,7 @@ func GetLoginRealms() ([]Realm, error) {
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res) return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
} }
for _, v := range body["data"].([]any) { for _, v := range body {
v = v.(map[string]any) v = v.(map[string]any)
realm := Realm{} realm := Realm{}
err := mapstructure.Decode(v, &realm) err := mapstructure.Decode(v, &realm)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func HandleGETSettings(c *gin.Context) { func HandleGETSettings(c *gin.Context) {
_, err := common.GetAuth(c) _, err := common.GetAuthFromRequest(c)
if err == nil { if err == nil {
c.HTML(http.StatusOK, "html/settings.html", gin.H{ c.HTML(http.StatusOK, "html/settings.html", gin.H{
"global": common.Global, "global": common.Global,
+3
View File
@@ -0,0 +1,3 @@
web/modules/*
WFA-JS/*
dist/*
+3
View File
@@ -0,0 +1,3 @@
web/modules/*
WFA-JS/*
dist/*
+29 -25
View File
@@ -1,29 +1,33 @@
import { defineConfig } from "eslint/config"; import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals"; import globals from "globals";
import js from "@eslint/js"; import js from "@eslint/js";
export default defineConfig([js.configs.recommended,{ export default defineConfig([
languageOptions: { js.configs.recommended,
globals: { globalIgnores(["dist/", "web/modules", "WFA-JS"]),
...globals.browser, {
languageOptions: {
globals: {
...globals.browser,
},
ecmaVersion: "latest",
sourceType: "module",
}, },
ecmaVersion: "latest", rules: {
sourceType: "module", "no-tabs": ["error", {
}, allowIndentationTabs: true,
rules: { }],
"no-tabs": ["error", { indent: ["error", "tab"],
allowIndentationTabs: true, "linebreak-style": ["error", "unix"],
}], quotes: ["error", "double"],
indent: ["error", "tab"], semi: ["error", "always"],
"linebreak-style": ["error", "unix"], "brace-style": ["error", "stroustrup", { allowSingleLine: false }],
quotes: ["error", "double"], "no-unused-vars": ["warn", {
semi: ["error", "always"], "argsIgnorePattern": "^_",
"brace-style": ["error", "stroustrup", { allowSingleLine: false }], "varsIgnorePattern": "^_",
"no-unused-vars": ["warn", { "caughtErrorsIgnorePattern": "^_"
"argsIgnorePattern": "^_", }],
"varsIgnorePattern": "^_", "prefer-const": ["error"]
"caughtErrorsIgnorePattern": "^_" },
}], }
"prefer-const": ["error"] ]);
},
}]);
+18 -16
View File
@@ -1,46 +1,48 @@
module proxmoxaas-dashboard module proxmoxaas-dashboard
go 1.26.2 go 1.26.5
require ( require (
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1 github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/go-viper/mapstructure/v2 v2.5.0 github.com/go-viper/mapstructure/v2 v2.5.0
github.com/tdewolff/minify/v2 v2.24.12 github.com/tdewolff/minify/v2 v2.24.13
proxmoxaas-common-lib v0.0.0 proxmoxaas-common-lib v0.0.0
web v0.0.0
) )
replace proxmoxaas-common-lib => ./proxmoxaas-common-lib replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
replace web => ./dist/web
require ( require (
github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.2 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-isatty v0.0.23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
golang.org/x/arch v0.26.0 // indirect golang.org/x/arch v0.29.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
-19
View File
@@ -1,19 +0,0 @@
{
"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"
}
}
+1 -4
View File
@@ -1,12 +1,9 @@
package main package main
import ( import (
"flag"
app "proxmoxaas-dashboard/app" app "proxmoxaas-dashboard/app"
) )
func main() { func main() {
configPath := flag.String("config", "config.json", "path to config.json file") app.Run()
flag.Parse()
app.Run(configPath)
} }
+25 -10
View File
@@ -5,7 +5,7 @@ input, select, textarea {
.input-grid { .input-grid {
display: grid; display: grid;
gap: 5px 10px; gap: 0.5em 1em;
align-items: center; align-items: center;
width: 100%; width: 100%;
} }
@@ -13,12 +13,12 @@ input, select, textarea {
.input-grid * { .input-grid * {
margin-top: 0; margin-top: 0;
margin-bottom: 0; margin-bottom: 0;
padding-top: 8px; padding-top: 0.5em;
padding-bottom: 8px; padding-bottom: 0.5em;
} }
.input-grid input { .input-grid input {
padding: 8px; padding: 0.5em;
} }
.input-grid img { .input-grid img {
@@ -34,17 +34,17 @@ legend {
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
line-height: 1.5em; margin-top: 0.25em;
margin-top: 0.25lh; margin-bottom: 0.25em;
margin-bottom: 0.25lh;
} }
fieldset { fieldset {
border: 0; border: 0;
padding: 0;
} }
fieldset > *:last-child { fieldset > *:last-child {
margin-bottom: 8px; margin-bottom: 0.5em;
} }
fieldset > .input-grid { fieldset > .input-grid {
@@ -66,7 +66,7 @@ input[type="radio"] {
} }
.w3-select, select { .w3-select, select {
padding: 8px; padding: 0.5em;
} }
.w3-check { .w3-check {
@@ -77,10 +77,25 @@ input[type="radio"] {
:not(.input-grid) .input-grid + * { :not(.input-grid) .input-grid + * {
display: inline-block; display: inline-block;
width: 100%; width: 100%;
margin-top: 5px; margin-top: 0.5em;
} }
dialog { dialog {
margin: auto;
max-width: calc(min(100% - 16px, 80ch)); max-width: calc(min(100% - 16px, 80ch));
color: var(--main-text-color); color: var(--main-text-color);
}
dialog #prompt {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
dialog button[value="confirm"] {
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
}
dialog button[value="cancel"] {
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
} }
+6 -6
View File
@@ -53,7 +53,7 @@ header {
} }
header h1 { header h1 {
font-size: 18px; font-size: var(--small-font-size);
margin: 0; margin: 0;
background-color: var(--nav-header-bg-color); background-color: var(--nav-header-bg-color);
color: var(--nav-header-text-color); color: var(--nav-header-text-color);
@@ -61,15 +61,15 @@ header h1 {
} }
nav { nav {
font-size: var(--small-font-size);
overflow: hidden; overflow: hidden;
font-size: larger;
width: fit-content; width: fit-content;
} }
nav a, header h1, label[for="navtoggle"] { nav a, header h1, label[for="navtoggle"] {
text-align: left; text-align: left;
padding-left: 8px; padding-left: 0.5em;
padding-right: 8px; padding-right: 0.5em;
text-decoration: none; text-decoration: none;
vertical-align: middle; vertical-align: middle;
height: 2em; height: 2em;
@@ -80,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
display: none; display: none;
} }
@media screen and (width >= 600px){ @media screen and (width >= 601px){
header { header {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }
@@ -106,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
} }
} }
@media screen and (width <= 600px){ @media screen and (width <= 601px){
header { header {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
+43 -1
View File
@@ -3,6 +3,9 @@
--positive-color: #0f0; --positive-color: #0f0;
--highlight-color: yellow; --highlight-color: yellow;
--lightbg-text-color: black; --lightbg-text-color: black;
--large-font-size: 32px;
--medium-font-size: 24px;
--small-font-size: 16px;
} }
@media screen and (prefers-color-scheme: dark) { @media screen and (prefers-color-scheme: dark) {
@@ -41,9 +44,35 @@
} }
} }
* { *, h1, h2, h3, h4, h5, h6, p {
box-sizing: border-box; box-sizing: border-box;
font-family: monospace; font-family: monospace;
line-height: normal;
margin: 0;
padding: 0;
}
h2 {
font-size: var(--large-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h3 {
font-size: var(--medium-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h4, legend {
font-size: var(--small-font-size);
text-decoration: underline;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
p {
font-size: var(--small-font-size);
} }
html { html {
@@ -101,6 +130,19 @@ hr {
align-items: center; 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 { .column-reverse {
flex-direction: column-reverse; flex-direction: column-reverse;
row-gap: 10px; row-gap: 10px;
+3
View File
@@ -0,0 +1,3 @@
module web
go 1.26.0
+12 -45
View File
@@ -34,71 +34,38 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<h2>Account</h2> <h2>Account</h2>
<section class="w3-card w3-padding"> <section class="w3-card w3-padding">
<h3>Account Details</h3> <h3>Account Details</h3>
<p id="username">Username: {{.account.Username}}</p> <p id="username">Username: {{.user.Username.UserID}}@{{.user.Username.Realm}}</p>
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p> <p id="email">Email: {{.user.Mail}}</p>
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</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>
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
</section>
<section class="w3-card w3-padding">
<div class="flex row nowrap">
<h3>Password</h3>
<button class="w3-button w3-margin" id="change-password" type="button">Change Password</button>
</div>
</section>
<section class="w3-card w3-padding">
<h3>Cluster Resources</h3>
<div>
{{range $category, $v := .account.Resources}}
{{if ne $category ""}}
<h4>{{$category}}</h4>
{{end}}
<div class="resource-container">
{{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}}
</div>
{{end}}
</div>
</section> </section>
{{range $poolname, $pool := .pools}}
{{template "pool-resources" $pool}}
{{end}}
</main> </main>
<template id="change-password-dialog"> <template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Change Password Change Password
</p> </p>
<div id="body"> <div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form"> <form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="new-password">New Password</label> <label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" required> <input class="w3-input w3-border" id="new-password" name="new-password" type="password" autocomplete="new-password" required>
<label for="confirm-password">Confirm Password</label> <label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required> <input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" autocomplete="new-password" required>
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
+1 -3
View File
@@ -9,9 +9,7 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2> <h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
<section class="w3-card w3-padding"> <section class="w3-card w3-padding">
+2 -4
View File
@@ -19,12 +19,10 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<section> <section>
<h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2> <h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
<form id="config-form"> <form id="config-form">
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
<legend>Resources</legend> <legend>Resources</legend>
+9 -11
View File
@@ -65,16 +65,14 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<section> <section>
<h2>Instances</h2> <h2>Instances</h2>
<div class="w3-card w3-padding"> <div class="w3-card w3-padding">
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;"> <div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0"> <form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
<img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"> <button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button>
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name"> <input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
</form> </form>
<!--Add Instance Button & Dialog Template--> <!--Add Instance Button & Dialog Template-->
@@ -84,7 +82,7 @@
</button> </button>
<template id="create-instance-dialog"> <template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Create New Instance Create New Instance
</p> </p>
<div id="body"> <div id="body">
@@ -94,14 +92,14 @@
<option value="lxc">Container</option> <option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option> <option value="qemu">Virtual Machine</option>
</select> </select>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="node">Node</label> <label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select> <select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label> <label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" type="text" required> <input class="w3-input w3-border" name="name" id="name" type="text" required>
<label for="vmid">ID</label> <label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required> <input class="w3-input w3-border" name="vmid" id="vmid" type="number" required>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="cores">Cores (Threads)</label> <label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required> <input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
<label for="memory">Memory (MiB)</label> <label for="memory">Memory (MiB)</label>
@@ -116,14 +114,14 @@
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label> <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> <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> <label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled> <input class="w3-input w3-border container-specific none" name="password" id="password" type="password" autocomplete="new-password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label> <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" required disabled> <input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" autocomplete="new-password" required disabled>
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
+1 -3
View File
@@ -7,9 +7,7 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main class="flex" style="justify-content: center; align-items: center;"> <main class="flex" style="justify-content: center; align-items: center;">
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;"> <div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
<h2 class="w3-center">{{.global.Organization}} Login</h2> <h2 class="w3-center">{{.global.Organization}} Login</h2>
+14 -11
View File
@@ -5,30 +5,31 @@
<script src="scripts/settings.js" type="module"></script> <script src="scripts/settings.js" type="module"></script>
<link rel="modulepreload" href="scripts/utils.js"> <link rel="modulepreload" href="scripts/utils.js">
<style> <style>
legend {
margin-bottom: 25px;
}
label { label {
display: flex; display: flex;
height: fit-content; height: fit-content;
width: 100%; width: 100%;
align-items: center; align-items: center;
justify-content: left; justify-content: left;
column-gap: 10px; column-gap: 0.5em;
} }
label + p { label + p {
margin-top: 5px; margin-top: 0.5em;
margin-bottom: 25px; margin-bottom: 1em;
} }
p:last-child { p:last-child {
margin-bottom: 0px; margin-bottom: 0;
}
#save.disabled {
display: none;
}
#save.enabled {
display: unset;
} }
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<h2>Settings</h2> <h2>Settings</h2>
<form id="settings"> <form id="settings">
@@ -42,6 +43,8 @@
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p> <p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label> <label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p> <p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>App Sync Frequency</legend> <legend>App Sync Frequency</legend>
@@ -73,7 +76,7 @@
</fieldset> </fieldset>
</section> </section>
<div class="w3-container w3-center" id="form-actions"> <div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button> <button class="w3-button w3-margin disabled" id="save" type="submit">SAVE</button>
</div> </div>
</form> </form>
</main> </main>
@@ -1 +0,0 @@
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 32 B

+1
View File
@@ -0,0 +1 @@
../../common/config-inactive.svg

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 32 B

+1
View File
@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+579 -2
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+2 -2
View File
@@ -1,5 +1,5 @@
import { requestAPI, setAppearance } from "./utils.js"; import { requestAPI, setAppearance } from "./utils.js";
import { dialog } from "./dialog.js"; import { dialog, error } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
@@ -15,7 +15,7 @@ function handlePasswordChangeButton () {
if (result === "confirm") { if (result === "confirm") {
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") }); const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to change password but got: ${result.error}`); error(`Attempted to change password but got: ${result.error}`);
} }
} }
}); });
+9 -13
View File
@@ -1,5 +1,5 @@
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js"; import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js"; import { error, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
@@ -74,7 +74,7 @@ class BackupCard extends HTMLElement {
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to edit backup but got: ${result.error}`); error(`Attempted to edit backup but got: ${result.error}`);
} }
refreshBackups(); refreshBackups();
} }
@@ -83,14 +83,14 @@ class BackupCard extends HTMLElement {
async handleDeleteButton () { async handleDeleteButton () {
const template = this.shadowRoot.querySelector("#delete-dialog"); const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => { dialog(template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
volid: this.volid volid: this.volid
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete backup but got: ${result.error}`); error(`Attempted to delete backup but got: ${result.error}`);
} }
refreshBackups(); refreshBackups();
} }
@@ -99,14 +99,14 @@ class BackupCard extends HTMLElement {
async handleRestoreButton () { async handleRestoreButton () {
const template = this.shadowRoot.querySelector("#restore-dialog"); const template = this.shadowRoot.querySelector("#restore-dialog");
dialog(template, async (result, form) => { dialog(template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
volid: this.volid volid: this.volid
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete backup but got: ${result.error}`); error(`Attempted to delete backup but got: ${result.error}`);
} }
refreshBackups(); refreshBackups();
} }
@@ -116,14 +116,10 @@ class BackupCard extends HTMLElement {
customElements.define("backup-card", BackupCard); customElements.define("backup-card", BackupCard);
async function getBackupsFragment () {
return await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
}
async function refreshBackups () { async function refreshBackups () {
let backups = await getBackupsFragment(); let backups = await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (backups.status !== 200) { if (backups.status !== 200) {
alert("Error fetching backups."); error("Error fetching backups.");
} }
else { else {
backups = backups.data; backups = backups.data;
@@ -141,7 +137,7 @@ async function handleBackupAddButton () {
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to create backup but got: ${result.error}`); error(`Attempted to create backup but got: ${result.error}`);
} }
refreshBackups(); refreshBackups();
} }
+11 -7
View File
@@ -1,9 +1,13 @@
import { getSyncSettings, requestAPI } from "./utils.js"; import { getSetting, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
export async function setupClientSync (callback) { export async function setupClientSync (callback) {
const { scheme, rate } = getSyncSettings(); const scheme = getSetting("sync-scheme");
const rate = getSetting("sync-rate");
if (scheme === "always") { if (scheme === "never") {
return;
}
else if (scheme === "always") {
window.setInterval(callback, rate * 1000); window.setInterval(callback, rate * 1000);
} }
else if (scheme === "hash") { else if (scheme === "hash") {
@@ -19,7 +23,7 @@ export async function setupClientSync (callback) {
} }
else if (scheme === "interrupt") { else if (scheme === "interrupt") {
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/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.send(`rate ${rate}`);
}); });
socket.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
@@ -28,12 +32,12 @@ export async function setupClientSync (callback) {
callback(); callback();
} }
else { else {
console.error("clientsync: recieved unexpected message from server, closing socket."); error("clientsync: recieved unexpected message from server, closing socket.");
socket.close(); socket.close();
} }
}); });
} }
else { else {
console.error(`clientsync: unsupported scheme ${scheme} selected.`); error(`clientsync: unsupported scheme ${scheme} selected.`);
} }
} }
+29 -29
View File
@@ -1,5 +1,5 @@
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js"; import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js"; import { error, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
@@ -54,12 +54,12 @@ class VolumeAction extends HTMLElement {
async handleDiskDetach () { async handleDiskDetach () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
dialog(this.template, async (result, form) => { dialog(this.template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to detach ${disk} but got: ${result.error}`); error(`Attempted to detach ${disk} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -80,7 +80,7 @@ class VolumeAction extends HTMLElement {
const disk = `${prefix}${device}`; const disk = `${prefix}${device}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`); error(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -98,7 +98,7 @@ class VolumeAction extends HTMLElement {
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to resize ${disk} but got: ${result.error}`); error(`Attempted to resize ${disk} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -117,7 +117,7 @@ class VolumeAction extends HTMLElement {
}; };
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`); error(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -136,12 +136,12 @@ class VolumeAction extends HTMLElement {
async handleDiskDelete () { async handleDiskDelete () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
dialog(this.template, async (result, form) => { dialog(this.template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete ${disk} but got: ${result.error}`); error(`Attempted to delete ${disk} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -162,7 +162,7 @@ async function initVolumes () {
async function refreshVolumes () { async function refreshVolumes () {
let volumes = await requestDash(`/config/volumes?node=${node}&type=${type}&vmid=${vmid}`, "GET"); let volumes = await requestDash(`/config/volumes?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (volumes.status !== 200) { if (volumes.status !== 200) {
alert("Error fetching instance volumes."); error("Error fetching instance volumes.");
} }
else { else {
volumes = volumes.data; volumes = volumes.data;
@@ -186,7 +186,7 @@ async function handleDiskAdd () {
const disk = `${prefix}${id}`; const disk = `${prefix}${id}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to create ${disk} but got: ${result.error}`); error(`Attempted to create ${disk} but got: ${result.error}`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -214,7 +214,7 @@ async function handleCDAdd () {
const disk = `ide${form.get("device")}`; const disk = `ide${form.get("device")}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to mount ${body.iso} to ${disk} but got: result.error`); error(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
} }
refreshVolumes(); refreshVolumes();
refreshBoot(); refreshBoot();
@@ -224,7 +224,7 @@ async function handleCDAdd () {
const isos = await requestAPI("/user/vm-isos", "GET"); const isos = await requestAPI("/user/vm-isos", "GET");
const select = d.querySelector("#iso-select"); 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.add(new Option(iso.name, iso.volid));
} }
select.selectedIndex = -1; select.selectedIndex = -1;
@@ -263,7 +263,7 @@ class NetworkAction extends HTMLElement {
const net = `${netID}`; const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/modify`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/modify`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to change ${net} but got: ${result.error}`); error(`Attempted to change ${net} but got: ${result.error}`);
} }
refreshNetworks(); refreshNetworks();
refreshBoot(); refreshBoot();
@@ -275,13 +275,13 @@ class NetworkAction extends HTMLElement {
async handleNetworkDelete () { async handleNetworkDelete () {
const netID = this.dataset.network; const netID = this.dataset.network;
dialog(this.template, async (result, form) => { dialog(this.template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg"); setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `${netID}`; const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete ${net} but got: ${result.error}`); error(`Attempted to delete ${net} but got: ${result.error}`);
} }
refreshNetworks(); refreshNetworks();
refreshBoot(); refreshBoot();
@@ -299,7 +299,7 @@ async function initNetworks () {
async function refreshNetworks () { async function refreshNetworks () {
let nets = await requestDash(`/config/nets?node=${node}&type=${type}&vmid=${vmid}`, "GET"); let nets = await requestDash(`/config/nets?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (nets.status !== 200) { if (nets.status !== 200) {
alert("Error fetching instance nets."); error("Error fetching instance nets.");
} }
else { else {
nets = nets.data; nets = nets.data;
@@ -324,7 +324,7 @@ async function handleNetworkAdd () {
const net = `net${id}`; const net = `net${id}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/create`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/create`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to create ${net} but got: ${result.error}`); error(`Attempted to create ${net} but got: ${result.error}`);
} }
refreshNetworks(); refreshNetworks();
refreshBoot(); refreshBoot();
@@ -367,7 +367,7 @@ class DeviceAction extends HTMLElement {
const device = `${deviceID}`; const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/modify`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/modify`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to add ${device} but got: ${result.error}`); error(`Attempted to add ${device} but got: ${result.error}`);
} }
refreshDevices(); refreshDevices();
} }
@@ -375,7 +375,7 @@ class DeviceAction extends HTMLElement {
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0])); 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("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
} }
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1"); d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
@@ -383,13 +383,13 @@ class DeviceAction extends HTMLElement {
async handleDeviceDelete () { async handleDeviceDelete () {
const deviceID = this.dataset.device; const deviceID = this.dataset.device;
dialog(this.template, async (result, form) => { dialog(this.template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const device = `${deviceID}`; const device = `${deviceID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE");
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete ${device} but got: ${result.error}`); error(`Attempted to delete ${device} but got: ${result.error}`);
} }
refreshDevices(); refreshDevices();
} }
@@ -408,7 +408,7 @@ async function initDevices () {
async function refreshDevices () { async function refreshDevices () {
let devices = await requestDash(`/config/devices?node=${node}&type=${type}&vmid=${vmid}`, "GET"); let devices = await requestDash(`/config/devices?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (devices.status !== 200) { if (devices.status !== 200) {
alert("Error fetching instance devices."); error("Error fetching instance devices.");
} }
else { else {
devices = devices.data; devices = devices.data;
@@ -431,14 +431,14 @@ async function handleDeviceAdd () {
const deviceID = `hostpci${hostpci}`; const deviceID = `hostpci${hostpci}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body);
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to add ${body.device} but got: ${result.error}`); error(`Attempted to add ${body.device} but got: ${result.error}`);
} }
refreshDevices(); refreshDevices();
} }
}); });
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
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("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
} }
d.querySelector("#pcie").checked = true; d.querySelector("#pcie").checked = true;
@@ -447,12 +447,12 @@ async function handleDeviceAdd () {
async function refreshBoot () { async function refreshBoot () {
let boot = await requestDash(`/config/boot?node=${node}&type=${type}&vmid=${vmid}`, "GET"); let boot = await requestDash(`/config/boot?node=${node}&type=${type}&vmid=${vmid}`, "GET");
if (boot.status !== 200) { if (boot.status !== 200) {
alert("Error fetching instance boot order."); error("Error fetching instance boot order.");
} }
else if (type === "qemu") { else if (type === "qemu") {
boot = boot.data; boot = boot.data;
const order = document.querySelector("#boot-order"); const container = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot); container.setHTMLUnsafe(boot);
} }
} }
@@ -474,6 +474,6 @@ async function handleFormExit (event) {
goToPage("index"); goToPage("index");
} }
else { else {
alert(`Attempted to set basic resources but got: ${result.error}`); error(`Attempted to set basic resources but got: ${result.error}`);
} }
} }
+10 -34
View File
@@ -17,7 +17,7 @@
* body contains an optional form or other information, * body contains an optional form or other information,
* and controls contains a series of buttons which controls the form * 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); const dialog = template.content.querySelector("dialog").cloneNode(true);
document.body.append(dialog); document.body.append(dialog);
dialog.addEventListener("close", async () => { dialog.addEventListener("close", async () => {
@@ -41,33 +41,6 @@ export function dialog (template, onclose = async (result, form) => { }) {
return dialog; 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-center" style="margin-bottom: 0px;">${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 { class ErrorDialog extends HTMLElement {
shadowRoot = null; shadowRoot = null;
dialog = null; dialog = null;
@@ -82,19 +55,22 @@ class ErrorDialog extends HTMLElement {
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<style> <style>
#errors { #errors {
margin-bottom: 0px; margin-bottom: 0;
max-height: 20lh; max-height: 20lh;
min-height: 20lh; min-height: 20lh;
overflow-y: scroll; overflow-y: scroll;
padding-left: 12px;
padding-right: 12px;
} }
#errors * { #errors * {
margin: 0px; margin: 0;
width: 100%;
} }
</style> </style>
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<form method="dialog"> <form method="dialog">
<p class="w3-large" id="prompt" style="text-align: center;">Error</p> <p class="w3-large" id="prompt">Error</p>
<div id="errors" class="flex column-reverse"></div> <div id="errors" class="flex column"></div>
<div class="w3-center" id="controls"> <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="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button> <button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
@@ -120,7 +96,7 @@ class ErrorDialog extends HTMLElement {
} }
navigator.clipboard.writeText(errors); navigator.clipboard.writeText(errors);
} }
this.parentElement.removeChild(this); this.dialog.close();
}); });
} }
@@ -140,7 +116,7 @@ customElements.define("error-dialog", ErrorDialog);
export function error (message) { export function error (message) {
let dialog = document.querySelector("error-dialog"); let dialog = document.querySelector("error-dialog");
if (dialog == null) { if (dialog === null) {
dialog = document.createElement("error-dialog"); dialog = document.createElement("error-dialog");
document.body.append(dialog); document.body.append(dialog);
dialog.appendError(message); dialog.appendError(message);
+1 -1
View File
@@ -13,7 +13,7 @@ class DraggableContainer extends HTMLElement {
window.Sortable.create(this.content, { window.Sortable.create(this.content, {
group: this.dataset.group, group: this.dataset.group,
ghostClass: "ghost", ghostClass: "ghost",
setData: function (dataTransfer, dragEl) { setData: function (dataTransfer, _dragEl) {
dataTransfer.setDragImage(blank, 0, 0); dataTransfer.setDragImage(blank, 0, 0);
} }
}); });
+60 -66
View File
@@ -1,14 +1,16 @@
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 { dialog, error } from "./dialog.js";
import { setupClientSync } from "./clientsync.js"; import { setupClientSync } from "./clientsync.js";
import wfaInit from "../modules/wfa.js"; import wfaInit from "../modules/wfa.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
var wfa;
async function init () { async function init () {
setAppearance(); setAppearance();
wfaInit("modules/wfa.wasm"); wfa = await wfaInit("modules/wfa.wasm");
initInstances(); initInstances();
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton); document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
@@ -159,7 +161,7 @@ class InstanceCard extends HTMLElement {
async handlePowerButton () { async handlePowerButton () {
if (!this.actionLock) { if (!this.actionLock) {
const template = this.shadowRoot.querySelector("#power-dialog"); const template = this.shadowRoot.querySelector("#power-dialog");
dialog(template, async (result, form) => { dialog(template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
const targetAction = this.status === "running" ? "stop" : "start"; const targetAction = this.status === "running" ? "stop" : "start";
@@ -175,7 +177,7 @@ class InstanceCard extends HTMLElement {
break; break;
} }
else if (taskStatus.data.status === "stopped") { // task stopped but was not successful else if (taskStatus.data.status === "stopped") { // task stopped but was not successful
alert(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`); error(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`);
break; break;
} }
else { // task has not stopped else { // task has not stopped
@@ -193,7 +195,7 @@ class InstanceCard extends HTMLElement {
handleDeleteButton () { handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") { if (!this.actionLock && this.status === "stopped") {
const template = this.shadowRoot.querySelector("#delete-dialog"); const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => { dialog(template, async (result, _form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
@@ -203,7 +205,7 @@ class InstanceCard extends HTMLElement {
const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE");
if (result.status !== 200) { if (result.status !== 200) {
alert(`Attempted to delete ${this.vmid} but got: ${result.error}`); error(`Attempted to delete ${this.vmid} but got: ${result.error}`);
} }
this.actionLock = false; this.actionLock = false;
@@ -216,12 +218,8 @@ class InstanceCard extends HTMLElement {
customElements.define("instance-card", InstanceCard); customElements.define("instance-card", InstanceCard);
async function getInstancesFragment () {
return await requestDash("/index/instances", "GET");
}
async function refreshInstances () { async function refreshInstances () {
let instances = await getInstancesFragment(); let instances = await requestDash("/index/instances", "GET");
if (instances.status !== 200) { if (instances.status !== 200) {
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`); error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
} }
@@ -243,11 +241,11 @@ function initInstances () {
} }
function sortInstances () { function sortInstances () {
const searchCriteria = getSearchSettings(); const searchCriteria = getSetting("search-criteria");
const searchQuery = document.querySelector("#search").value || null; const searchQuery = document.querySelector("#search").value || null;
let criteria; let criteria;
if (!searchQuery) { if (!searchQuery) {
criteria = (item, query = null) => { criteria = (item, _query = null) => {
return { score: item.vmid, alignment: null }; return { score: item.vmid, alignment: null };
}; };
} }
@@ -276,8 +274,8 @@ function sortInstances () {
}; };
criteria = (item, query) => { criteria = (item, query) => {
// lower is better // lower is better
const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true); const { score, CIGAR } = wfa.wfAlign(query, item, penalties, true);
const alignment = global.wfa.DecodeCIGAR(CIGAR); const alignment = wfa.DecodeCIGAR(CIGAR);
return { score: score / item.length, alignment }; return { score: score / item.length, alignment };
}; };
} }
@@ -337,16 +335,16 @@ async function handleInstanceAddButton () {
refreshInstances(); refreshInstances();
} }
else { else {
alert(`Attempted to create new instance ${vmid} but got: ${result.error}`); error(`Attempted to create new instance ${vmid} but got: ${result.error}`);
refreshInstances(); refreshInstances();
} }
} }
}); });
const templates = await requestAPI("/user/ct-templates", "GET"); // setup type select
const typeSelect = d.querySelector("#type"); const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1; typeSelect.selectedIndex = -1;
// on type change, reveal or hide the container specific section
typeSelect.addEventListener("change", () => { typeSelect.addEventListener("change", () => {
if (typeSelect.value === "qemu") { if (typeSelect.value === "qemu") {
d.querySelectorAll(".container-specific").forEach((element) => { d.querySelectorAll(".container-specific").forEach((element) => {
@@ -366,66 +364,62 @@ async function handleInstanceAddButton () {
element.disabled = true; element.disabled = true;
}); });
const rootfsContent = "rootdir"; // setup pool select
const rootfsStorage = d.querySelector("#rootfs-storage"); const poolSelect = d.querySelector("#pool");
rootfsStorage.selectedIndex = -1; poolSelect.innerHTML = "";
// add user pools to selector
const userResources = await requestAPI("/user/dynamic/resources", "GET"); const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
const userCluster = await requestAPI("/user/config/cluster", "GET"); userPools.forEach((element) => {
poolSelect.add(new Option(element));
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));
}
}); });
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; 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 nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value; const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
rootfsStorage.innerHTML = ""; rootfsStorage.innerHTML = "";
storage.data.forEach((element) => { storage.forEach((element) => {
if (element.content.includes(rootfsContent)) { if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage)); rootfsStorage.add(new Option(element.storage));
} }
}); });
rootfsStorage.selectedIndex = -1; 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 // setup root dir select
d.querySelector("#vmid").min = userCluster.vmid.min; const rootfsStorage = d.querySelector("#rootfs-storage");
d.querySelector("#vmid").max = userCluster.vmid.max; rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
// add user pools to selector const rootfsContent = "rootdir";
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 templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector // add template images to selector
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage const templates = await requestAPI("/user/ct-templates", "GET");
for (const template of templates) { for (const template of templates.data) {
templateImage.append(new Option(template.name, template.volid)); templateImage.append(new Option(template.name, template.volid));
} }
templateImage.selectedIndex = -1; templateImage.selectedIndex = -1;
+5 -6
View File
@@ -1,11 +1,12 @@
import { goToPage, setAppearance, requestAPI } from "./utils.js"; import { goToPage, setAppearance, requestAPI } from "./utils.js";
import { alert } from "./dialog.js"; import { error } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
async function init () { async function init () {
await deleteAllCookies(); await deleteAllCookies();
setAppearance(); setAppearance();
const formSubmitButton = document.querySelector("#submit"); const formSubmitButton = document.querySelector("#submit");
formSubmitButton.addEventListener("click", async (e) => { formSubmitButton.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
@@ -19,18 +20,16 @@ async function init () {
goToPage("index"); goToPage("index");
} }
else if (ticket.status === 401) { else if (ticket.status === 401) {
alert("Authenticaton failed."); error("Authenticaton failed.");
formSubmitButton.innerText = "LOGIN"; formSubmitButton.innerText = "LOGIN";
} }
else if (ticket.status === 408) { else if (ticket.status === 408) {
alert("Network error."); error("Network error.");
formSubmitButton.innerText = "LOGIN"; formSubmitButton.innerText = "LOGIN";
} }
else { else {
alert("An error occured."); error(`An error occured: ${JSON.stringify(ticket)}`);
console.error(ticket);
formSubmitButton.innerText = "LOGIN"; formSubmitButton.innerText = "LOGIN";
console.error(ticket.error);
} }
}); });
} }
+46 -19
View File
@@ -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); window.addEventListener("DOMContentLoaded", init);
function init () { function init () {
setAppearance(); setAppearance();
const { scheme, rate } = getSyncSettings();
if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true;
}
if (rate) {
document.querySelector("#sync-rate").value = rate;
}
const search = getSearchSettings(); document.querySelectorAll("[id^=sync-]").forEach((v) => {
if (search) { v.addEventListener("change", handleSettingsChange);
document.querySelector(`#search-${search}`).checked = true; });
} document.querySelector(`#sync-${getSetting("sync-scheme")}`).checked = true;
document.querySelector("#sync-rate").value = getSetting("sync-rate");
const theme = getThemeSettings(); document.querySelectorAll("[id^=search-]").forEach((v) => {
if (theme) { v.addEventListener("change", handleSettingsChange);
document.querySelector("#appearance-theme").value = theme; });
} 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); 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) { function handleSaveSettings (event) {
event.preventDefault(); event.preventDefault();
const form = new FormData(document.querySelector("#settings")); const form = new FormData(document.querySelector("#settings"));
setSyncSettings(form.get("sync-scheme"), form.get("sync-rate")); const saveBtn = document.querySelector("#save");
setSearchSettings(form.get("search-criteria")); setSetting("sync-scheme", form.get("sync-scheme"));
setThemeSettings(form.get("appearance-theme")); 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(); init();
} }
+30 -60
View File
@@ -80,33 +80,34 @@ async function request (url, content) {
try { try {
const response = await fetch(url, content); const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type"); const contentType = response.headers.get("Content-Type");
let data = null; const res = {};
if (contentType === null) { if (contentType === null) {
data = {}; res.data = null;
res.status = response.status;
} }
else if (contentType.includes("application/json")) { else if (contentType.includes("application/json")) {
data = await response.json(); res.data = await response.json();
data.status = response.status; res.status = response.status;
} }
else if (contentType.includes("text/html")) { else if (contentType.includes("text/html")) {
data = { data: await response.text() }; res.data = await response.text();
data.status = response.status; res.status = response.status;
} }
else if (contentType.includes("text/plain")) { else if (contentType.includes("text/plain")) {
data = { data: await response.text() }; res.data = await response.text();
data.status = response.status; res.status = response.status;
} }
else { else {
data = {}; res.data = null;
res.status = response.status;
} }
if (!response.ok) { 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 { else {
data.status = response.status; return res;
return data || response;
} }
} }
catch (error) { catch (error) {
@@ -124,60 +125,30 @@ export function getURIData () {
return Object.fromEntries(url.searchParams); return Object.fromEntries(url.searchParams);
} }
const settingsDefault = { const settings = {
"sync-scheme": "always", "sync-scheme": {"type": String, "default": "always"},
"sync-rate": 5, "sync-rate": {"type": Number, "default": 5},
"search-criteria": "fuzzy", "search-criteria": {"type": String, "default": "fuzzy"},
"appearance-theme": "auto" "appearance-theme": {"type": String, "default": "auto"}
}; };
export function getSyncSettings () { export function getSetting (key) {
let scheme = localStorage.getItem("sync-scheme"); const meta = settings[key];
let rate = Number(localStorage.getItem("sync-rate")); let value = localStorage.getItem(key);
if (!scheme) { if (value === null || meta === null) {
scheme = settingsDefault["sync-scheme"]; value = meta.default;
localStorage.setItem("sync-scheme", scheme); localStorage.setItem(key, meta.default);
} }
if (!rate) {
rate = settingsDefault["sync-rate"]; return meta.type(value);
localStorage.setItem("sync-rate", rate);
}
return { scheme, rate };
} }
export function getSearchSettings () { export function setSetting (key, value) {
let searchCriteria = localStorage.getItem("search-criteria"); localStorage.setItem(key, value);
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 () { export function setAppearance () {
const theme = getThemeSettings(); const theme = getSetting("appearance-theme");
if (theme === "auto") { if (theme === "auto") {
document.querySelector(":root").classList.remove("dark-theme", "light-theme"); 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) { export function setIconSrc (icon, path) {
icon.setAttribute("src", path); icon.setAttribute("src", path);
} }
+14 -14
View File
@@ -10,8 +10,8 @@
a { a {
height: 1em; height: 1em;
width: 1em; width: 1em;
margin: 0px; margin: 0;
padding: 0px; padding: 0;
} }
</style> </style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;"> <div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
@@ -29,7 +29,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Edit Backup Edit Backup
</p> </p>
<div id="body"> <div id="body">
@@ -39,8 +39,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -49,7 +49,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Delete Backup Delete Backup
</p> </p>
<div id="body"> <div id="body">
@@ -60,8 +60,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -70,7 +70,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Restore From Backup? Restore From Backup?
</p> </p>
<div id="body"> <div id="body">
@@ -84,8 +84,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -103,7 +103,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Create Backup Create Backup
</p> </p>
<div id="body"> <div id="body">
@@ -113,8 +113,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
+17 -13
View File
@@ -1,3 +1,4 @@
{{/* <head> common across all pages*/}}
{{define "head"}} {{define "head"}}
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -14,18 +15,21 @@
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
{{end}} {{end}}
{{/* <header> common across all pages*/}}
{{define "header"}} {{define "header"}}
<h1>{{.global.Organization}}</h1> <header>
<label for="navtoggle">&#9776;</label> <h1>{{.global.Organization}}</h1>
<input type="checkbox" id="navtoggle"> <label for="navtoggle">&#9776;</label>
<nav id="navigation"> <input type="checkbox" id="navtoggle">
{{if eq .page "login"}} <nav id="navigation">
<a href="login" aria-current="page">Login</a> {{if eq .page "login"}}
{{else}} <a href="login" aria-current="page">Login</a>
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a> {{else}}
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a> <a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a> <a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
<a href="login">Logout</a> <a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
{{end}} <a href="login">Logout</a>
</nav> {{end}}
</nav>
</header>
{{end}} {{end}}
+59 -52
View File
@@ -50,7 +50,7 @@
</button> </button>
<template id="add-disk-dialog"> <template id="add-disk-dialog">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Create New Disk Create New Disk
</p> </p>
<div id="body"> <div id="body">
@@ -65,8 +65,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -79,7 +79,7 @@
</button> </button>
<template id="add-cd-dialog"> <template id="add-cd-dialog">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Mount a CDROM Mount a CDROM
</p> </p>
<div id="body"> <div id="body">
@@ -89,8 +89,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -163,7 +163,7 @@
<img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb"> <img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Move {{.Name}} Move {{.Name}}
</p> </p>
<div id="body"> <div id="body">
@@ -173,8 +173,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -198,7 +198,7 @@
<img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb"> <img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Resize {{.Name}} Resize {{.Name}}
</p> </p>
<div id="body"> <div id="body">
@@ -208,8 +208,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -233,7 +233,7 @@
<img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb"> <img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Delete {{.Name}} Delete {{.Name}}
</p> </p>
<div id="body"> <div id="body">
@@ -242,8 +242,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -267,7 +267,7 @@
<img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb"> <img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Attach {{.Name}} Attach {{.Name}}
</p> </p>
<div id="body"> <div id="body">
@@ -284,8 +284,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -300,7 +300,7 @@
<img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb"> <img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Detach {{.Name}} Detach {{.Name}}
</p> </p>
<div id="body"> <div id="body">
@@ -309,8 +309,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -340,7 +340,7 @@
</button> </button>
<template id="add-net-dialog"> <template id="add-net-dialog">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Create Network Interface Create Network Interface
</p> </p>
<div id="body"> <div id="body">
@@ -353,8 +353,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -371,7 +371,7 @@
<img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb"> <img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Edit {{.Net_ID}} Edit {{.Net_ID}}
</p> </p>
<div id="body"> <div id="body">
@@ -380,8 +380,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -393,7 +393,7 @@
<img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb"> <img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Delete {{.Net_ID}} Delete {{.Net_ID}}
</p> </p>
<div id="body"> <div id="body">
@@ -402,8 +402,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -425,7 +425,7 @@
</button> </button>
<template id="add-device-dialog"> <template id="add-device-dialog">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Add Expansion Card Add Expansion Card
</p> </p>
<div id="body"> <div id="body">
@@ -436,8 +436,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -448,13 +448,13 @@
<p>{{.Device_ID}}</p> <p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p> <p>{{.Device_Name}}</p>
<div> <div>
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}"> <device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb"> <img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Edit Expansion Card {{.Device_ID}} Edit Expansion Card {{.Device_ID}}
</p> </p>
<div id="body"> <div id="body">
@@ -463,20 +463,20 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
</template> </template>
</device-action> </device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}"> <device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb"> <img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
<template id="dialog-template"> <template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
remove Expansion Card {{.Device_ID}} remove Expansion Card {{.Device_ID}}
</p> </p>
<div id="body"> <div id="body">
@@ -485,8 +485,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -516,10 +516,17 @@
div.draggable-item { div.draggable-item {
cursor: grab; cursor: grab;
} }
div.draggable-item svg { div.draggable-item img {
height: 1em; height: 1em;
width: 1em; width: 1em;
} }
div.draggable-item p {
margin: 0;
}
div.draggable-item p.volume-file {
overflow: hidden;
white-space: nowrap;
}
#wrapper { #wrapper {
padding-bottom: 1em; padding-bottom: 1em;
} }
@@ -536,18 +543,18 @@
{{define "boot-target"}} {{define "boot-target"}}
{{if .volume_id}} {{if .volume_id}}
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;"> <div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb"> <img alt="Drag" src="images/actions/drag.svg#symb">
<img style="height: 1em; width: 1em" alt="Volume" src="images/resources/drive.svg#symb"> <img alt="Volume" src="images/resources/drive.svg#symb">
<p style="margin: 0px;">{{.volume_id}}</p> <p class="volume-id">{{.volume_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p> <p class="volume-file">{{.file}}</p>
</div> </div>
{{else if .net_id}} {{else if .net_id}}
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;"> <div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb"> <img alt="Drag" src="images/actions/drag.svg#symb">
<img style="height: 1em; width: 1em" alt="Net" src="images/resources/network.svg#symb"> <img alt="Net" src="images/resources/network.svg#symb">
<p style="margin: 0px;">{{.net_id}}</p> <p class="volume-id">{{.net_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p> <p class="volume-file">{{.value}}</p>
</div> </div>
{{else}} {{else}}
{{end}} {{end}}
+11 -11
View File
@@ -13,8 +13,8 @@
line-height: 1em; line-height: 1em;
height: 1em; height: 1em;
width: 1em; width: 1em;
margin: 0px; margin: 0;
padding: 0px; padding: 0;
} }
a img { a img {
vertical-align: unset; vertical-align: unset;
@@ -30,7 +30,7 @@
} }
.row { /* needed for some reason to avoid a flickering issue on chrome ONLY */ .row { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-direction: row; flex-direction: row;
column-gap: 10px; column-gap: 0.5em;
align-items: center; align-items: center;
} }
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */ .nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
@@ -43,7 +43,7 @@
.hide-large {display: none !important;} .hide-large {display: none !important;}
.hide-medium {display:none !important} .hide-medium {display:none !important}
} }
@media screen and (width <=601px) { @media screen and (width <=601px) and (width >=440px){
.hide-large {display: none !important;} .hide-large {display: none !important;}
.hide-medium {display:none !important} .hide-medium {display:none !important}
.hide-small {display:none !important} .hide-small {display:none !important}
@@ -82,7 +82,7 @@
{{end}} {{end}}
<p>{{.NodeStatus}}</p> <p>{{.NodeStatus}}</p>
</div> </div>
<div class="flex row nowrap" style="height: 1lh;"> <div class="flex row nowrap">
{{if and (eq .NodeStatus "online") (eq .Status "running")}} {{if and (eq .NodeStatus "online") (eq .Status "running")}}
<img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb"> <img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb">
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb"> <img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
@@ -115,7 +115,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
{{if eq .Status "running"}} {{if eq .Status "running"}}
Stop {{.VMID}} Stop {{.VMID}}
{{else if eq .Status "stopped"}} {{else if eq .Status "stopped"}}
@@ -134,8 +134,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
@@ -144,7 +144,7 @@
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0"> <dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;"> <p class="w3-large" id="prompt">
Delete {{.VMID}} Delete {{.VMID}}
</p> </p>
<div id="body"> <div id="body">
@@ -153,8 +153,8 @@
</form> </form>
</div> </div>
<div id="controls" class="w3-center w3-container"> <div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> <button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> <button id="confirm" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
</div> </div>
</dialog> </dialog>
</template> </template>
+34
View File
@@ -0,0 +1,34 @@
{{define "pool-resources"}}
<section class="w3-card w3-padding">
<h3>Pool: {{.PoolID}}</h3>
<p id="vmid">VMID Range: {{.AllowedVMIDRange.Min}} - {{.AllowedVMIDRange.Max}}</p>
<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}}
{{if eq $category ""}}
<h4>Generic</h4>
{{else}}
<h4>{{$category}}</h4>
{{end}}
<div class="resource-container">
{{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}}
</div>
{{end}}
</div>
</section>
{{end}}
+3 -4
View File
@@ -8,8 +8,7 @@
margin: 0; margin: 0;
width: 100%; width: 100%;
height: fit-content; height: fit-content;
padding: 10px; padding: 0.5em;
border-radius: 5px;
} }
progress { progress {
width: 100%; width: 100%;
@@ -19,7 +18,7 @@
} }
#caption { #caption {
text-align: center; text-align: center;
margin-top: 10px; margin-top: 0.5em;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -37,7 +36,7 @@
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress> <progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
<label id="caption" for="resource"> <label id="caption" for="resource">
<span>{{.Name}}</span> <span>{{.Name}}</span>
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span> <span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</label> </label>
</div> </div>
</template> </template>
+19 -3
View File
@@ -1,11 +1,27 @@
{{/*
Select: generic data driven <select> element template
.ID = (string) select element id & name attribute
.Required = (bool) select element required attribute
.Options = ([]Options) array of Options
*/}}
{{define "select"}} {{define "select"}}
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}> <select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
{{range .Options}} {{range .Options}}
{{template "option" .}}
{{end}}
</select>
{{end}}
{{/*
Options: generic data driven <option> element template
.Selected = (bool) option element selected attribute
.Value = (string) option element value attribute
.Display = (string) option element innerText
*/}}
{{define "option"}}
{{if .Selected}} {{if .Selected}}
<option value="{{.Value}}" selected>{{.Display}}</option> <option value="{{.Value}}" selected>{{.Display}}</option>
{{else}} {{else}}
<option value="{{.Value}}">{{.Display}}</option> <option value="{{.Value}}">{{.Display}}</option>
{{end}} {{end}}
{{end}}
</select>
{{end}} {{end}}