1 Commits

Author SHA1 Message Date
alu 04c4d990c6 Update README.md 2026-05-24 19:12:38 +00:00
40 changed files with 469 additions and 556 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 -tags release -ldflags="-s -w" -v -o dist/ . CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
test: clean test: clean
go run . go run .
+1 -1
View File
@@ -1,5 +1,5 @@
# ProxmoxAAS Dashboard - Proxmox As A Service User Web Interface # ProxmoxAAS Dashboard - Proxmox As A Service User Web Interface
ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service a simplified UI which gives users power management, console access, and instance configuration utility. It also allows administrators to set resource quotas for users and allows users to configure instances without administrator priviledges. ProxmoxAAS Dashboard provides a simplified UI for users of a proxmox based compute on demand service. It provides users power management, console access, and instance configuration utility. It also allows administrators to set resource quotas for users and allows users to configure instances without administrator priviledges.
## Features ## Features
- Simplified interface for non administrator users - Simplified interface for non administrator users
+2 -6
View File
@@ -1,7 +1,6 @@
package app package app
import ( import (
"flag"
"fmt" "fmt"
"log" "log"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
@@ -12,10 +11,7 @@ import (
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
) )
func Run() { func Run(configPath *string) {
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
@@ -42,7 +38,7 @@ func Run() {
router.GET("/settings", routes.HandleGETSettings) router.GET("/settings", routes.HandleGETSettings)
// run on all interfaces with port // run on all interfaces with port
log.Fatal("[Error] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port))) log.Fatal(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)
@@ -1,6 +1,3 @@
//go:build release
// +build release
package common package common
import ( import (
@@ -58,3 +55,45 @@ 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,
},
}
*/
-56
View File
@@ -1,56 +0,0 @@
//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,
},
}
+3 -4
View File
@@ -48,10 +48,9 @@ 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 {
+16 -48
View File
@@ -22,23 +22,15 @@ import (
// get config file from configPath // get config file from configPath
func GetConfig(configPath string) Config { func GetConfig(configPath string) Config {
root, err := os.OpenRoot(".") content, err := os.ReadFile(configPath)
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
} }
@@ -55,14 +47,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)
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { 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
@@ -70,7 +62,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,
@@ -92,13 +84,7 @@ 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 {
@@ -179,7 +165,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, Secure: true}) req.AddCookie(&http.Cookie{Name: k, Value: v})
} }
client := &http.Client{} client := &http.Client{}
@@ -199,6 +185,7 @@ 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)
@@ -221,11 +208,10 @@ 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")
access, errAccess := c.Cookie("PAASAccessManagerTicket") if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
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, access}, nil return Auth{username, token, csrf}, nil
} }
} }
@@ -244,7 +230,7 @@ func ExtractVMPath(c *gin.Context) (VMPath, error) {
return vm_path, nil return vm_path, nil
} }
func FormatNumber(val int64, base int64) (string, string) { func FormatNumber(val int64, base int64) (float64, string) {
valf := float64(val) valf := float64(val)
basef := float64(base) basef := float64(base)
steps := 0 steps := 0
@@ -253,31 +239,13 @@ func FormatNumber(val int64, base int64) (string, string) {
steps++ steps++
} }
switch base { if base == 1000 {
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 s, prefixes[steps] return valf, prefixes[steps]
case 1024: } else if base == 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 s, prefixes[steps] return valf, prefixes[steps]
default: } else {
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,
},
} }
} }
+128 -142
View File
@@ -3,7 +3,6 @@ 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"
@@ -12,8 +11,14 @@ import (
) )
type Account struct { type Account struct {
paas.User Username string
Pools map[string]paas.Pool Pools map[string]bool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
} }
// numerical constraint // numerical constraint
@@ -77,7 +82,7 @@ type ResourceChart struct {
Name string Name string
Used int64 Used int64
Max int64 Max int64
Avail string Avail float64
Prefix string Prefix string
Unit string Unit string
ColorHex string ColorHex string
@@ -98,84 +103,70 @@ 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
} }
pools, err := GetUserPools(auth) // for each resource category, create a resource chart
if err != nil { for category, resources := range account.Resources {
common.HandleNonFatalError(c, err) for resource, v := range resources {
return switch t := v.(type) {
} case NumericResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
for poolname, pool := range pools { account.Resources[category][resource] = ResourceChart{
// for each resource category Type: t.Type,
for category := range pool.Resources { Display: t.Display,
// for each resource in each category Name: t.Name,
for resource, v := range pool.Resources[category].(map[string]any) { Used: t.Total.Used,
// create a resource chart for resource depending on resource type Max: t.Total.Max,
switch t := v.(type) { Avail: avail,
case NumericResource: Prefix: prefix,
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base) Unit: t.Unit,
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{ ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
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)
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
} }
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 {
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: float64(r.Avail), // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
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",
@@ -186,102 +177,97 @@ func HandleGETAccount(c *gin.Context) {
} }
} }
func GetUser(auth common.Auth) (Account, error) { func GetUserAccount(auth common.Auth) (Account, error) {
account := Account{} account := Account{
ctx := common.GetRequestContextFromCookies(auth) Resources: map[string]map[string]any{},
}
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(fmt.Sprintf("/access/users/%s", auth.Username), ctx, &body) res, code, err := common.RequestGetAPI("/user/config/cluster", 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 /access/pools resulted in %+v", res) return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
} }
err = mapstructure.Decode(body, &account) err = mapstructure.Decode(body, &account)
return account, err
}
func GetUserPools(auth common.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
// 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 pools, err 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 { if code != 200 {
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res) return account, fmt.Errorf("request to /user/dynamic/resources 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 pools, err return account, err
} }
if code != 200 { if code != 200 {
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res) return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
} }
meta := body["resources"].(map[string]any) meta := body["resources"].(map[string]any)
// for each pool // build each resource by its meta type
for poolname, pool := range pools { for k, v := range meta {
// for each resource in pool data m := v.(map[string]any)
for k, v := range pool.Resources { t := m["type"].(string)
m := meta[k].(map[string]any) r := resources[k].(map[string]any)
t := m["type"].(string) category := m["category"].(string)
r := v.(map[string]any) if _, ok := account.Resources[category]; !ok {
category := m["category"].(string) account.Resources[category] = map[string]any{}
}
// create a category if it does not already exist if t == "numeric" {
if _, ok := pool.Resources[category]; !ok { n := NumericResource{}
pool.Resources[category] = map[string]any{} 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
// depending on type, decode the pool data into the corresponding resource type } else if t == "storage" {
switch t { n := StorageResource{}
case "numeric": n.Type = t
n := NumericResource{} err_m := mapstructure.Decode(m, &n)
n.Type = t err_r := mapstructure.Decode(r, &n)
err_m := mapstructure.Decode(m, &n) if err_m != nil || err_r != nil {
err_r := mapstructure.Decode(r, &n) return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
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
// delete the old entry, only categories should be left at the end of the loop } else if t == "list" {
delete(pools[poolname].Resources, k) 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 pools, nil return account, nil
} }
// interpolate between min and max by normalized (0 - 1) val // interpolate between min and max by normalized (0 - 1) val
+12 -7
View File
@@ -2,6 +2,7 @@ package routes
import ( import (
"fmt" "fmt"
"log"
"net/http" "net/http"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"time" "time"
@@ -38,6 +39,8 @@ 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",
@@ -64,14 +67,10 @@ func HandleGETBackupsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
"backups": backups, "backups": backups,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401 } else { // return 401
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -80,7 +79,13 @@ 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.GetRequestContextFromCookies(auth) 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, ctx, &body)
if err != nil { if err != nil {
+55 -46
View File
@@ -15,7 +15,16 @@ import (
// imported types from fabric // imported types from fabric
type InstanceConfig struct { type InstanceConfig struct {
paas.Instance `mapstructure:",squash"` Type paas.InstanceType `json:"type"`
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
} }
@@ -26,13 +35,17 @@ type GlobalConfig struct {
} }
} }
type PoolConfig struct { type UserConfigResources struct {
CPU struct { CPU struct {
Global []paas.MatchLimit Global []CPUConfig
Nodes map[string][]paas.MatchLimit Nodes map[string][]CPUConfig
} }
} }
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 {
@@ -48,13 +61,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, config.Pool, auth) config.ProctypeSelect, err = GetCPUTypes(vm_path, 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.Proctype { if cpu.Value == config.CPU {
config.ProctypeSelect.Options[i].Selected = true config.ProctypeSelect.Options[i].Selected = true
} }
} }
@@ -84,14 +97,10 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
"config": config, "config": config,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -112,14 +121,10 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
"config": config, "config": config,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -140,14 +145,10 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
"config": config, "config": config,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -168,14 +169,10 @@ func HandleGETConfigBootFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
"config": config, "config": config,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -184,7 +181,13 @@ 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.GetRequestContextFromCookies(auth) 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, ctx, &body)
if err != nil { if err != nil {
@@ -205,14 +208,20 @@ func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, erro
return config, nil return config, nil
} }
func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select, error) { func GetCPUTypes(vm common.VMPath, 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.GetRequestContextFromCookies(auth) 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, ctx, &body)
@@ -222,15 +231,15 @@ func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select
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)
} }
globalConfig := GlobalConfig{} global := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &globalConfig) err = mapstructure.Decode(body["resources"], &global)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// get pool resource config // get user resource config
body = map[string]any{} body = map[string]any{}
path = fmt.Sprintf("/access/pools/%s", pool) path = "/user/config/resources"
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
@@ -238,21 +247,21 @@ func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select
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)
} }
poolCPUConfig := PoolConfig{} user := UserConfigResources{}
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig) err = mapstructure.Decode(body, &user)
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 []paas.MatchLimit var userCPU []CPUConfig
if _, ok := poolCPUConfig.CPU.Nodes[vm.Node]; ok { if _, ok := user.CPU.Nodes[vm.Node]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.Node] userCPU = user.CPU.Nodes[vm.Node]
} else { } else {
userCPU = poolCPUConfig.CPU.Global userCPU = user.CPU.Global
} }
if globalConfig.CPU.Whitelist { // cpu is a whitelist if global.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,7 +280,7 @@ func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select
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 []paas.MatchLimit data []CPUConfig
}{} }{}
err = mapstructure.Decode(body, supported) err = mapstructure.Decode(body, supported)
if err != nil { if err != nil {
@@ -280,7 +289,7 @@ func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select
// 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 paas.MatchLimit) bool { contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
return c.Name == cpu.Name return c.Name == cpu.Name
}) })
if !contains { if !contains {
+25 -31
View File
@@ -72,14 +72,10 @@ func HandleGETInstancesFragment(c *gin.Context) {
return return
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
err = common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
"instances": instances, "instances": instances,
}) })
if err != nil { c.Status(http.StatusOK)
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401 } else { // return 401
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -87,8 +83,13 @@ 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.GetRequestContextFromCookies(auth) ctx := common.RequestContext{
body := []any{} Cookies: map[string]string{
"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
@@ -101,17 +102,16 @@ 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 { for _, v := range body["data"].([]any) {
m := v.(map[string]any) m := v.(map[string]any)
switch m["type"] { if m["type"] == "node" { // if type is node -> parse as Node object
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
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object } else if m["type"] == "lxc" || m["type"] == "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,10 +127,9 @@ 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
switch instance.Type { if instance.Type == "qemu" {
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)
case "lxc": } else if instance.Type == "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
@@ -139,7 +138,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
instances[vmid] = instance instances[vmid] = instance
} }
body = []any{} body = map[string]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
@@ -149,10 +148,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_states := map[uint]string{} expected_state := 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 { for _, v := range body["data"].([]any) {
// parse task as Task object // parse task as Task object
task := Task{} task := Task{}
err := mapstructure.Decode(v, &task) err := mapstructure.Decode(v, &task)
@@ -180,24 +179,23 @@ 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
switch task.Type { if task.Type == "qmstart" || task.Type == "vzstart" { // if the task was a start task, update the expected state to running
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running expected_state[task.VMID] = "running"
expected_states[task.VMID] = "running" } else if task.Type == "qmstop" || task.Type == "vzstop" { // if the task was a stop task, update the expected state to stopped
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped expected_state[task.VMID] = "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_states { // for the expected states from recent tasks for vmid, expected_state := range expected_state { // 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
@@ -206,12 +204,8 @@ 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{}
err = mapstructure.Decode(body, &status) mapstructure.Decode(body["data"], &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 := []any{} body := map[string]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 { for _, v := range body["data"].([]any) {
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.4 go 1.26.2
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.13 github.com/tdewolff/minify/v2 v2.24.12
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.1 // indirect github.com/bytedance/sonic v1.15.0 // 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.7 // indirect github.com/cloudwego/base64x v0.1.6 // 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.3 // indirect github.com/go-playground/validator/v10 v10.30.2 // 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.22 // indirect github.com/mattn/go-isatty v0.0.21 // 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.1 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // 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.1 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect github.com/tdewolff/parse/v2 v2.8.12 // 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.6.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
golang.org/x/arch v0.27.0 // indirect golang.org/x/arch v0.26.0 // indirect
golang.org/x/crypto v0.52.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.55.0 // indirect golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.45.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+4 -1
View File
@@ -1,9 +1,12 @@
package main package main
import ( import (
"flag"
app "proxmoxaas-dashboard/app" app "proxmoxaas-dashboard/app"
) )
func main() { func main() {
app.Run() configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
app.Run(configPath)
} }
-1
View File
@@ -41,7 +41,6 @@ legend {
fieldset { fieldset {
border: 0; border: 0;
padding: 0;
} }
fieldset > *:last-child { fieldset > *:last-child {
+4 -3
View File
@@ -53,6 +53,7 @@ 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);
@@ -60,8 +61,8 @@ header h1 {
} }
nav { nav {
font-size: var(--small-font-size);
overflow: hidden; overflow: hidden;
font-size: larger;
width: fit-content; width: fit-content;
} }
@@ -79,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
display: none; display: none;
} }
@media screen and (width >= 601px){ @media screen and (width >= 600px){
header { header {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }
@@ -105,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
} }
} }
@media screen and (width <= 601px){ @media screen and (width <= 600px){
header { header {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
+1 -17
View File
@@ -3,9 +3,6 @@
--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) {
@@ -44,22 +41,9 @@
} }
} }
*, 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 {
+40 -7
View File
@@ -34,18 +34,51 @@
</style> </style>
</head> </head>
<body> <body>
{{template "header" .}} <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.UserID}}@{{.account.Username.Realm}}</p> <p id="username">Username: {{.account.Username}}</p>
<p id="email">Email: {{.account.Mail}}</p> <p id="pool">Pools: {{MapKeys .account.Pools ", "}}</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="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</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">
+3 -1
View File
@@ -9,7 +9,9 @@
</style> </style>
</head> </head>
<body> <body>
{{template "header" .}} <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">
+3 -1
View File
@@ -19,7 +19,9 @@
</style> </style>
</head> </head>
<body> <body>
{{template "header" .}} <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>
+6 -4
View File
@@ -65,14 +65,16 @@
</style> </style>
</head> </head>
<body> <body>
{{template "header" .}} <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">
<button type="submit"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button> <img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb">
<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-->
@@ -92,14 +94,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>
+3 -1
View File
@@ -7,7 +7,9 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
</head> </head>
<body> <body>
{{template "header" .}} <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,7 +26,9 @@
</style> </style>
</head> </head>
<body> <body>
{{template "header" .}} <header>
{{template "header" .}}
</header>
<main> <main>
<h2>Settings</h2> <h2>Settings</h2>
<form id="settings"> <form id="settings">
@@ -40,8 +42,6 @@
<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 @@
../../common/config-inactive.svg

Before

Width:  |  Height:  |  Size: 32 B

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -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>

Before

Width:  |  Height:  |  Size: 32 B

After

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -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

+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
+3 -5
View File
@@ -2,10 +2,8 @@ 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") {
return; if (scheme === "always") {
}
else if (scheme === "always") {
window.setInterval(callback, rate * 1000); window.setInterval(callback, rate * 1000);
} }
else if (scheme === "hash") { else if (scheme === "hash") {
@@ -21,7 +19,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.data) { for (const iso of isos) {
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.data) { for (const availDevice of availDevices) {
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}/${type}/${vmid}/pci`, "GET"); const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
for (const availDevice of availDevices.data) { for (const availDevice of availDevices) {
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);
} }
}); });
+49 -45
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 () {
} }
}); });
// setup type select const templates = await requestAPI("/user/ct-templates", "GET");
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,62 +366,66 @@ async function handleInstanceAddButton () {
element.disabled = true; element.disabled = true;
}); });
// setup pool select const rootfsContent = "rootdir";
const poolSelect = d.querySelector("#pool"); const rootfsStorage = d.querySelector("#rootfs-storage");
poolSelect.innerHTML = ""; rootfsStorage.selectedIndex = -1;
// add user pools to selector
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
poolSelect.addEventListener("change", async () => {
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
const nodeSelect = d.querySelector("#node"); const userResources = await requestAPI("/user/dynamic/resources", "GET");
nodeSelect.innerHTML = ""; const userCluster = await requestAPI("/user/config/cluster", "GET");
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"); 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));
}
});
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")).data; const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
rootfsStorage.innerHTML = ""; rootfsStorage.innerHTML = "";
storage.forEach((element) => { storage.data.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;
}
}); });
// setup root dir select // set vmid min/max
const rootfsStorage = d.querySelector("#rootfs-storage"); d.querySelector("#vmid").min = userCluster.vmid.min;
rootfsStorage.selectedIndex = -1; d.querySelector("#vmid").max = userCluster.vmid.max;
// set rootfs content type (rootdir)
const rootfsContent = "rootdir"; // add user pools to selector
const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// setup templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector // add template images to selector
const templates = await requestAPI("/user/ct-templates", "GET"); const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
for (const template of templates.data) { for (const template of templates) {
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,7 +4,6 @@ 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;
+12 -13
View File
@@ -80,34 +80,33 @@ 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");
const res = {}; let data = null;
if (contentType === null) { if (contentType === null) {
res.data = null; data = {};
res.status = response.status;
} }
else if (contentType.includes("application/json")) { else if (contentType.includes("application/json")) {
res.data = await response.json(); data = await response.json();
res.status = response.status; data.status = response.status;
} }
else if (contentType.includes("text/html")) { else if (contentType.includes("text/html")) {
res.data = await response.text(); data = { data: await response.text() };
res.status = response.status; data.status = response.status;
} }
else if (contentType.includes("text/plain")) { else if (contentType.includes("text/plain")) {
res.data = await response.text(); data = { data: await response.text() };
res.status = response.status; data.status = response.status;
} }
else { else {
res.data = null; data = {};
res.status = response.status;
} }
if (!response.ok) { if (!response.ok) {
return { status: response.status, error: res.data ? res.data.error : response.status }; return { status: response.status, error: data ? data.error : response.status };
} }
else { else {
return res; data.status = response.status;
return data || response;
} }
} }
catch (error) { catch (error) {
+13 -17
View File
@@ -1,4 +1,3 @@
{{/* <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">
@@ -15,21 +14,18 @@
<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"}}
<header> <h1>{{.global.Organization}}</h1>
<h1>{{.global.Organization}}</h1> <label for="navtoggle">&#9776;</label>
<label for="navtoggle">&#9776;</label> <input type="checkbox" id="navtoggle">
<input type="checkbox" id="navtoggle"> <nav id="navigation">
<nav id="navigation"> {{if eq .page "login"}}
{{if eq .page "login"}} <a href="login" aria-current="page">Login</a>
<a href="login" aria-current="page">Login</a> {{else}}
{{else}} <a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a> <a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a> <a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a> <a href="login">Logout</a>
<a href="login">Logout</a> {{end}}
{{end}} </nav>
</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="{{.Device_ID}}"> <device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<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="{{.Device_ID}}"> <device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<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) and (width >=440px){ @media screen and (width <=601px) {
.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
@@ -1,34 +0,0 @@
{{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>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span> <span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</label> </label>
</div> </div>
</template> </template>
+3 -19
View File
@@ -1,27 +1,11 @@
{{/*
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}}