initial changes for API v2.0.0:

- added access manager api token to auth object
- update account page to show pool based resource quotas
- update config logic to use pool based resource quotas
- minor improvements and cleanup
This commit is contained in:
2026-05-26 20:28:21 +00:00
parent eb201de26b
commit c3fe936e05
21 changed files with 309 additions and 335 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ func Run(configPath *string) {
router.GET("/settings", routes.HandleGETSettings)
// run on all interfaces with port
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
log.Fatal("[Error] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
}
// setup static resources under web (css, images, modules, scripts)
+1
View File
@@ -51,6 +51,7 @@ type Auth struct {
Username string
Token string
CSRF string
AccessManagerTicket string
}
type Icon struct {
+22 -10
View File
@@ -24,12 +24,12 @@ import (
func GetConfig(configPath string) Config {
content, err := os.ReadFile(configPath)
if err != nil {
log.Fatal("Error when opening config file: ", err)
log.Fatal("[Error] when opening config file: ", err)
}
var config Config
err = json.Unmarshal(content, &config)
if err != nil {
log.Fatal("Error during parsing config file: ", err)
log.Fatal("[Error] during parsing config file: ", err)
}
return config
}
@@ -54,7 +54,7 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
if !entry.IsDir() {
v, err := files.ReadFile(path)
if err != nil {
log.Fatalf("error parsing template file %s: %s", path, err.Error())
log.Fatalf("[Error] parsing template file %s: %s", path, err.Error())
}
x := strings.Split(entry.Name(), ".")
if len(x) >= 2 { // file has extension
@@ -62,7 +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("error minifying file %s: %s", path, err.Error())
log.Fatalf("[Error] minifying file %s: %s", path, err.Error())
}
minified[path] = StaticFile{
Data: min,
@@ -185,7 +185,6 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
if err != nil {
return nil, response.StatusCode, err
}
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
case *map[string]any:
err = json.Unmarshal(data, &body)
@@ -208,10 +207,11 @@ func GetAuth(c *gin.Context) (Auth, error) {
username, errUsername := c.Cookie("username")
token, errToken := c.Cookie("PVEAuthCookie")
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
access, errAccess := c.Cookie("PAASAccessManagerTicket")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
} else {
return Auth{username, token, csrf}, nil
return Auth{username, token, csrf, access}, nil
}
}
@@ -239,13 +239,25 @@ func FormatNumber(val int64, base int64) (float64, string) {
steps++
}
if base == 1000 {
switch base {
case 1000:
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
case 1024:
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
default:
return 0, ""
}
}
func GetRequestContextFromCookies(auth Auth) RequestContext {
return RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
},
}
}
+56 -64
View File
@@ -3,6 +3,7 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color"
@@ -12,13 +13,7 @@ import (
type Account struct {
Username string
Pools map[string]bool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
Pools map[string]paas.Pool
}
// numerical constraint
@@ -103,19 +98,22 @@ var Green = color.RGB{
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
account, err := GetUserAccount(auth)
pools, err := GetUserPools(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
// for each resource category, create a resource chart
for category, resources := range account.Resources {
for resource, v := range resources {
for poolname, pool := range pools {
// for each resource category
for category := range pool.Resources {
// for each resource in each category
for resource, v := range pool.Resources[category].(map[string]any) {
// create a resource chart for resource depending on resource type
switch t := v.(type) {
case NumericResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[category][resource] = ResourceChart{
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
@@ -128,7 +126,7 @@ func HandleGETAccount(c *gin.Context) {
}
case StorageResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[category][resource] = ResourceChart{
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
@@ -162,7 +160,8 @@ func HandleGETAccount(c *gin.Context) {
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
account.Resources[category][resource] = l
pools[poolname].Resources[category].(map[string]any)[resource] = l
}
}
}
}
@@ -170,104 +169,97 @@ func HandleGETAccount(c *gin.Context) {
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"account": account,
"account": map[string]any{
"Username": auth.Username,
"Pools": pools,
},
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func GetUserAccount(auth common.Auth) (Account, error) {
account := Account{
Resources: map[string]map[string]any{},
}
func GetUserPools(auth common.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
// get user account basic data
// get all pools
ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body)
res, code, err := common.RequestGetAPI("/access/pools", ctx, &body)
if err != nil {
return account, err
return pools, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body, &account)
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
if err != nil {
return account, err
} else {
account.Username = auth.Username
return pools, err
}
body = map[string]any{}
// get user resources
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
}
resources := body
// get global config for resource type metadata
body = map[string]any{}
// get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil {
return account, err
return pools, err
}
if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
}
meta := body["resources"].(map[string]any)
// build each resource by its meta type
for k, v := range meta {
m := v.(map[string]any)
// for each pool
for poolname, pool := range pools {
// for each resource in pool data
for k, v := range pool.Resources {
m := meta[k].(map[string]any)
t := m["type"].(string)
r := resources[k].(map[string]any)
r := v.(map[string]any)
category := m["category"].(string)
if _, ok := account.Resources[category]; !ok {
account.Resources[category] = map[string]any{}
// create a category if it does not already exist
if _, ok := pool.Resources[category]; !ok {
pool.Resources[category] = map[string]any{}
}
if t == "numeric" {
// depending on type, decode the pool data into the corresponding resource type
switch t {
case "numeric":
n := NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
} else if t == "storage" {
pools[poolname].Resources[category].(map[string]any)[k] = n
case "storage":
n := StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
} else if t == "list" {
pools[poolname].Resources[category].(map[string]any)[k] = n
case "list":
n := ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
pools[poolname].Resources[category].(map[string]any)[k] = n
}
// delete the old entry, only categories should be left at the end of the loop
delete(pools[poolname].Resources, k)
}
}
return account, nil
return pools, nil
}
// interpolate between min and max by normalized (0 - 1) val
+1 -10
View File
@@ -2,7 +2,6 @@ package routes
import (
"fmt"
"log"
"net/http"
"proxmoxaas-dashboard/app/common"
"time"
@@ -39,8 +38,6 @@ func HandleGETBackups(c *gin.Context) {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
log.Printf("%+v", backups)
c.HTML(http.StatusOK, "html/backups.html", gin.H{
"global": common.Global,
"page": "backups",
@@ -79,13 +76,7 @@ func HandleGETBackupsFragment(c *gin.Context) {
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
ctx := common.GetRequestContextFromCookies(auth)
body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
+22 -47
View File
@@ -15,16 +15,7 @@ import (
// imported types from fabric
type InstanceConfig struct {
Type paas.InstanceType `json:"type"`
Name string `json:"name"`
CPU string `json:"cpu"`
Cores uint64 `json:"cores"`
Memory uint64 `json:"memory"`
Swap uint64 `json:"swap"`
Volumes map[string]*paas.Volume `json:"volumes"`
Nets map[string]*paas.Net `json:"nets"`
Devices map[string]*paas.Device `json:"devices"`
Boot paas.BootOrder `json:"boot"`
paas.Instance `mapstructure:",squash"`
// overrides
ProctypeSelect common.Select
}
@@ -35,17 +26,13 @@ type GlobalConfig struct {
}
}
type UserConfigResources struct {
type PoolConfig struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
Global []paas.MatchLimit
Nodes map[string][]paas.MatchLimit
}
}
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
@@ -61,13 +48,13 @@ func HandleGETConfig(c *gin.Context) {
}
if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.CPU {
if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true
}
}
@@ -181,13 +168,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
@@ -208,20 +189,14 @@ func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, erro
return config, nil
}
func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select, error) {
cputypes := common.Select{
ID: "proctype",
Required: true,
}
// get global resource config
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx, &body)
@@ -231,15 +206,15 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
global := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &global)
globalConfig := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &globalConfig)
if err != nil {
return cputypes, err
}
// get user resource config
// get pool resource config
body = map[string]any{}
path = "/user/config/resources"
path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil {
return cputypes, err
@@ -247,21 +222,21 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
user := UserConfigResources{}
err = mapstructure.Decode(body, &user)
poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
if err != nil {
return cputypes, err
}
// use node specific rules if present, otherwise use global rules
var userCPU []CPUConfig
if _, ok := user.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node]
var userCPU []paas.MatchLimit
if _, ok := poolCPUConfig.CPU.Nodes[vm.Node]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.Node]
} else {
userCPU = user.CPU.Global
userCPU = poolCPUConfig.CPU.Global
}
if global.CPU.Whitelist { // cpu is a whitelist
if globalConfig.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
@@ -280,7 +255,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []CPUConfig
data []paas.MatchLimit
}{}
err = mapstructure.Decode(body, supported)
if err != nil {
@@ -289,7 +264,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
return c.Name == cpu.Name
})
if !contains {
+16 -18
View File
@@ -83,13 +83,8 @@ func HandleGETInstancesFragment(c *gin.Context) {
}
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
ctx := common.RequestContext{
Cookies: map[string]string{
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{}
ctx := common.GetRequestContextFromCookies(auth)
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
if err != nil {
return nil, nil, err
@@ -102,16 +97,17 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
nodes := map[string]Node{}
// parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range body["data"].([]any) {
for _, v := range body {
m := v.(map[string]any)
if m["type"] == "node" { // if type is node -> parse as Node object
switch m["type"] {
case "node": // if type is node -> parse as Node object
node := Node{}
err := mapstructure.Decode(v, &node)
if err != nil {
return nil, nil, err
}
nodes[node.Node] = node
} else if m["type"] == "lxc" || m["type"] == "qemu" { // if type is lxc or qemu -> parse as InstanceCard object
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
instance := InstanceCard{}
err := mapstructure.Decode(v, &instance)
if err != nil {
@@ -127,9 +123,10 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
// set instance's config link path
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// set the instance's console link path
if instance.Type == "qemu" {
switch instance.Type {
case "qemu":
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
} else if instance.Type == "lxc" {
case "lxc":
instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
}
// set the instance's backups link path
@@ -138,7 +135,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
instances[vmid] = instance
}
body = map[string]any{}
body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
if err != nil {
return nil, nil, err
@@ -151,7 +148,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
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["data"].([]any) {
for _, v := range body {
// parse task as Task object
task := Task{}
err := mapstructure.Decode(v, &task)
@@ -180,9 +177,10 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
} else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task
if task.Type == "qmstart" || task.Type == "vzstart" { // if the task was a start task, update the expected state to running
switch task.Type {
case "qmstart", "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
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
expected_state[task.VMID] = "stopped"
}
}
@@ -195,7 +193,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
// get /status/current which is updated faster than /cluster/resources
instance := instances[vmid]
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
body = map[string]any{}
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return nil, nil, err
@@ -205,7 +203,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
}
status := InstanceStatus{}
mapstructure.Decode(body["data"], &status)
mapstructure.Decode(body, &status)
instance.Status = status.Status
instances[vmid] = instance
+2 -2
View File
@@ -27,7 +27,7 @@ func GetLoginRealms() ([]Realm, error) {
ctx := common.RequestContext{
Cookies: nil,
}
body := map[string]any{}
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil {
return realms, err
@@ -36,7 +36,7 @@ func GetLoginRealms() ([]Realm, error) {
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
}
for _, v := range body["data"].([]any) {
for _, v := range body {
v = v.(map[string]any)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
+2 -29
View File
@@ -42,9 +42,6 @@
<section class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username: {{.account.Username}}</p>
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
</section>
<section class="w3-card w3-padding">
<div class="flex row nowrap">
@@ -52,33 +49,9 @@
<button class="w3-button w3-margin" id="change-password" type="button">Change Password</button>
</div>
</section>
<section class="w3-card w3-padding">
<h3>Cluster Resources</h3>
<div>
{{range $category, $v := .account.Resources}}
{{if ne $category ""}}
<h4>{{$category}}</h4>
{{range $poolname, $pool := .account.Pools}}
{{template "pool-resources" $pool}}
{{end}}
<div class="resource-container">
{{range $v}}
{{if .Display}}
{{if eq .Type "numeric"}}
{{template "resource-chart" .}}
{{end}}
{{if eq .Type "storage"}}
{{template "resource-chart" .}}
{{end}}
{{if eq .Type "list"}}
{{range .Resources}}
{{template "resource-chart" .}}
{{end}}
{{end}}
{{end}}
{{end}}
</div>
{{end}}
</div>
</section>
</main>
<template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css">
+2 -2
View File
@@ -94,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>
+1
View File
@@ -23,6 +23,7 @@
<div class="w3-center">
<button class="w3-button w3-margin" id="submit" type="submit">LOGIN</button>
</div>
<p>Notice: There is a known regression in login time. Please be patient.</p>
</form>
</div>
</main>
+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
+1 -1
View File
@@ -19,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) {
for (const iso of isos.data) {
select.add(new Option(iso.name, iso.volid));
}
select.selectedIndex = -1;
@@ -275,7 +275,7 @@ class NetworkAction extends HTMLElement {
async handleNetworkDelete () {
const netID = this.dataset.network;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `${netID}`;
@@ -375,7 +375,7 @@ class DeviceAction extends HTMLElement {
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
for (const availDevice of availDevices) {
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
@@ -383,7 +383,7 @@ class DeviceAction extends HTMLElement {
async handleDeviceDelete () {
const deviceID = this.dataset.device;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const device = `${deviceID}`;
@@ -437,8 +437,8 @@ async function handleDeviceAdd () {
}
});
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
for (const availDevice of availDevices) {
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = true;
+1 -1
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 () => {
+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);
}
});
+41 -45
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 () {
}
});
const templates = await requestAPI("/user/ct-templates", "GET");
// setup type select
const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1;
// on type change, reveal or hide the container specific section
typeSelect.addEventListener("change", () => {
if (typeSelect.value === "qemu") {
d.querySelectorAll(".container-specific").forEach((element) => {
@@ -366,66 +366,62 @@ async function handleInstanceAddButton () {
element.disabled = true;
});
const rootfsContent = "rootdir";
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
const userResources = await requestAPI("/user/dynamic/resources", "GET");
const userCluster = await requestAPI("/user/config/cluster", "GET");
// setup pool select
const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
// add user pools to selector
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
poolSelect.addEventListener("change", async () => {
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
const nodeSelect = d.querySelector("#node");
nodeSelect.innerHTML = "";
const clusterNodes = await requestPVE("/nodes", "GET");
const allowedNodes = Object.keys(userCluster.nodes);
clusterNodes.data.forEach((element) => {
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
const allowedNodes = Object.keys(pool["nodes-allowed"]);
clusterNodes.forEach((element) => {
if (element.status === "online" && allowedNodes.includes(element.node)) {
nodeSelect.add(new Option(element.node));
}
});
nodeSelect.selectedIndex = -1;
// set vmid min/max
d.querySelector("#vmid").min = pool["vmid-allowed"].min;
d.querySelector("#vmid").max = pool["vmid-allowed"].max;
});
// setup node select
const nodeSelect = d.querySelector("#node");
nodeSelect.selectedIndex = -1;
// on node change, get the available storages and repopulate the storage selector
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
rootfsStorage.innerHTML = "";
storage.data.forEach((element) => {
storage.forEach((element) => {
if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage));
}
});
rootfsStorage.selectedIndex = -1;
// set core and memory min/max depending on node selected
if (node in userResources.cores.nodes) {
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
}
else {
d.querySelector("#cores").max = userResources.cores.global.avail;
}
if (node in userResources.memory.nodes) {
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
}
else {
d.querySelector("#memory").max = userResources.memory.global.avail;
}
});
// set vmid min/max
d.querySelector("#vmid").min = userCluster.vmid.min;
d.querySelector("#vmid").max = userCluster.vmid.max;
// add user pools to selector
const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// setup root dir select
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
const rootfsContent = "rootdir";
// setup templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
for (const template of templates) {
const templates = await requestAPI("/user/ct-templates", "GET");
for (const template of templates.data) {
templateImage.append(new Option(template.name, template.volid));
}
templateImage.selectedIndex = -1;
+13 -12
View File
@@ -80,33 +80,34 @@ async function request (url, content) {
try {
const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type");
let data = null;
const res = {};
if (contentType === null) {
data = {};
res.data = null;
res.status = response.status;
}
else if (contentType.includes("application/json")) {
data = await response.json();
data.status = response.status;
res.data = await response.json();
res.status = response.status;
}
else if (contentType.includes("text/html")) {
data = { data: await response.text() };
data.status = response.status;
res.data = await response.text();
res.status = response.status;
}
else if (contentType.includes("text/plain")) {
data = { data: await response.text() };
data.status = response.status;
res.data = await response.text();
res.status = response.status;
}
else {
data = {};
res.data = null;
res.status = response.status;
}
if (!response.ok) {
return { status: response.status, error: data ? data.error : response.status };
return { status: response.status, error: res.data ? res.data.error : response.status };
}
else {
data.status = response.status;
return data || response;
return res;
}
}
catch (error) {
+2 -2
View File
@@ -448,7 +448,7 @@
<p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p>
<div>
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
@@ -470,7 +470,7 @@
</template>
</template>
</device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
+34
View File
@@ -0,0 +1,34 @@
{{define "pool-resources"}}
<section class="w3-card w3-padding">
<h3>Pool: {{.PoolID}}</h3>
<p id="vmid">VMID Range: {{.AllowedVMIDRange.Min}} - {{.AllowedVMIDRange.Max}}</p>
<p id="nodes">Nodes: {{MapKeys .AllowedNodes ", "}}</p>
<p id="backups">Max Backups Per Instance: {{.AllowedBackups.MaxPerInstance}} Max Backups Total: {{.AllowedBackups.MaxTotal}}</p>
<div>
{{range $category, $v := .Resources}}
{{if eq $category ""}}
<h4>Generic</h4>
{{else}}
<h4>{{$category}}</h4>
{{end}}
<div class="resource-container">
{{range $v}}
{{if .Display}}
{{if eq .Type "numeric"}}
{{template "resource-chart" .}}
{{end}}
{{if eq .Type "storage"}}
{{template "resource-chart" .}}
{{end}}
{{if eq .Type "list"}}
{{range .Resources}}
{{template "resource-chart" .}}
{{end}}
{{end}}
{{end}}
{{end}}
</div>
{{end}}
</div>
</section>
{{end}}