11 Commits

Author SHA1 Message Date
alu d88a208da5 font consistency fixes 2026-06-05 21:38:51 +00:00
alu 3b1b20b506 template cleanup 2026-06-04 17:58:42 +00:00
alu 66747fa657 update go mod 2026-06-03 17:05:23 +00:00
alu f40638598d move flag parsing to app.go 2026-06-02 18:00:24 +00:00
alu 0082f2f3e5 update go mod 2026-05-31 02:18:56 +00:00
alu 549316ab73 remove temp message notifying of login delay regression 2026-05-30 06:22:27 +00:00
alu fc58861046 add release compile flag for mapping mime types 2026-05-29 16:35:12 +00:00
alu 9b7404c8d6 update go mod, fix get config to use os.Root 2026-05-28 20:05:20 +00:00
alu 08cd4dfaaa add various missing error handling 2026-05-27 18:43:27 +00:00
alu 26a21e6cc7 move config-inactive to common 2026-05-26 22:43:07 +00:00
alu c3fe936e05 initial changes for API v2.0.0:
- added access manager api token to auth object
- update account page to show pool based resource quotas
- update config logic to use pool based resource quotas
- minor improvements and cleanup
2026-05-26 20:28:21 +00:00
39 changed files with 553 additions and 466 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build: clean
@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/
cp -rL web/ dist/web/ cp -rL web/ dist/web/
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ . CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
test: clean test: clean
go run . go run .
+6 -2
View File
@@ -1,6 +1,7 @@
package app package app
import ( import (
"flag"
"fmt" "fmt"
"log" "log"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
@@ -11,7 +12,10 @@ import (
"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("[Error] 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)
+56
View File
@@ -0,0 +1,56 @@
//go:build !release
// +build !release
package common
import (
"io"
"github.com/tdewolff/minify/v2"
)
// defines mime type and associated minifier
type MimeType struct {
Type string
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
}
// debug mime types
var MimeTypes = map[string]MimeType{
"css": {
Type: "text/css",
Minifier: nil,
},
"html": {
Type: "text/html",
Minifier: nil,
},
"tmpl": {
Type: "text/plain",
Minifier: nil,
},
"frag": {
Type: "text/plain",
Minifier: nil,
},
"svg": {
Type: "image/svg+xml",
Minifier: nil,
},
"png": {
Type: "image/png",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: nil,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
@@ -1,3 +1,6 @@
//go:build release
// +build release
package common package common
import ( import (
@@ -55,45 +58,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,
},
}
*/
+4 -3
View File
@@ -48,9 +48,10 @@ type RequestContext struct {
} }
type Auth struct { type Auth struct {
Username string Username string
Token string Token string
CSRF string CSRF string
AccessManagerTicket string
} }
type Icon struct { type Icon struct {
+48 -16
View File
@@ -22,15 +22,23 @@ import (
// 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 {
log.Fatal("Error when opening root dir: ", err)
}
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil { if err != nil {
log.Fatal("Error when opening config file: ", err) log.Fatal("Error when 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("Error during parsing config file: ", err)
} }
return config return config
} }
@@ -47,14 +55,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("[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 +70,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("[Error] minifying file %s: %s", path, err.Error())
} }
minified[path] = StaticFile{ minified[path] = StaticFile{
Data: min, Data: min,
@@ -84,7 +92,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
} }
return nil return nil
}) })
return minified
if err != nil {
log.Printf("[Error] 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 {
@@ -165,7 +179,7 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
return nil, 0, err return nil, 0, err
} }
for k, v := range context.Cookies { for k, v := range context.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v}) req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
} }
client := &http.Client{} client := &http.Client{}
@@ -185,7 +199,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)
@@ -208,10 +221,11 @@ func GetAuth(c *gin.Context) (Auth, error) {
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")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF) return 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 Auth{username, token, csrf, access}, nil
} }
} }
@@ -230,7 +244,7 @@ func ExtractVMPath(c *gin.Context) (VMPath, error) {
return vm_path, nil return vm_path, nil
} }
func FormatNumber(val int64, base int64) (float64, string) { func FormatNumber(val int64, base int64) (string, string) {
valf := float64(val) valf := float64(val)
basef := float64(base) basef := float64(base)
steps := 0 steps := 0
@@ -239,13 +253,31 @@ func FormatNumber(val int64, base int64) (float64, string) {
steps++ steps++
} }
if base == 1000 { switch base {
case 1000:
s := fmt.Sprintf("%.4f", valf)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
prefixes := []string{"", "K", "M", "G", "T"} prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps] return s, prefixes[steps]
} else if base == 1024 { case 1024:
s := fmt.Sprintf("%.4f", valf)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"} prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps] return s, prefixes[steps]
} else { default:
return 0, "" return "0", ""
}
}
func GetRequestContextFromCookies(auth Auth) RequestContext {
return RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
},
} }
} }
+138 -124
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"
@@ -11,14 +12,8 @@ import (
) )
type Account struct { type Account struct {
Username string paas.User
Pools map[string]bool Pools map[string]paas.Pool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
} }
// numerical constraint // numerical constraint
@@ -82,7 +77,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
@@ -103,70 +98,84 @@ var Green = color.RGB{
func HandleGETAccount(c *gin.Context) { func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
account, err := GetUserAccount(auth)
account, err := GetUser(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{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case StorageResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[category][resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total { for poolname, pool := range pools {
l.Resources = append(l.Resources, ResourceChart{ // for each resource category
for category := range pool.Resources {
// for each resource in each category
for resource, v := range pool.Resources[category].(map[string]any) {
// create a resource chart for resource depending on resource type
switch t := v.(type) {
case NumericResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type, Type: t.Type,
Display: t.Display, Display: t.Display,
Name: r.Name, Name: t.Name,
Used: r.Used, Used: t.Total.Used,
Max: r.Max, Max: t.Total.Max,
Avail: float64(r.Avail), // usually an int Avail: avail,
Unit: "", Prefix: prefix,
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(), Unit: t.Unit,
}) ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case StorageResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
avail := fmt.Sprintf("%d", r.Avail)
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: avail, // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
pools[poolname].Resources[category].(map[string]any)[resource] = l
} }
account.Resources[category][resource] = l
} }
} }
} }
account.Pools = pools
c.HTML(http.StatusOK, "html/account.html", gin.H{ c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global, "global": common.Global,
"page": "account", "page": "account",
@@ -177,97 +186,102 @@ func HandleGETAccount(c *gin.Context) {
} }
} }
func GetUserAccount(auth common.Auth) (Account, error) { func GetUser(auth common.Auth) (Account, error) {
account := Account{ account := Account{}
Resources: map[string]map[string]any{}, ctx := common.GetRequestContextFromCookies(auth)
}
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
// get user account basic data
body := map[string]any{} body := map[string]any{}
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body) res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), ctx, &body)
if err != nil { if err != nil {
return account, err return account, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res) return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
} }
err = mapstructure.Decode(body, &account) err = mapstructure.Decode(body, &account)
if err != nil { return account, err
return account, err }
} else {
account.Username = auth.Username
}
body = map[string]any{} func GetUserPools(auth common.Auth) (map[string]paas.Pool, error) {
// get user resources pools := map[string]paas.Pool{}
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
// get all pools
ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", ctx, &body)
if err != nil { if err != nil {
return account, err return pools, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res) 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
} }
resources := body
// get global config for resource type metadata
body = map[string]any{} body = map[string]any{}
// get resource meta data // get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body) res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil { if err != nil {
return account, err return pools, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res) return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
} }
meta := body["resources"].(map[string]any) meta := body["resources"].(map[string]any)
// build each resource by its meta type // for each pool
for k, v := range meta { for poolname, pool := range pools {
m := v.(map[string]any) // for each resource in pool data
t := m["type"].(string) for k, v := range pool.Resources {
r := resources[k].(map[string]any) m := meta[k].(map[string]any)
category := m["category"].(string) t := m["type"].(string)
if _, ok := account.Resources[category]; !ok { r := v.(map[string]any)
account.Resources[category] = map[string]any{} category := m["category"].(string)
}
if t == "numeric" { // create a category if it does not already exist
n := NumericResource{} if _, ok := pool.Resources[category]; !ok {
n.Type = t pool.Resources[category] = map[string]any{}
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" { // depending on type, decode the pool data into the corresponding resource type
n := StorageResource{} switch t {
n.Type = t case "numeric":
err_m := mapstructure.Decode(m, &n) n := NumericResource{}
err_r := mapstructure.Decode(r, &n) n.Type = t
if err_m != nil || err_r != nil { err_m := mapstructure.Decode(m, &n)
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) 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())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "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 pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "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 pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
} }
account.Resources[category][k] = n
} else if t == "list" { // delete the old entry, only categories should be left at the end of the loop
n := ListResource{} delete(pools[poolname].Resources, k)
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 return pools, nil
} }
// interpolate between min and max by normalized (0 - 1) val // interpolate between min and max by normalized (0 - 1) val
+7 -12
View File
@@ -2,7 +2,6 @@ package routes
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"time" "time"
@@ -39,8 +38,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",
@@ -67,10 +64,14 @@ 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)
} }
@@ -79,13 +80,7 @@ func HandleGETBackupsFragment(c *gin.Context) {
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) { func GetInstanceBackups(vm common.VMPath, auth common.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/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{ ctx := common.GetRequestContextFromCookies(auth)
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, ctx, &body)
if err != nil { if err != nil {
+46 -55
View File
@@ -15,16 +15,7 @@ import (
// imported types from fabric // 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,17 +26,13 @@ type GlobalConfig struct {
} }
} }
type UserConfigResources struct { type PoolConfig struct {
CPU struct { CPU struct {
Global []CPUConfig Global []paas.MatchLimit
Nodes map[string][]CPUConfig Nodes map[string][]paas.MatchLimit
} }
} }
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) { func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
@@ -61,13 +48,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
} }
} }
@@ -97,10 +84,14 @@ 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)
} }
@@ -121,10 +112,14 @@ 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)
} }
@@ -145,10 +140,14 @@ 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)
} }
@@ -169,10 +168,14 @@ 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)
} }
@@ -181,13 +184,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) { func GetInstanceConfig(vm common.VMPath, auth common.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/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{ ctx := common.GetRequestContextFromCookies(auth)
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, ctx, &body)
if err != nil { if err != nil {
@@ -208,20 +205,14 @@ 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 common.VMPath, pool string, auth common.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{ ctx := common.GetRequestContextFromCookies(auth)
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, ctx, &body)
@@ -231,15 +222,15 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
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, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
@@ -247,21 +238,21 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
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.MatchLimit
if _, ok := user.CPU.Nodes[vm.Node]; ok { if _, ok := poolCPUConfig.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node] userCPU = poolCPUConfig.CPU.Nodes[vm.Node]
} 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,
@@ -280,7 +271,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.MatchLimit
}{} }{}
err = mapstructure.Decode(body, supported) err = mapstructure.Decode(body, supported)
if err != nil { if err != nil {
@@ -289,7 +280,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.MatchLimit) bool {
return c.Name == cpu.Name return c.Name == cpu.Name
}) })
if !contains { if !contains {
+31 -25
View File
@@ -72,10 +72,14 @@ 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)
} }
@@ -83,13 +87,8 @@ func HandleGETInstancesFragment(c *gin.Context) {
} }
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) { func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
ctx := common.RequestContext{ ctx := common.GetRequestContextFromCookies(auth)
Cookies: map[string]string{ body := []any{}
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body) 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,7 +139,7 @@ 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", ctx, &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,23 +180,24 @@ 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, ctx, &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
+2 -2
View File
@@ -27,7 +27,7 @@ func GetLoginRealms() ([]Realm, error) {
ctx := common.RequestContext{ ctx := common.RequestContext{
Cookies: nil, Cookies: nil,
} }
body := map[string]any{} body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body) res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil { if err != nil {
return realms, err return realms, err
@@ -36,7 +36,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)
+15 -15
View File
@@ -1,12 +1,12 @@
module proxmoxaas-dashboard module proxmoxaas-dashboard
go 1.26.2 go 1.26.4
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
) )
@@ -14,33 +14,33 @@ replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
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.1 // 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.3.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.22 // 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.3.1 // 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.59.1 // 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.6.0 // indirect
golang.org/x/arch v0.26.0 // indirect golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+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)
} }
+1
View File
@@ -41,6 +41,7 @@ legend {
fieldset { fieldset {
border: 0; border: 0;
padding: 0;
} }
fieldset > *:last-child { fieldset > *:last-child {
+3 -4
View File
@@ -53,7 +53,6 @@ header {
} }
header h1 { header h1 {
font-size: 18px;
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,8 +60,8 @@ header h1 {
} }
nav { nav {
font-size: var(--small-font-size);
overflow: hidden; overflow: hidden;
font-size: larger;
width: fit-content; width: fit-content;
} }
@@ -80,7 +79,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 +105,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;
} }
+17 -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,22 @@
} }
} }
* { *, h1, h2, h3, p {
box-sizing: border-box; box-sizing: border-box;
font-family: monospace; font-family: monospace;
}
h1, p {
font-size: var(--small-font-size);
}
h2 {
font-size: var(--large-font-size);
}
h3 {
font-size: var(--medium-font-size);
} }
html { html {
+7 -40
View File
@@ -34,51 +34,18 @@
</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: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p> <p id="email">Email: {{.account.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 := .account.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">
+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">
+1 -3
View File
@@ -19,9 +19,7 @@
</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>
+4 -6
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"><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-->
@@ -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>
+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>
+3 -3
View File
@@ -26,9 +26,7 @@
</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 +40,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>
@@ -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

+2 -2
View File
@@ -83,7 +83,7 @@ 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
@@ -99,7 +99,7 @@ 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
+5 -3
View File
@@ -2,8 +2,10 @@ import { getSyncSettings, requestAPI } from "./utils.js";
export async function setupClientSync (callback) { export async function setupClientSync (callback) {
const { scheme, rate } = getSyncSettings(); const { scheme, rate } = getSyncSettings();
if (scheme === "never") {
if (scheme === "always") { 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 +21,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) => {
+8 -8
View File
@@ -54,7 +54,7 @@ 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");
@@ -136,7 +136,7 @@ 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");
@@ -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;
@@ -275,7 +275,7 @@ 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}`;
@@ -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,7 +383,7 @@ 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}`;
@@ -437,8 +437,8 @@ async function handleDeviceAdd () {
} }
}); });
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;
+1 -1
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 () => {
+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);
} }
}); });
+47 -51
View File
@@ -159,7 +159,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";
@@ -193,7 +193,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;
@@ -247,7 +247,7 @@ function sortInstances () {
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 };
}; };
} }
@@ -343,10 +343,10 @@ async function handleInstanceAddButton () {
} }
}); });
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 +366,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;
+1
View File
@@ -4,6 +4,7 @@ window.addEventListener("DOMContentLoaded", init);
function init () { function init () {
setAppearance(); setAppearance();
const { scheme, rate } = getSyncSettings(); const { scheme, rate } = getSyncSettings();
if (scheme) { if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true; document.querySelector(`#sync-${scheme}`).checked = true;
+13 -12
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) {
+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}}
+2 -2
View File
@@ -448,7 +448,7 @@
<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">
@@ -470,7 +470,7 @@
</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">
+1 -1
View File
@@ -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}
+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 := .Resources}}
{{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}}
+1 -1
View File
@@ -37,7 +37,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}} {{end}}
</select>
{{end}}