Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d88a208da5 | |||
| 3b1b20b506 | |||
| 66747fa657 | |||
| f40638598d | |||
| 0082f2f3e5 | |||
| 549316ab73 | |||
| fc58861046 | |||
| 9b7404c8d6 | |||
| 08cd4dfaaa | |||
| 26a21e6cc7 | |||
| c3fe936e05 |
@@ -4,7 +4,7 @@ build: clean
|
||||
@echo "======================== Building Binary ======================="
|
||||
# resolve symbolic links in web by copying it into dist/web/
|
||||
cp -rL web/ dist/web/
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
|
||||
CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
|
||||
|
||||
test: clean
|
||||
go run .
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ProxmoxAAS Dashboard - Proxmox As A Service User Web Interface
|
||||
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.
|
||||
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.
|
||||
|
||||
## Features
|
||||
- Simplified interface for non administrator users
|
||||
|
||||
+6
-2
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
@@ -11,7 +12,10 @@ import (
|
||||
"github.com/tdewolff/minify/v2"
|
||||
)
|
||||
|
||||
func Run(configPath *string) {
|
||||
func Run() {
|
||||
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||
flag.Parse()
|
||||
|
||||
common.Global = common.GetConfig(*configPath)
|
||||
|
||||
// setup static resources
|
||||
@@ -38,7 +42,7 @@ func Run(configPath *string) {
|
||||
router.GET("/settings", routes.HandleGETSettings)
|
||||
|
||||
// run on all interfaces with port
|
||||
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
|
||||
log.Fatal("[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)
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -55,45 +58,3 @@ var MimeTypes = map[string]MimeType{
|
||||
Minifier: nil,
|
||||
},
|
||||
}
|
||||
|
||||
// debug mime types
|
||||
/*
|
||||
var MimeTypes = map[string]MimeType{
|
||||
"css": {
|
||||
Type: "text/css",
|
||||
Minifier: nil,
|
||||
},
|
||||
"html": {
|
||||
Type: "text/html",
|
||||
Minifier: nil,
|
||||
},
|
||||
"tmpl": {
|
||||
Type: "text/plain",
|
||||
Minifier: nil,
|
||||
},
|
||||
"frag": {
|
||||
Type: "text/plain",
|
||||
Minifier: nil,
|
||||
},
|
||||
"svg": {
|
||||
Type: "image/svg+xml",
|
||||
Minifier: nil,
|
||||
},
|
||||
"png": {
|
||||
Type: "image/png",
|
||||
Minifier: nil,
|
||||
},
|
||||
"js": {
|
||||
Type: "application/javascript",
|
||||
Minifier: nil,
|
||||
},
|
||||
"wasm": {
|
||||
Type: "application/wasm",
|
||||
Minifier: nil,
|
||||
},
|
||||
"*": {
|
||||
Type: "text/plain",
|
||||
Minifier: nil,
|
||||
},
|
||||
}
|
||||
*/
|
||||
@@ -51,6 +51,7 @@ type Auth struct {
|
||||
Username string
|
||||
Token string
|
||||
CSRF string
|
||||
AccessManagerTicket string
|
||||
}
|
||||
|
||||
type Icon struct {
|
||||
|
||||
+47
-15
@@ -22,15 +22,23 @@ import (
|
||||
|
||||
// get config file from configPath
|
||||
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 {
|
||||
log.Fatal("Error when opening config file: ", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
err = json.Unmarshal(content, &config)
|
||||
if err != nil {
|
||||
log.Fatal("Error during parsing config file: ", err)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -47,14 +55,14 @@ func InitMinify() *minify.M {
|
||||
|
||||
func MinifyStatic(m *minify.M, files embed.FS) 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 {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
v, err := files.ReadFile(path)
|
||||
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(), ".")
|
||||
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
|
||||
min, err := m.String(mimetype.Type, string(v)) // try to minify
|
||||
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{
|
||||
Data: min,
|
||||
@@ -84,8 +92,14 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
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 {
|
||||
root := template.New("")
|
||||
@@ -165,7 +179,7 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
|
||||
return nil, 0, err
|
||||
}
|
||||
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{}
|
||||
@@ -185,7 +199,6 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
|
||||
if err != nil {
|
||||
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)
|
||||
case *map[string]any:
|
||||
err = json.Unmarshal(data, &body)
|
||||
@@ -208,10 +221,11 @@ func GetAuth(c *gin.Context) (Auth, error) {
|
||||
username, errUsername := c.Cookie("username")
|
||||
token, errToken := c.Cookie("PVEAuthCookie")
|
||||
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)
|
||||
} 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
|
||||
}
|
||||
|
||||
func FormatNumber(val int64, base int64) (float64, string) {
|
||||
func FormatNumber(val int64, base int64) (string, string) {
|
||||
valf := float64(val)
|
||||
basef := float64(base)
|
||||
steps := 0
|
||||
@@ -239,13 +253,31 @@ func FormatNumber(val int64, base int64) (float64, string) {
|
||||
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"}
|
||||
return valf, prefixes[steps]
|
||||
} else if base == 1024 {
|
||||
return s, prefixes[steps]
|
||||
case 1024:
|
||||
s := fmt.Sprintf("%.4f", valf)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
|
||||
return valf, prefixes[steps]
|
||||
} else {
|
||||
return 0, ""
|
||||
return s, prefixes[steps]
|
||||
default:
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+74
-60
@@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
paas "proxmoxaas-common-lib"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
|
||||
"github.com/gerow/go-color"
|
||||
@@ -11,14 +12,8 @@ import (
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Pools map[string]bool
|
||||
Nodes map[string]bool
|
||||
VMID struct {
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
Resources map[string]map[string]any
|
||||
paas.User
|
||||
Pools map[string]paas.Pool
|
||||
}
|
||||
|
||||
// numerical constraint
|
||||
@@ -82,7 +77,7 @@ type ResourceChart struct {
|
||||
Name string
|
||||
Used int64
|
||||
Max int64
|
||||
Avail float64
|
||||
Avail string
|
||||
Prefix string
|
||||
Unit string
|
||||
ColorHex string
|
||||
@@ -103,19 +98,29 @@ var Green = color.RGB{
|
||||
func HandleGETAccount(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
account, err := GetUserAccount(auth)
|
||||
|
||||
account, err := GetUser(auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// for each resource category, create a resource chart
|
||||
for category, resources := range account.Resources {
|
||||
for resource, v := range resources {
|
||||
pools, err := GetUserPools(auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
for poolname, pool := range pools {
|
||||
// for each resource category
|
||||
for category := range pool.Resources {
|
||||
// for each resource in each category
|
||||
for resource, v := range pool.Resources[category].(map[string]any) {
|
||||
// create a resource chart for resource depending on resource type
|
||||
switch t := v.(type) {
|
||||
case NumericResource:
|
||||
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[category][resource] = ResourceChart{
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
@@ -128,7 +133,7 @@ func HandleGETAccount(c *gin.Context) {
|
||||
}
|
||||
case StorageResource:
|
||||
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[category][resource] = ResourceChart{
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
@@ -151,21 +156,25 @@ func HandleGETAccount(c *gin.Context) {
|
||||
}
|
||||
|
||||
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: float64(r.Avail), // usually an int
|
||||
Avail: avail, // usually an int
|
||||
Unit: "",
|
||||
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
|
||||
})
|
||||
}
|
||||
account.Resources[category][resource] = l
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
account.Pools = pools
|
||||
|
||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||
"global": common.Global,
|
||||
@@ -177,97 +186,102 @@ func HandleGETAccount(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func GetUserAccount(auth common.Auth) (Account, error) {
|
||||
account := Account{
|
||||
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
|
||||
func GetUser(auth common.Auth) (Account, error) {
|
||||
account := Account{}
|
||||
ctx := common.GetRequestContextFromCookies(auth)
|
||||
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 {
|
||||
return account, err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return account, err
|
||||
} else {
|
||||
account.Username = auth.Username
|
||||
}
|
||||
|
||||
body = map[string]any{}
|
||||
// get user resources
|
||||
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
|
||||
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 {
|
||||
return account, err
|
||||
return pools, err
|
||||
}
|
||||
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{}
|
||||
// get resource meta data
|
||||
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
|
||||
if err != nil {
|
||||
return account, err
|
||||
return pools, err
|
||||
}
|
||||
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)
|
||||
|
||||
// build each resource by its meta type
|
||||
for k, v := range meta {
|
||||
m := v.(map[string]any)
|
||||
// for each pool
|
||||
for poolname, pool := range pools {
|
||||
// for each resource in pool data
|
||||
for k, v := range pool.Resources {
|
||||
m := meta[k].(map[string]any)
|
||||
t := m["type"].(string)
|
||||
r := resources[k].(map[string]any)
|
||||
r := v.(map[string]any)
|
||||
category := m["category"].(string)
|
||||
if _, ok := account.Resources[category]; !ok {
|
||||
account.Resources[category] = map[string]any{}
|
||||
|
||||
// create a category if it does not already exist
|
||||
if _, ok := pool.Resources[category]; !ok {
|
||||
pool.Resources[category] = map[string]any{}
|
||||
}
|
||||
if t == "numeric" {
|
||||
|
||||
// depending on type, decode the pool data into the corresponding resource type
|
||||
switch t {
|
||||
case "numeric":
|
||||
n := NumericResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[category][k] = n
|
||||
} else if t == "storage" {
|
||||
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 account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[category][k] = n
|
||||
} else if t == "list" {
|
||||
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 account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[category][k] = n
|
||||
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||
}
|
||||
|
||||
// delete the old entry, only categories should be left at the end of the loop
|
||||
delete(pools[poolname].Resources, k)
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
// interpolate between min and max by normalized (0 - 1) val
|
||||
|
||||
+6
-11
@@ -2,7 +2,6 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"time"
|
||||
@@ -39,8 +38,6 @@ func HandleGETBackups(c *gin.Context) {
|
||||
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{
|
||||
"global": common.Global,
|
||||
"page": "backups",
|
||||
@@ -67,10 +64,14 @@ func HandleGETBackupsFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else { // return 401
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -79,13 +80,7 @@ func HandleGETBackupsFragment(c *gin.Context) {
|
||||
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
|
||||
backups := []InstanceBackup{}
|
||||
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
}
|
||||
ctx := common.GetRequestContextFromCookies(auth)
|
||||
body := []any{}
|
||||
res, code, err := common.RequestGetAPI(path, ctx, &body)
|
||||
if err != nil {
|
||||
|
||||
+42
-51
@@ -15,16 +15,7 @@ import (
|
||||
// imported types from fabric
|
||||
|
||||
type InstanceConfig struct {
|
||||
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"`
|
||||
paas.Instance `mapstructure:",squash"`
|
||||
// overrides
|
||||
ProctypeSelect common.Select
|
||||
}
|
||||
@@ -35,17 +26,13 @@ type GlobalConfig struct {
|
||||
}
|
||||
}
|
||||
|
||||
type UserConfigResources struct {
|
||||
type PoolConfig struct {
|
||||
CPU struct {
|
||||
Global []CPUConfig
|
||||
Nodes map[string][]CPUConfig
|
||||
Global []paas.MatchLimit
|
||||
Nodes map[string][]paas.MatchLimit
|
||||
}
|
||||
}
|
||||
|
||||
type CPUConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func HandleGETConfig(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
@@ -61,13 +48,13 @@ func HandleGETConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
for i, cpu := range config.ProctypeSelect.Options {
|
||||
if cpu.Value == config.CPU {
|
||||
if cpu.Value == config.Proctype {
|
||||
config.ProctypeSelect.Options[i].Selected = true
|
||||
}
|
||||
}
|
||||
@@ -97,10 +84,14 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -121,10 +112,14 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -145,10 +140,14 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -169,10 +168,14 @@ func HandleGETConfigBootFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -181,13 +184,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
|
||||
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
|
||||
config := InstanceConfig{}
|
||||
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
}
|
||||
ctx := common.GetRequestContextFromCookies(auth)
|
||||
body := map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(path, ctx, &body)
|
||||
if err != nil {
|
||||
@@ -208,20 +205,14 @@ func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, erro
|
||||
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{
|
||||
ID: "proctype",
|
||||
Required: true,
|
||||
}
|
||||
|
||||
// get global resource config
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
}
|
||||
ctx := common.GetRequestContextFromCookies(auth)
|
||||
body := map[string]any{}
|
||||
path := "/global/config/resources"
|
||||
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 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
global := GlobalConfig{}
|
||||
err = mapstructure.Decode(body["resources"], &global)
|
||||
globalConfig := GlobalConfig{}
|
||||
err = mapstructure.Decode(body["resources"], &globalConfig)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// get user resource config
|
||||
// get pool resource config
|
||||
body = map[string]any{}
|
||||
path = "/user/config/resources"
|
||||
path = fmt.Sprintf("/access/pools/%s", pool)
|
||||
res, code, err = common.RequestGetAPI(path, ctx, &body)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
@@ -247,21 +238,21 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
user := UserConfigResources{}
|
||||
err = mapstructure.Decode(body, &user)
|
||||
poolCPUConfig := PoolConfig{}
|
||||
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// use node specific rules if present, otherwise use global rules
|
||||
var userCPU []CPUConfig
|
||||
if _, ok := user.CPU.Nodes[vm.Node]; ok {
|
||||
userCPU = user.CPU.Nodes[vm.Node]
|
||||
var userCPU []paas.MatchLimit
|
||||
if _, ok := poolCPUConfig.CPU.Nodes[vm.Node]; ok {
|
||||
userCPU = poolCPUConfig.CPU.Nodes[vm.Node]
|
||||
} 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
|
||||
cputypes.Options = append(cputypes.Options, common.Option{
|
||||
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)
|
||||
}
|
||||
supported := struct {
|
||||
data []CPUConfig
|
||||
data []paas.MatchLimit
|
||||
}{}
|
||||
err = mapstructure.Decode(body, supported)
|
||||
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 _, 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
|
||||
})
|
||||
if !contains {
|
||||
|
||||
+29
-23
@@ -72,10 +72,14 @@ func HandleGETInstancesFragment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else { // return 401
|
||||
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) {
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
}
|
||||
body := map[string]any{}
|
||||
ctx := common.GetRequestContextFromCookies(auth)
|
||||
body := []any{}
|
||||
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -102,16 +101,17 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
||||
nodes := map[string]Node{}
|
||||
|
||||
// parse /proxmox/cluster/resources to separate instances and nodes
|
||||
for _, v := range body["data"].([]any) {
|
||||
for _, v := range body {
|
||||
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{}
|
||||
err := mapstructure.Decode(v, &node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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{}
|
||||
err := mapstructure.Decode(v, &instance)
|
||||
if err != nil {
|
||||
@@ -127,9 +127,10 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
||||
// set instance's config link path
|
||||
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||
// 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)
|
||||
} 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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
body = map[string]any{}
|
||||
body = []any{}
|
||||
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
|
||||
if err != nil {
|
||||
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{}
|
||||
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
|
||||
for _, v := range body["data"].([]any) {
|
||||
for _, v := range body {
|
||||
// parse task as Task object
|
||||
task := Task{}
|
||||
err := mapstructure.Decode(v, &task)
|
||||
@@ -180,22 +181,23 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
||||
} 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
|
||||
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
|
||||
expected_state[task.VMID] = "running"
|
||||
} else if task.Type == "qmstop" || task.Type == "vzstop" { // if the task was a stop task, update the expected state to stopped
|
||||
expected_state[task.VMID] = "stopped"
|
||||
switch task.Type {
|
||||
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
|
||||
expected_states[task.VMID] = "running"
|
||||
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
|
||||
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
|
||||
// get /status/current which is updated faster than /cluster/resources
|
||||
instance := instances[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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// attempt to decode task status as instance status
|
||||
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
|
||||
instances[vmid] = instance
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ func GetLoginRealms() ([]Realm, error) {
|
||||
ctx := common.RequestContext{
|
||||
Cookies: nil,
|
||||
}
|
||||
body := map[string]any{}
|
||||
body := []any{}
|
||||
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
|
||||
if err != nil {
|
||||
return realms, err
|
||||
@@ -36,7 +36,7 @@ func GetLoginRealms() ([]Realm, error) {
|
||||
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)
|
||||
realm := Realm{}
|
||||
err := mapstructure.Decode(v, &realm)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
module proxmoxaas-dashboard
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
|
||||
github.com/gin-gonic/gin v1.12.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
|
||||
)
|
||||
|
||||
@@ -14,33 +14,33 @@ replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
|
||||
|
||||
require (
|
||||
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/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/gin-contrib/sse v1.1.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/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-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.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/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/quic-go v0.59.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.12 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
+1
-1
Submodule proxmoxaas-common-lib updated: cc53d7bdea...52ac2c2b97
@@ -1,12 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
app "proxmoxaas-dashboard/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||
flag.Parse()
|
||||
app.Run(configPath)
|
||||
app.Run()
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ legend {
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset > *:last-child {
|
||||
|
||||
+3
-4
@@ -53,7 +53,6 @@ header {
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
background-color: var(--nav-header-bg-color);
|
||||
color: var(--nav-header-text-color);
|
||||
@@ -61,8 +60,8 @@ header h1 {
|
||||
}
|
||||
|
||||
nav {
|
||||
font-size: var(--small-font-size);
|
||||
overflow: hidden;
|
||||
font-size: larger;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -80,7 +79,7 @@ label[for="navtoggle"], #navtoggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (width >= 600px){
|
||||
@media screen and (width >= 601px){
|
||||
header {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
@@ -106,7 +105,7 @@ label[for="navtoggle"], #navtoggle {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px){
|
||||
@media screen and (width <= 601px){
|
||||
header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
+17
-1
@@ -3,6 +3,9 @@
|
||||
--positive-color: #0f0;
|
||||
--highlight-color: yellow;
|
||||
--lightbg-text-color: black;
|
||||
--large-font-size: 32px;
|
||||
--medium-font-size: 24px;
|
||||
--small-font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
@@ -41,9 +44,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
*, h1, h2, h3, p {
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
|
||||
+5
-38
@@ -34,51 +34,18 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<h2>Account</h2>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Account Details</h3>
|
||||
<p id="username">Username: {{.account.Username}}</p>
|
||||
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
|
||||
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
|
||||
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
|
||||
<p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
|
||||
<p id="email">Email: {{.account.Mail}}</p>
|
||||
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
|
||||
</section>
|
||||
<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>
|
||||
{{range $poolname, $pool := .account.Pools}}
|
||||
{{template "pool-resources" $pool}}
|
||||
{{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>
|
||||
</main>
|
||||
<template id="change-password-dialog">
|
||||
<link rel="stylesheet" href="modules/w3.css">
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
|
||||
<section class="w3-card w3-padding">
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
|
||||
|
||||
+3
-5
@@ -65,16 +65,14 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Instances</h2>
|
||||
<div class="w3-card w3-padding">
|
||||
<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">
|
||||
<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">
|
||||
</form>
|
||||
<!--Add Instance Button & Dialog Template-->
|
||||
@@ -94,14 +92,14 @@
|
||||
<option value="lxc">Container</option>
|
||||
<option value="qemu">Virtual Machine</option>
|
||||
</select>
|
||||
<label for="pool">Pool</label>
|
||||
<select class="w3-select w3-border" name="pool" id="pool" required></select>
|
||||
<label for="node">Node</label>
|
||||
<select class="w3-select w3-border" name="node" id="node" required></select>
|
||||
<label for="name">Name</label>
|
||||
<input class="w3-input w3-border" name="name" id="name" type="text" required>
|
||||
<label for="vmid">ID</label>
|
||||
<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>
|
||||
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
|
||||
<label for="memory">Memory (MiB)</label>
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main class="flex" style="justify-content: center; align-items: center;">
|
||||
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<h2>Settings</h2>
|
||||
<form id="settings">
|
||||
@@ -42,6 +40,8 @@
|
||||
<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>
|
||||
<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>
|
||||
<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 |
@@ -0,0 +1 @@
|
||||
../../common/config-inactive.svg
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 32 B |
@@ -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 |
@@ -83,7 +83,7 @@ class BackupCard extends HTMLElement {
|
||||
|
||||
async handleDeleteButton () {
|
||||
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||
dialog(template, async (result, form) => {
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
volid: this.volid
|
||||
@@ -99,7 +99,7 @@ class BackupCard extends HTMLElement {
|
||||
|
||||
async handleRestoreButton () {
|
||||
const template = this.shadowRoot.querySelector("#restore-dialog");
|
||||
dialog(template, async (result, form) => {
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
volid: this.volid
|
||||
|
||||
@@ -2,8 +2,10 @@ import { getSyncSettings, requestAPI } from "./utils.js";
|
||||
|
||||
export async function setupClientSync (callback) {
|
||||
const { scheme, rate } = getSyncSettings();
|
||||
|
||||
if (scheme === "always") {
|
||||
if (scheme === "never") {
|
||||
return;
|
||||
}
|
||||
else if (scheme === "always") {
|
||||
window.setInterval(callback, rate * 1000);
|
||||
}
|
||||
else if (scheme === "hash") {
|
||||
@@ -19,7 +21,7 @@ export async function setupClientSync (callback) {
|
||||
}
|
||||
else if (scheme === "interrupt") {
|
||||
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
|
||||
socket.addEventListener("open", (event) => {
|
||||
socket.addEventListener("open", (_event) => {
|
||||
socket.send(`rate ${rate}`);
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
|
||||
@@ -54,7 +54,7 @@ class VolumeAction extends HTMLElement {
|
||||
|
||||
async handleDiskDetach () {
|
||||
const disk = this.dataset.volume;
|
||||
dialog(this.template, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
||||
@@ -136,7 +136,7 @@ class VolumeAction extends HTMLElement {
|
||||
|
||||
async handleDiskDelete () {
|
||||
const disk = this.dataset.volume;
|
||||
dialog(this.template, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
||||
@@ -224,7 +224,7 @@ async function handleCDAdd () {
|
||||
const isos = await requestAPI("/user/vm-isos", "GET");
|
||||
const select = d.querySelector("#iso-select");
|
||||
|
||||
for (const iso of isos) {
|
||||
for (const iso of isos.data) {
|
||||
select.add(new Option(iso.name, iso.volid));
|
||||
}
|
||||
select.selectedIndex = -1;
|
||||
@@ -275,7 +275,7 @@ class NetworkAction extends HTMLElement {
|
||||
|
||||
async handleNetworkDelete () {
|
||||
const netID = this.dataset.network;
|
||||
dialog(this.template, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
|
||||
const net = `${netID}`;
|
||||
@@ -375,7 +375,7 @@ class DeviceAction extends HTMLElement {
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
|
||||
for (const availDevice of availDevices) {
|
||||
for (const availDevice of availDevices.data) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||
}
|
||||
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
||||
@@ -383,7 +383,7 @@ class DeviceAction extends HTMLElement {
|
||||
|
||||
async handleDeviceDelete () {
|
||||
const deviceID = this.dataset.device;
|
||||
dialog(this.template, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const device = `${deviceID}`;
|
||||
@@ -437,8 +437,8 @@ async function handleDeviceAdd () {
|
||||
}
|
||||
});
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
for (const availDevice of availDevices) {
|
||||
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
|
||||
for (const availDevice of availDevices.data) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||
}
|
||||
d.querySelector("#pcie").checked = true;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* body contains an optional form or other information,
|
||||
* and controls contains a series of buttons which controls the form
|
||||
*/
|
||||
export function dialog (template, onclose = async (result, form) => { }) {
|
||||
export function dialog (template, onclose = async (_result, _form) => { }) {
|
||||
const dialog = template.content.querySelector("dialog").cloneNode(true);
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener("close", async () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ class DraggableContainer extends HTMLElement {
|
||||
window.Sortable.create(this.content, {
|
||||
group: this.dataset.group,
|
||||
ghostClass: "ghost",
|
||||
setData: function (dataTransfer, dragEl) {
|
||||
setData: function (dataTransfer, _dragEl) {
|
||||
dataTransfer.setDragImage(blank, 0, 0);
|
||||
}
|
||||
});
|
||||
|
||||
+41
-45
@@ -159,7 +159,7 @@ class InstanceCard extends HTMLElement {
|
||||
async handlePowerButton () {
|
||||
if (!this.actionLock) {
|
||||
const template = this.shadowRoot.querySelector("#power-dialog");
|
||||
dialog(template, async (result, form) => {
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
const targetAction = this.status === "running" ? "stop" : "start";
|
||||
@@ -193,7 +193,7 @@ class InstanceCard extends HTMLElement {
|
||||
handleDeleteButton () {
|
||||
if (!this.actionLock && this.status === "stopped") {
|
||||
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||
dialog(template, async (result, form) => {
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
|
||||
@@ -247,7 +247,7 @@ function sortInstances () {
|
||||
const searchQuery = document.querySelector("#search").value || null;
|
||||
let criteria;
|
||||
if (!searchQuery) {
|
||||
criteria = (item, query = null) => {
|
||||
criteria = (item, _query = null) => {
|
||||
return { score: item.vmid, alignment: null };
|
||||
};
|
||||
}
|
||||
@@ -343,10 +343,10 @@ async function handleInstanceAddButton () {
|
||||
}
|
||||
});
|
||||
|
||||
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||
|
||||
// setup type select
|
||||
const typeSelect = d.querySelector("#type");
|
||||
typeSelect.selectedIndex = -1;
|
||||
// on type change, reveal or hide the container specific section
|
||||
typeSelect.addEventListener("change", () => {
|
||||
if (typeSelect.value === "qemu") {
|
||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||
@@ -366,66 +366,62 @@ async function handleInstanceAddButton () {
|
||||
element.disabled = true;
|
||||
});
|
||||
|
||||
const rootfsContent = "rootdir";
|
||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
const userResources = await requestAPI("/user/dynamic/resources", "GET");
|
||||
const userCluster = await requestAPI("/user/config/cluster", "GET");
|
||||
// setup pool select
|
||||
const poolSelect = d.querySelector("#pool");
|
||||
poolSelect.innerHTML = "";
|
||||
// add user pools to selector
|
||||
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
|
||||
userPools.forEach((element) => {
|
||||
poolSelect.add(new Option(element));
|
||||
});
|
||||
poolSelect.selectedIndex = -1;
|
||||
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
|
||||
poolSelect.addEventListener("change", async () => {
|
||||
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
|
||||
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
nodeSelect.innerHTML = "";
|
||||
const clusterNodes = await requestPVE("/nodes", "GET");
|
||||
const allowedNodes = Object.keys(userCluster.nodes);
|
||||
clusterNodes.data.forEach((element) => {
|
||||
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
|
||||
const allowedNodes = Object.keys(pool["nodes-allowed"]);
|
||||
clusterNodes.forEach((element) => {
|
||||
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||
nodeSelect.add(new Option(element.node));
|
||||
}
|
||||
});
|
||||
nodeSelect.selectedIndex = -1;
|
||||
|
||||
// set vmid min/max
|
||||
d.querySelector("#vmid").min = pool["vmid-allowed"].min;
|
||||
d.querySelector("#vmid").max = pool["vmid-allowed"].max;
|
||||
});
|
||||
|
||||
// setup node select
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
nodeSelect.selectedIndex = -1;
|
||||
// on node change, get the available storages and repopulate the storage selector
|
||||
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
|
||||
const node = nodeSelect.value;
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
|
||||
rootfsStorage.innerHTML = "";
|
||||
storage.data.forEach((element) => {
|
||||
storage.forEach((element) => {
|
||||
if (element.content.includes(rootfsContent)) {
|
||||
rootfsStorage.add(new Option(element.storage));
|
||||
}
|
||||
});
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
// set core and memory min/max depending on node selected
|
||||
if (node in userResources.cores.nodes) {
|
||||
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#cores").max = userResources.cores.global.avail;
|
||||
}
|
||||
|
||||
if (node in userResources.memory.nodes) {
|
||||
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#memory").max = userResources.memory.global.avail;
|
||||
}
|
||||
});
|
||||
|
||||
// set vmid min/max
|
||||
d.querySelector("#vmid").min = userCluster.vmid.min;
|
||||
d.querySelector("#vmid").max = userCluster.vmid.max;
|
||||
|
||||
// add user pools to selector
|
||||
const poolSelect = d.querySelector("#pool");
|
||||
poolSelect.innerHTML = "";
|
||||
const userPools = Object.keys(userCluster.pools);
|
||||
userPools.forEach((element) => {
|
||||
poolSelect.add(new Option(element));
|
||||
});
|
||||
poolSelect.selectedIndex = -1;
|
||||
// setup root dir select
|
||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
// set rootfs content type (rootdir)
|
||||
const rootfsContent = "rootdir";
|
||||
|
||||
// setup templateImage depending on selected image storage
|
||||
const templateImage = d.querySelector("#template-image");
|
||||
// add template images to selector
|
||||
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
|
||||
for (const template of templates) {
|
||||
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||
for (const template of templates.data) {
|
||||
templateImage.append(new Option(template.name, template.volid));
|
||||
}
|
||||
templateImage.selectedIndex = -1;
|
||||
|
||||
@@ -4,6 +4,7 @@ window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
function init () {
|
||||
setAppearance();
|
||||
|
||||
const { scheme, rate } = getSyncSettings();
|
||||
if (scheme) {
|
||||
document.querySelector(`#sync-${scheme}`).checked = true;
|
||||
|
||||
+13
-12
@@ -80,33 +80,34 @@ async function request (url, content) {
|
||||
try {
|
||||
const response = await fetch(url, content);
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
let data = null;
|
||||
const res = {};
|
||||
|
||||
if (contentType === null) {
|
||||
data = {};
|
||||
res.data = null;
|
||||
res.status = response.status;
|
||||
}
|
||||
else if (contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
data.status = response.status;
|
||||
res.data = await response.json();
|
||||
res.status = response.status;
|
||||
}
|
||||
else if (contentType.includes("text/html")) {
|
||||
data = { data: await response.text() };
|
||||
data.status = response.status;
|
||||
res.data = await response.text();
|
||||
res.status = response.status;
|
||||
}
|
||||
else if (contentType.includes("text/plain")) {
|
||||
data = { data: await response.text() };
|
||||
data.status = response.status;
|
||||
res.data = await response.text();
|
||||
res.status = response.status;
|
||||
}
|
||||
else {
|
||||
data = {};
|
||||
res.data = null;
|
||||
res.status = response.status;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: response.status, error: data ? data.error : response.status };
|
||||
return { status: response.status, error: res.data ? res.data.error : response.status };
|
||||
}
|
||||
else {
|
||||
data.status = response.status;
|
||||
return data || response;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{{/* <head> common across all pages*/}}
|
||||
{{define "head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -14,7 +15,9 @@
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
{{end}}
|
||||
|
||||
{{/* <header> common across all pages*/}}
|
||||
{{define "header"}}
|
||||
<header>
|
||||
<h1>{{.global.Organization}}</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
@@ -28,4 +31,5 @@
|
||||
<a href="login">Logout</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
{{end}}
|
||||
@@ -448,7 +448,7 @@
|
||||
<p>{{.Device_ID}}</p>
|
||||
<p>{{.Device_Name}}</p>
|
||||
<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">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
|
||||
@@ -470,7 +470,7 @@
|
||||
</template>
|
||||
</template>
|
||||
</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">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
.hide-large {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-medium {display:none !important}
|
||||
.hide-small {display:none !important}
|
||||
|
||||
@@ -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}}
|
||||
@@ -37,7 +37,7 @@
|
||||
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
|
||||
<label id="caption" for="resource">
|
||||
<span>{{.Name}}</span>
|
||||
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||
<span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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"}}
|
||||
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
|
||||
{{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}}
|
||||
<option value="{{.Value}}" selected>{{.Display}}</option>
|
||||
{{else}}
|
||||
<option value="{{.Value}}">{{.Display}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user