1 Commits

Author SHA1 Message Date
alu 04c4d990c6 Update README.md 2026-05-24 19:12:38 +00:00
46 changed files with 676 additions and 1329 deletions
-3
View File
@@ -1,6 +1,3 @@
[submodule "proxmoxaas-common-lib"]
path = proxmoxaas-common-lib
url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib
[submodule "WFA-JS"]
path = WFA-JS
url = https://git.tronnet.net/alu/WFA-JS
+3 -8
View File
@@ -1,15 +1,10 @@
.PHONY: build test clean wfa-js
.PHONY: build test clean
build: clean wfa-js
build: clean
@echo "======================== Building Binary ======================="
mkdir -p dist
# resolve symbolic links in web by copying it into dist/web/
cp -rL web/ dist/web/
CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
wfa-js:
$(MAKE) -C WFA-JS
cp -f WFA-JS/dist/* web/modules
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
test: clean
go run .
+5 -5
View File
@@ -1,5 +1,5 @@
# 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
- Simplified interface for non administrator users
@@ -13,7 +13,7 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
## ProxmoxAAS System Installation Overview
The ProxmoxAAS project is large and is split into multiple components. There are four required components, the Dashboard, API, Fabric, and access-manager-api. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
The ProxmoxAAS project is large and is split into multiple components. There are three required components, the Dashboard, API, and Fabric. There is also an optional LDAP component for organizations that want to use LDAP as their authentication backend. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs.
@@ -25,7 +25,7 @@ We will assume different hosts for each component which are accessible by unique
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net |
| ProxmoxAAS-API | api.local | paas.domain.net/api/ |
| ProxmoxAAS-Fabric | fabric.local | N/A |
| access-manager-api | access.local | N/A|
| ProxmoxAAS-LDAP | ldap.local | N/A|
## Prerequisites - Dashboard
- Proxmox VE Cluster (v7.0+)
@@ -53,9 +53,9 @@ To install the API component, go to [ProxmoxAAS-API](https://git.tronnet.net/tro
To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function. The Fabric installation will also have steps for setting up the reverse proxy server.
## Installation - access-manager-api
## Installation - LDAP
To install the access-manager-api component, go to [access-manager-api](https://git.tronnet.net/tronnet/access-manager-api). This is required for the app to function. The access-manager-api installation will also have steps for setting up the reverse proxy server.
To install the LDAP component, go to [ProxmoxAAS-LDAP](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP).This is an optional component which adds a lightweight REST API server ontop of a simplified LDAP environment. It is only used by the API as a potential authentication backend.
## Installation - Reverse Proxy
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. The configuration should include at least the following, ensuring that the configured ports are adjusted appropriately:
Submodule WFA-JS deleted from f8f8636cc3
+2 -6
View File
@@ -1,7 +1,6 @@
package app
import (
"flag"
"fmt"
"log"
"proxmoxaas-dashboard/app/common"
@@ -12,10 +11,7 @@ import (
"github.com/tdewolff/minify/v2"
)
func Run() {
configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
func Run(configPath *string) {
common.Global = common.GetConfig(*configPath)
// setup static resources
@@ -42,7 +38,7 @@ func Run() {
router.GET("/settings", routes.HandleGETSettings)
// run on all interfaces with port
log.Fatal("[ERR ] 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)
@@ -1,5 +1,3 @@
//go:build release
package common
import (
@@ -57,3 +55,45 @@ 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,
},
}
*/
-55
View File
@@ -1,55 +0,0 @@
//go:build !release
package common
import (
"io"
"github.com/tdewolff/minify/v2"
)
// defines mime type and associated minifier
type MimeType struct {
Type string
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
}
// debug mime types
var MimeTypes = map[string]MimeType{
"css": {
Type: "text/css",
Minifier: nil,
},
"html": {
Type: "text/html",
Minifier: nil,
},
"tmpl": {
Type: "text/plain",
Minifier: nil,
},
"frag": {
Type: "text/plain",
Minifier: nil,
},
"svg": {
Type: "image/svg+xml",
Minifier: nil,
},
"png": {
Type: "image/png",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: nil,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
+24
View File
@@ -22,6 +22,13 @@ type StaticFile struct {
MimeType MimeType
}
// parsed vmpath data (ie node/type/vmid)
type VMPath struct {
Node string
Type string
VMID string
}
// type used for templated <select>
type Select struct {
ID string
@@ -35,3 +42,20 @@ type Option struct {
Value string
Display string
}
type RequestContext struct {
Cookies map[string]string
}
type Auth struct {
Username string
Token string
CSRF string
}
type Icon struct {
ID string
Src string
Alt string
Clickable bool
}
+45 -59
View File
@@ -10,37 +10,27 @@ import (
"io"
"io/fs"
"log"
"math"
"net/http"
"os"
"reflect"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2"
paas "proxmoxaas-common-lib"
)
// get config file from configPath
func GetConfig(configPath string) Config {
root, err := os.OpenRoot(".")
content, err := os.ReadFile(configPath)
if err != nil {
log.Fatal("[ERR ] error opening root dir: ", err)
log.Fatal("Error when opening config file: ", err)
}
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil {
log.Fatal("[ERR ] error opening config file: ", err)
}
var config Config
err = json.Unmarshal(content, &config)
if err != nil {
log.Fatal("[ERR ] error parsing config file: ", err)
log.Fatal("Error during parsing config file: ", err)
}
return config
}
@@ -57,14 +47,14 @@ func InitMinify() *minify.M {
func MinifyStatic(m *minify.M, files embed.FS) 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 {
return err
}
if !entry.IsDir() {
v, err := files.ReadFile(path)
if err != nil {
log.Fatalf("[ERR ] 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
@@ -72,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
min, err := m.String(mimetype.Type, string(v)) // try to minify
if err != nil {
log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
log.Fatalf("error minifying file %s: %s", path, err.Error())
}
minified[path] = StaticFile{
Data: min,
@@ -94,13 +84,7 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
}
return nil
})
if err != nil {
log.Printf("[ERR ] error in MinifyStatic: %s", err)
return nil
} else {
return minified
}
}
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
@@ -109,20 +93,20 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
"MapKeys": func(x any, sep string) string {
v := reflect.ValueOf(x)
keys := v.MapKeys()
var s strings.Builder
for i := range keys {
s := ""
for i := 0; i < len(keys); i++ {
if i != 0 {
s.WriteString(sep)
s += sep
}
s.WriteString(keys[i].String())
s += keys[i].String()
}
return s.String()
return s
},
"Map": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]any, len(values)/2)
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
@@ -171,20 +155,17 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string
}
func HandleNonFatalError(c *gin.Context, err error) {
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
log.Printf("[Error] encountered an error: %s", err.Error())
c.Status(http.StatusInternalServerError)
}
func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
func RequestGetAPI(path string, context RequestContext, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil {
return nil, 0, err
}
if auth != nil {
for k, v := range GetRequestContextFromCookies(*auth) {
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
}
for k, v := range context.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
client := &http.Client{}
@@ -204,6 +185,7 @@ func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int,
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)
@@ -221,45 +203,49 @@ func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int,
return response, response.StatusCode, nil
}
func GetAuthFromRequest(c *gin.Context) (paas.Auth, error) {
func GetAuth(c *gin.Context) (Auth, error) {
_, errAuth := c.Cookie("auth")
username, errUsername := c.Cookie("username")
token, errToken := c.Cookie("PVEAuthCookie")
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
access, errAccess := c.Cookie("PAASAccessManagerTicket")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return paas.Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
} else {
return paas.Auth{Username: username, Token: token, CSRF: csrf, AccessManagerTicket: access}, nil
return Auth{username, token, csrf}, nil
}
}
func GetInstancePathFromRequest(c *gin.Context) (paas.InstancePath, error) {
func ExtractVMPath(c *gin.Context) (VMPath, error) {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return paas.InstancePath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
}
vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
if err != nil {
return paas.InstancePath{}, err
}
vm_path := paas.InstancePath{
NodeName: req_node,
InstanceType: paas.InstanceType(req_type),
InstanceID: paas.InstanceID(vmid_int),
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
return vm_path, nil
}
func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
return map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
func FormatNumber(val int64, base int64) (float64, string) {
valf := float64(val)
basef := float64(base)
steps := 0
for math.Abs(valf) > basef && steps < 4 {
valf /= basef
steps++
}
if base == 1000 {
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
return 0, ""
}
}
+69 -81
View File
@@ -3,7 +3,6 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color"
@@ -12,8 +11,14 @@ import (
)
type Account struct {
paas.User
Pools map[string]paas.Pool
Username string
Pools map[string]bool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
}
// numerical constraint
@@ -36,7 +41,7 @@ type NumericResource struct {
Type string
Name string
Multiplier int64
Base uint64
Base int64
Compact bool
Unit string
Display bool
@@ -50,7 +55,7 @@ type StorageResource struct {
Type string
Name string
Multiplier int64
Base uint64
Base int64
Compact bool
Unit string
Display bool
@@ -77,7 +82,7 @@ type ResourceChart struct {
Name string
Used int64
Max int64
Avail string
Avail float64
Prefix string
Unit string
ColorHex string
@@ -96,31 +101,21 @@ var Green = color.RGB{
}
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
account, err := GetUser(auth)
account, err := GetUserAccount(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
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
// for each resource category, create a resource chart
for category, resources := range account.Resources {
for resource, v := range resources {
switch t := v.(type) {
case NumericResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
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,
@@ -132,8 +127,8 @@ func HandleGETAccount(c *gin.Context) {
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
}
case StorageResource:
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
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,
@@ -156,25 +151,21 @@ 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: avail, // usually an int
Avail: float64(r.Avail), // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
pools[poolname].Resources[category].(map[string]any)[resource] = l
account.Resources[category][resource] = l
}
}
}
}
account.Pools = pools
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
@@ -186,100 +177,97 @@ func HandleGETAccount(c *gin.Context) {
}
}
func GetUser(auth paas.Auth) (Account, error) {
account := Account{}
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
body := map[string]any{}
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
}
err = mapstructure.Decode(body, &account)
return account, err
}
func GetUserPools(auth paas.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
// get all pools
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
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 {
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
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
}
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", &auth, &body)
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil {
return pools, err
return account, err
}
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)
// 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)
// build each resource by its meta type
for k, v := range meta {
m := v.(map[string]any)
t := m["type"].(string)
r := v.(map[string]any)
r := resources[k].(map[string]any)
category := m["category"].(string)
// create a category if it does not already exist
if _, ok := pool.Resources[category]; !ok {
pool.Resources[category] = map[string]any{}
if _, ok := account.Resources[category]; !ok {
account.Resources[category] = map[string]any{}
}
// depending on type, decode the pool data into the corresponding resource type
switch t {
case "numeric":
if t == "numeric" {
n := NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "storage":
account.Resources[category][k] = n
} else if t == "storage" {
n := StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "list":
account.Resources[category][k] = n
} else if t == "list" {
n := ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
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)
account.Resources[category][k] = n
}
}
return pools, nil
return account, nil
}
// interpolate between min and max by normalized (0 - 1) val
+20 -15
View File
@@ -2,8 +2,8 @@ package routes
import (
"fmt"
"log"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"time"
@@ -21,9 +21,9 @@ type InstanceBackup struct {
}
func HandleGETBackups(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -39,6 +39,8 @@ 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",
@@ -51,9 +53,9 @@ func HandleGETBackups(c *gin.Context) {
}
func HandleGETBackupsFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -65,24 +67,27 @@ func HandleGETBackupsFragment(c *gin.Context) {
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
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,
},
}
body := []any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return backups, err
}
@@ -96,8 +101,8 @@ func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup,
}
for i := range backups {
size, prefix := paas.FormatNumber(paas.SafeUint64(backups[i].Size), paas.Base1024)
backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
size, prefix := common.FormatNumber(backups[i].Size, 1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
+68 -57
View File
@@ -15,7 +15,16 @@ import (
// imported types from fabric
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
ProctypeSelect common.Select
}
@@ -26,17 +35,21 @@ type GlobalConfig struct {
}
}
type PoolConfig struct {
type UserConfigResources struct {
CPU struct {
Global []paas.MatchLimit
Nodes map[string][]paas.MatchLimit
Global []CPUConfig
Nodes map[string][]CPUConfig
}
}
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -48,13 +61,13 @@ func HandleGETConfig(c *gin.Context) {
}
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 {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.Proctype {
if cpu.Value == config.CPU {
config.ProctypeSelect.Options[i].Selected = true
}
}
@@ -70,9 +83,9 @@ func HandleGETConfig(c *gin.Context) {
}
func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -84,23 +97,19 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -112,23 +121,19 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -140,23 +145,19 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.GetInstancePathFromRequest(c)
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
@@ -168,24 +169,27 @@ func HandleGETConfigBootFragment(c *gin.Context) {
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
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,
},
}
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return config, err
}
@@ -204,53 +208,60 @@ func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, er
return config, nil
}
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
func GetCPUTypes(vm common.VMPath, 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,
},
}
body := map[string]any{}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, &auth, &body)
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
globalConfig := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &globalConfig)
global := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &global)
if err != nil {
return cputypes, err
}
// get pool resource config
// get user resource config
body = map[string]any{}
path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, &auth, &body)
path = "/user/config/resources"
res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
user := UserConfigResources{}
err = mapstructure.Decode(body, &user)
if err != nil {
return cputypes, err
}
// use node specific rules if present, otherwise use global rules
var userCPU []paas.MatchLimit
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
var userCPU []CPUConfig
if _, ok := user.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node]
} 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
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
@@ -260,8 +271,8 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
} else { // cpu is a blacklist
// get the supported cpu types from the node
body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
res, code, err = common.RequestGetAPI(path, &auth, &body)
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil {
return cputypes, err
}
@@ -269,7 +280,7 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []paas.MatchLimit
data []CPUConfig
}{}
err = mapstructure.Decode(body, supported)
if err != nil {
@@ -278,7 +289,7 @@ func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Sele
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
return c.Name == cpu.Name
})
if !contains {
+29 -35
View File
@@ -3,7 +3,6 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"strconv"
@@ -46,7 +45,7 @@ type InstanceStatus struct {
}
func HandleGETIndex(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
@@ -65,7 +64,7 @@ func HandleGETIndex(c *gin.Context) {
}
func HandleGETInstancesFragment(c *gin.Context) {
auth, err := common.GetAuthFromRequest(c)
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
@@ -73,23 +72,25 @@ func HandleGETInstancesFragment(c *gin.Context) {
return
}
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,
})
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { // return 401
c.Status(http.StatusUnauthorized)
}
}
func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
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{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
if err != nil {
return nil, nil, err
}
@@ -101,17 +102,16 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
nodes := map[string]Node{}
// parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range body {
for _, v := range body["data"].([]any) {
m := v.(map[string]any)
switch m["type"] {
case "node": // if type is node -> parse as Node object
if m["type"] == "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
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{}
err := mapstructure.Decode(v, &instance)
if err != nil {
@@ -127,10 +127,9 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
// 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
switch instance.Type {
case "qemu":
if instance.Type == "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)
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)
}
// set the instance's backups link path
@@ -139,8 +138,8 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
instances[vmid] = instance
}
body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
body = map[string]any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
if err != nil {
return nil, nil, err
}
@@ -149,10 +148,10 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
}
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
for _, v := range body {
for _, v := range body["data"].([]any) {
// parse task as Task object
task := Task{}
err := mapstructure.Decode(v, &task)
@@ -181,24 +180,23 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
} 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
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"
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"
}
}
}
}
// 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
// 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{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
body = map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return nil, nil, err
}
@@ -206,12 +204,8 @@ func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
}
// attempt to decode task status as instance status
status := InstanceStatus{}
err = mapstructure.Decode(body, &status)
if err != nil { // did not successfully decode task status, just skip
continue
}
mapstructure.Decode(body["data"], &status)
instance.Status = status.Status
instances[vmid] = instance
+6 -3
View File
@@ -24,8 +24,11 @@ type Realm struct {
func GetLoginRealms() ([]Realm, error) {
realms := []Realm{}
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
ctx := common.RequestContext{
Cookies: nil,
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil {
return realms, err
}
@@ -33,7 +36,7 @@ func GetLoginRealms() ([]Realm, error) {
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)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func HandleGETSettings(c *gin.Context) {
_, err := common.GetAuthFromRequest(c)
_, err := common.GetAuth(c)
if err == nil {
c.HTML(http.StatusOK, "html/settings.html", gin.H{
"global": common.Global,
+15 -15
View File
@@ -1,12 +1,12 @@
module proxmoxaas-dashboard
go 1.26.4
go 1.26.2
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.13
github.com/tdewolff/minify/v2 v2.24.12
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.2 // indirect
github.com/bytedance/sonic v1.15.0 // 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/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.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-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.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/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/tdewolff/parse/v2 v2.8.12 // 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.6.1 // indirect
golang.org/x/arch v0.28.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
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
google.golang.org/protobuf v1.36.11 // indirect
)
+4 -1
View File
@@ -1,9 +1,12 @@
package main
import (
"flag"
app "proxmoxaas-dashboard/app"
)
func main() {
app.Run()
configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
app.Run(configPath)
}
+10 -25
View File
@@ -5,7 +5,7 @@ input, select, textarea {
.input-grid {
display: grid;
gap: 0.5em 1em;
gap: 5px 10px;
align-items: center;
width: 100%;
}
@@ -13,12 +13,12 @@ input, select, textarea {
.input-grid * {
margin-top: 0;
margin-bottom: 0;
padding-top: 0.5em;
padding-bottom: 0.5em;
padding-top: 8px;
padding-bottom: 8px;
}
.input-grid input {
padding: 0.5em;
padding: 8px;
}
.input-grid img {
@@ -34,17 +34,17 @@ legend {
width: 100%;
margin: 0;
padding: 0;
margin-top: 0.25em;
margin-bottom: 0.25em;
line-height: 1.5em;
margin-top: 0.25lh;
margin-bottom: 0.25lh;
}
fieldset {
border: 0;
padding: 0;
}
fieldset > *:last-child {
margin-bottom: 0.5em;
margin-bottom: 8px;
}
fieldset > .input-grid {
@@ -66,7 +66,7 @@ input[type="radio"] {
}
.w3-select, select {
padding: 0.5em;
padding: 8px;
}
.w3-check {
@@ -77,25 +77,10 @@ input[type="radio"] {
:not(.input-grid) .input-grid + * {
display: inline-block;
width: 100%;
margin-top: 0.5em;
margin-top: 5px;
}
dialog {
margin: auto;
max-width: calc(min(100% - 16px, 80ch));
color: var(--main-text-color);
}
dialog #prompt {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
dialog button[value="confirm"] {
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
}
dialog button[value="cancel"] {
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
}
+6 -6
View File
@@ -53,7 +53,7 @@ header {
}
header h1 {
font-size: var(--small-font-size);
font-size: 18px;
margin: 0;
background-color: var(--nav-header-bg-color);
color: var(--nav-header-text-color);
@@ -61,15 +61,15 @@ header h1 {
}
nav {
font-size: var(--small-font-size);
overflow: hidden;
font-size: larger;
width: fit-content;
}
nav a, header h1, label[for="navtoggle"] {
text-align: left;
padding-left: 0.5em;
padding-right: 0.5em;
padding-left: 8px;
padding-right: 8px;
text-decoration: none;
vertical-align: middle;
height: 2em;
@@ -80,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
display: none;
}
@media screen and (width >= 601px){
@media screen and (width >= 600px){
header {
grid-template-columns: auto 1fr;
}
@@ -106,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
}
}
@media screen and (width <= 601px){
@media screen and (width <= 600px){
header {
grid-template-columns: 1fr auto;
}
+1 -30
View File
@@ -3,9 +3,6 @@
--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) {
@@ -44,35 +41,9 @@
}
}
*, h1, h2, h3, h4, h5, h6, p {
* {
box-sizing: border-box;
font-family: monospace;
line-height: normal;
margin: 0;
padding: 0;
}
h2 {
font-size: var(--large-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h3 {
font-size: var(--medium-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h4, legend {
font-size: var(--small-font-size);
text-decoration: underline;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
p {
font-size: var(--small-font-size);
}
html {
+41 -8
View File
@@ -34,25 +34,58 @@
</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.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>
<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>
</section>
{{range $poolname, $pool := .account.Pools}}
{{template "pool-resources" $pool}}
<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>
</main>
<template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Change Password
</p>
<div id="body">
@@ -64,8 +97,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
+2
View File
@@ -9,7 +9,9 @@
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
<main>
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
<section class="w3-card w3-padding">
+3 -1
View File
@@ -19,10 +19,12 @@
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
<main>
<section>
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
<h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
<form id="config-form">
<fieldset class="w3-card w3-padding">
<legend>Resources</legend>
+8 -6
View File
@@ -65,14 +65,16 @@
</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">
<button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button>
<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">
</form>
<!--Add Instance Button & Dialog Template-->
@@ -82,7 +84,7 @@
</button>
<template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Create New Instance
</p>
<div id="body">
@@ -92,14 +94,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>
@@ -120,8 +122,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
+2
View File
@@ -7,7 +7,9 @@
<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>
+9 -6
View File
@@ -5,25 +5,30 @@
<script src="scripts/settings.js" type="module"></script>
<link rel="modulepreload" href="scripts/utils.js">
<style>
legend {
margin-bottom: 25px;
}
label {
display: flex;
height: fit-content;
width: 100%;
align-items: center;
justify-content: left;
column-gap: 0.5em;
column-gap: 10px;
}
label + p {
margin-top: 0.5em;
margin-bottom: 1em;
margin-top: 5px;
margin-bottom: 25px;
}
p:last-child {
margin-bottom: 0;
margin-bottom: 0px;
}
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
<main>
<h2>Settings</h2>
<form id="settings">
@@ -37,8 +42,6 @@
<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 @@
../../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 -579
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+2 -2
View File
@@ -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
+3 -5
View File
@@ -2,10 +2,8 @@ import { getSyncSettings, requestAPI } from "./utils.js";
export async function setupClientSync (callback) {
const { scheme, rate } = getSyncSettings();
if (scheme === "never") {
return;
}
else if (scheme === "always") {
if (scheme === "always") {
window.setInterval(callback, rate * 1000);
}
else if (scheme === "hash") {
@@ -21,7 +19,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) => {
+8 -8
View File
@@ -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.data) {
for (const iso of isos) {
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.data) {
for (const availDevice of availDevices) {
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}/${type}/${vmid}/pci`, "GET");
for (const availDevice of availDevices.data) {
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
for (const availDevice of availDevices) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = true;
+5 -6
View File
@@ -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 () => {
@@ -48,8 +48,7 @@ export function alert (message) {
dialog.id = "alert-dialog";
dialog.innerHTML = `
<form method="dialog">
<p class="w3-large" id="prompt">Alert</p>
<p class="w3-center" style="margin-bottom: 0;">${message}</p>
<p class="w3-center" style="margin-bottom: 0px;">${message}</p>
<div class="w3-center">
<button class="w3-button w3-margin" id="submit">OK</button>
</div>
@@ -83,18 +82,18 @@ class ErrorDialog extends HTMLElement {
<link rel="stylesheet" href="css/form.css">
<style>
#errors {
margin-bottom: 0;
margin-bottom: 0px;
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
}
#errors * {
margin: 0;
margin: 0px;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt">Error</p>
<p class="w3-large" id="prompt" style="text-align: center;">Error</p>
<div id="errors" class="flex column-reverse"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
+1 -1
View File
@@ -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);
}
});
+45 -41
View File
@@ -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 () {
}
});
// setup type select
const templates = await requestAPI("/user/ct-templates", "GET");
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,62 +366,66 @@ async function handleInstanceAddButton () {
element.disabled = true;
});
// 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 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");
const nodeSelect = d.querySelector("#node");
nodeSelect.innerHTML = "";
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
const allowedNodes = Object.keys(pool["nodes-allowed"]);
clusterNodes.forEach((element) => {
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;
// 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")).data;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
rootfsStorage.innerHTML = "";
storage.forEach((element) => {
storage.data.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;
}
});
// setup root dir select
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
const rootfsContent = "rootdir";
// 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 templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector
const templates = await requestAPI("/user/ct-templates", "GET");
for (const template of templates.data) {
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
for (const template of templates) {
templateImage.append(new Option(template.name, template.volid));
}
templateImage.selectedIndex = -1;
-1
View File
@@ -4,7 +4,6 @@ window.addEventListener("DOMContentLoaded", init);
function init () {
setAppearance();
const { scheme, rate } = getSyncSettings();
if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true;
+12 -13
View File
@@ -80,34 +80,33 @@ async function request (url, content) {
try {
const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type");
const res = {};
let data = null;
if (contentType === null) {
res.data = null;
res.status = response.status;
data = {};
}
else if (contentType.includes("application/json")) {
res.data = await response.json();
res.status = response.status;
data = await response.json();
data.status = response.status;
}
else if (contentType.includes("text/html")) {
res.data = await response.text();
res.status = response.status;
data = { data: await response.text() };
data.status = response.status;
}
else if (contentType.includes("text/plain")) {
res.data = await response.text();
res.status = response.status;
data = { data: await response.text() };
data.status = response.status;
}
else {
res.data = null;
res.status = response.status;
data = {};
}
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 {
return res;
data.status = response.status;
return data || response;
}
}
catch (error) {
+14 -14
View File
@@ -10,8 +10,8 @@
a {
height: 1em;
width: 1em;
margin: 0;
padding: 0;
margin: 0px;
padding: 0px;
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
@@ -29,7 +29,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit Backup
</p>
<div id="body">
@@ -39,8 +39,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -49,7 +49,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete Backup
</p>
<div id="body">
@@ -60,8 +60,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -70,7 +70,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Restore From Backup?
</p>
<div id="body">
@@ -84,8 +84,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -103,7 +103,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Create Backup
</p>
<div id="body">
@@ -113,8 +113,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
+5 -9
View File
@@ -1,4 +1,3 @@
{{/* <head> common across all pages*/}}
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -15,13 +14,11 @@
<link rel="stylesheet" href="css/form.css">
{{end}}
{{/* <header> common across all pages*/}}
{{define "header"}}
<header>
<h1>{{.global.Organization}}</h1>
<label for="navtoggle">&#9776;</label>
<input type="checkbox" id="navtoggle">
<nav id="navigation">
<h1>{{.global.Organization}}</h1>
<label for="navtoggle">&#9776;</label>
<input type="checkbox" id="navtoggle">
<nav id="navigation">
{{if eq .page "login"}}
<a href="login" aria-current="page">Login</a>
{{else}}
@@ -30,6 +27,5 @@
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
<a href="login">Logout</a>
{{end}}
</nav>
</header>
</nav>
{{end}}
+52 -59
View File
@@ -50,7 +50,7 @@
</button>
<template id="add-disk-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Create New Disk
</p>
<div id="body">
@@ -65,8 +65,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -79,7 +79,7 @@
</button>
<template id="add-cd-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Mount a CDROM
</p>
<div id="body">
@@ -89,8 +89,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -163,7 +163,7 @@
<img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Move {{.Name}}
</p>
<div id="body">
@@ -173,8 +173,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -198,7 +198,7 @@
<img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Resize {{.Name}}
</p>
<div id="body">
@@ -208,8 +208,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -233,7 +233,7 @@
<img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.Name}}
</p>
<div id="body">
@@ -242,8 +242,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -267,7 +267,7 @@
<img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Attach {{.Name}}
</p>
<div id="body">
@@ -284,8 +284,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -300,7 +300,7 @@
<img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Detach {{.Name}}
</p>
<div id="body">
@@ -309,8 +309,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -340,7 +340,7 @@
</button>
<template id="add-net-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Create Network Interface
</p>
<div id="body">
@@ -353,8 +353,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -371,7 +371,7 @@
<img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit {{.Net_ID}}
</p>
<div id="body">
@@ -380,8 +380,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -393,7 +393,7 @@
<img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.Net_ID}}
</p>
<div id="body">
@@ -402,8 +402,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -425,7 +425,7 @@
</button>
<template id="add-device-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Add Expansion Card
</p>
<div id="body">
@@ -436,8 +436,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -448,13 +448,13 @@
<p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p>
<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">
<link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit Expansion Card {{.Device_ID}}
</p>
<div id="body">
@@ -463,20 +463,20 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template>
</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">
<link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
remove Expansion Card {{.Device_ID}}
</p>
<div id="body">
@@ -485,8 +485,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -516,17 +516,10 @@
div.draggable-item {
cursor: grab;
}
div.draggable-item img {
div.draggable-item svg {
height: 1em;
width: 1em;
}
div.draggable-item p {
margin: 0;
}
div.draggable-item p.volume-file {
overflow: hidden;
white-space: nowrap;
}
#wrapper {
padding-bottom: 1em;
}
@@ -543,18 +536,18 @@
{{define "boot-target"}}
{{if .volume_id}}
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img alt="Drag" src="images/actions/drag.svg#symb">
<img alt="Volume" src="images/resources/drive.svg#symb">
<p class="volume-id">{{.volume_id}}</p>
<p class="volume-file">{{.file}}</p>
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb">
<img style="height: 1em; width: 1em" alt="Volume" src="images/resources/drive.svg#symb">
<p style="margin: 0px;">{{.volume_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
</div>
{{else if .net_id}}
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img alt="Drag" src="images/actions/drag.svg#symb">
<img alt="Net" src="images/resources/network.svg#symb">
<p class="volume-id">{{.net_id}}</p>
<p class="volume-file">{{.value}}</p>
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb">
<img style="height: 1em; width: 1em" alt="Net" src="images/resources/network.svg#symb">
<p style="margin: 0px;">{{.net_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
</div>
{{else}}
{{end}}
+11 -11
View File
@@ -13,8 +13,8 @@
line-height: 1em;
height: 1em;
width: 1em;
margin: 0;
padding: 0;
margin: 0px;
padding: 0px;
}
a img {
vertical-align: unset;
@@ -30,7 +30,7 @@
}
.row { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-direction: row;
column-gap: 0.5em;
column-gap: 10px;
align-items: center;
}
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
@@ -43,7 +43,7 @@
.hide-large {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-medium {display:none !important}
.hide-small {display:none !important}
@@ -82,7 +82,7 @@
{{end}}
<p>{{.NodeStatus}}</p>
</div>
<div class="flex row nowrap">
<div class="flex row nowrap" style="height: 1lh;">
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
<img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb">
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
@@ -115,7 +115,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
{{if eq .Status "running"}}
Stop {{.VMID}}
{{else if eq .Status "stopped"}}
@@ -134,8 +134,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
@@ -144,7 +144,7 @@
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.VMID}}
</p>
<div id="body">
@@ -153,8 +153,8 @@
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
-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}}
+4 -3
View File
@@ -8,7 +8,8 @@
margin: 0;
width: 100%;
height: fit-content;
padding: 0.5em;
padding: 10px;
border-radius: 5px;
}
progress {
width: 100%;
@@ -18,7 +19,7 @@
}
#caption {
text-align: center;
margin-top: 0.5em;
margin-top: 10px;
display: flex;
flex-direction: column;
}
@@ -36,7 +37,7 @@
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
<label id="caption" for="resource">
<span>{{.Name}}</span>
<span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</label>
</div>
</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"}}
<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"}}
{{range .Options}}
{{if .Selected}}
<option value="{{.Value}}" selected>{{.Display}}</option>
{{else}}
<option value="{{.Value}}">{{.Display}}</option>
{{end}}
{{end}}
</select>
{{end}}