Compare commits
10 Commits
instance-p
...
478ca20451
Author | SHA1 | Date | |
---|---|---|---|
478ca20451 | |||
28c60aecc9 | |||
3d677a46ee | |||
e170d7f93d | |||
85bd81ef30 | |||
53832b67a2 | |||
e6cd1fbb3d | |||
3f21f3c4a4 | |||
1bcbed6828 | |||
31bfa79e66 |
@@ -6,81 +6,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"proxmoxaas-dashboard/app/common"
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
|
||||||
|
"github.com/gerow/go-color"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleGETAccount(c *gin.Context) {
|
|
||||||
auth, err := common.GetAuth(c)
|
|
||||||
if err == nil {
|
|
||||||
account, err := GetUserAccount(auth)
|
|
||||||
if err != nil {
|
|
||||||
common.HandleNonFatalError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range account.Resources {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case NumericResource:
|
|
||||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
|
||||||
account.Resources[k] = ResourceChart{
|
|
||||||
Type: t.Type,
|
|
||||||
Display: t.Display,
|
|
||||||
Name: t.Name,
|
|
||||||
Used: t.Total.Used,
|
|
||||||
Max: t.Total.Max,
|
|
||||||
Avail: avail,
|
|
||||||
Prefix: prefix,
|
|
||||||
Unit: t.Unit,
|
|
||||||
}
|
|
||||||
case StorageResource:
|
|
||||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
|
||||||
account.Resources[k] = ResourceChart{
|
|
||||||
Type: t.Type,
|
|
||||||
Display: t.Display,
|
|
||||||
Name: t.Name,
|
|
||||||
Used: t.Total.Used,
|
|
||||||
Max: t.Total.Max,
|
|
||||||
Avail: avail,
|
|
||||||
Prefix: prefix,
|
|
||||||
Unit: t.Unit,
|
|
||||||
}
|
|
||||||
case ListResource:
|
|
||||||
l := struct {
|
|
||||||
Type string
|
|
||||||
Display bool
|
|
||||||
Resources []ResourceChart
|
|
||||||
}{
|
|
||||||
Type: t.Type,
|
|
||||||
Display: t.Display,
|
|
||||||
Resources: []ResourceChart{},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, r := range t.Total {
|
|
||||||
l.Resources = append(l.Resources, ResourceChart{
|
|
||||||
Type: t.Type,
|
|
||||||
Display: t.Display,
|
|
||||||
Name: r.Name,
|
|
||||||
Used: r.Used,
|
|
||||||
Max: r.Max,
|
|
||||||
Avail: float64(r.Avail), // usually an int
|
|
||||||
Unit: "",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
account.Resources[k] = l
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
|
||||||
"global": common.Global,
|
|
||||||
"page": "account",
|
|
||||||
"account": account,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
Username string
|
Username string
|
||||||
Pools map[string]bool
|
Pools map[string]bool
|
||||||
@@ -143,14 +73,101 @@ type ListResource struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ResourceChart struct {
|
type ResourceChart struct {
|
||||||
Type string
|
Type string
|
||||||
Display bool
|
Display bool
|
||||||
Name string
|
Name string
|
||||||
Used int64
|
Used int64
|
||||||
Max int64
|
Max int64
|
||||||
Avail float64
|
Avail float64
|
||||||
Prefix string
|
Prefix string
|
||||||
Unit string
|
Unit string
|
||||||
|
ColorHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Red = color.RGB{
|
||||||
|
R: 1,
|
||||||
|
G: 0,
|
||||||
|
B: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
var Green = color.RGB{
|
||||||
|
R: 0,
|
||||||
|
G: 1,
|
||||||
|
B: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETAccount(c *gin.Context) {
|
||||||
|
auth, err := common.GetAuth(c)
|
||||||
|
if err == nil {
|
||||||
|
account, err := GetUserAccount(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range account.Resources {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case NumericResource:
|
||||||
|
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||||
|
account.Resources[k] = ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: t.Name,
|
||||||
|
Used: t.Total.Used,
|
||||||
|
Max: t.Total.Max,
|
||||||
|
Avail: avail,
|
||||||
|
Prefix: prefix,
|
||||||
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
|
}
|
||||||
|
case StorageResource:
|
||||||
|
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||||
|
account.Resources[k] = ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: t.Name,
|
||||||
|
Used: t.Total.Used,
|
||||||
|
Max: t.Total.Max,
|
||||||
|
Avail: avail,
|
||||||
|
Prefix: prefix,
|
||||||
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
|
}
|
||||||
|
case ListResource:
|
||||||
|
l := struct {
|
||||||
|
Type string
|
||||||
|
Display bool
|
||||||
|
Resources []ResourceChart
|
||||||
|
}{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Resources: []ResourceChart{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range t.Total {
|
||||||
|
l.Resources = append(l.Resources, ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: r.Name,
|
||||||
|
Used: r.Used,
|
||||||
|
Max: r.Max,
|
||||||
|
Avail: float64(r.Avail), // usually an int
|
||||||
|
Unit: "",
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
account.Resources[k] = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "account",
|
||||||
|
"account": account,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetUserAccount(auth common.Auth) (Account, error) {
|
func GetUserAccount(auth common.Auth) (Account, error) {
|
||||||
@@ -261,3 +278,15 @@ func FormatNumber(val int64, base int64) (float64, string) {
|
|||||||
return 0, ""
|
return 0, ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// interpolate between min and max by normalized (0 - 1) val
|
||||||
|
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
|
||||||
|
minhsl := min.ToHSL()
|
||||||
|
maxhsl := max.ToHSL()
|
||||||
|
interphsl := color.HSL{
|
||||||
|
H: (1-val)*minhsl.H + (val)*maxhsl.H,
|
||||||
|
S: (1-val)*minhsl.S + (val)*maxhsl.S,
|
||||||
|
L: (1-val)*minhsl.L + (val)*maxhsl.L,
|
||||||
|
}
|
||||||
|
return interphsl.ToRGB()
|
||||||
|
}
|
||||||
|
@@ -13,6 +13,46 @@ import (
|
|||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type VMPath struct {
|
||||||
|
Node string
|
||||||
|
Type string
|
||||||
|
VMID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imported types from fabric
|
||||||
|
|
||||||
|
type InstanceConfig struct {
|
||||||
|
Type fabric.InstanceType `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Proctype string `json:"cpu"`
|
||||||
|
Cores uint64 `json:"cores"`
|
||||||
|
Memory uint64 `json:"memory"`
|
||||||
|
Swap uint64 `json:"swap"`
|
||||||
|
Volumes map[string]*fabric.Volume `json:"volumes"`
|
||||||
|
Nets map[string]*fabric.Net `json:"nets"`
|
||||||
|
Devices map[string]*fabric.Device `json:"devices"`
|
||||||
|
Boot fabric.BootOrder `json:"boot"`
|
||||||
|
// overrides
|
||||||
|
ProctypeSelect common.Select
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalConfig struct {
|
||||||
|
CPU struct {
|
||||||
|
Whitelist bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserConfigResources struct {
|
||||||
|
CPU struct {
|
||||||
|
Global []CPUConfig
|
||||||
|
Nodes map[string][]CPUConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CPUConfig struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
func HandleGETConfig(c *gin.Context) {
|
func HandleGETConfig(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuth(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -62,7 +102,7 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/plain")
|
c.Header("Content-Type", "text/plain")
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{
|
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -85,7 +125,7 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/plain")
|
c.Header("Content-Type", "text/plain")
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{
|
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -108,7 +148,7 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/plain")
|
c.Header("Content-Type", "text/plain")
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{
|
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -131,7 +171,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Header("Content-Type", "text/plain")
|
c.Header("Content-Type", "text/plain")
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{
|
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -155,29 +195,6 @@ func ExtractVMPath(c *gin.Context) (VMPath, error) {
|
|||||||
return vm_path, nil
|
return vm_path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type VMPath struct {
|
|
||||||
Node string
|
|
||||||
Type string
|
|
||||||
VMID string
|
|
||||||
}
|
|
||||||
|
|
||||||
// imported types from fabric
|
|
||||||
|
|
||||||
type InstanceConfig struct {
|
|
||||||
Type fabric.InstanceType `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Proctype string `json:"cpu"`
|
|
||||||
Cores uint64 `json:"cores"`
|
|
||||||
Memory uint64 `json:"memory"`
|
|
||||||
Swap uint64 `json:"swap"`
|
|
||||||
Volumes map[string]*fabric.Volume `json:"volumes"`
|
|
||||||
Nets map[string]*fabric.Net `json:"nets"`
|
|
||||||
Devices map[string]*fabric.Device `json:"devices"`
|
|
||||||
Boot fabric.BootOrder `json:"boot"`
|
|
||||||
// overrides
|
|
||||||
ProctypeSelect common.Select
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
||||||
config := InstanceConfig{}
|
config := InstanceConfig{}
|
||||||
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
||||||
@@ -208,23 +225,6 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type GlobalConfig struct {
|
|
||||||
CPU struct {
|
|
||||||
Whitelist bool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserConfig struct {
|
|
||||||
CPU struct {
|
|
||||||
Global []CPUConfig
|
|
||||||
Nodes map[string][]CPUConfig
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type CPUConfig struct {
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
||||||
cputypes := common.Select{
|
cputypes := common.Select{
|
||||||
ID: "proctype",
|
ID: "proctype",
|
||||||
@@ -264,7 +264,7 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
|||||||
if code != 200 {
|
if code != 200 {
|
||||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
}
|
}
|
||||||
user := UserConfig{}
|
user := UserConfigResources{}
|
||||||
err = mapstructure.Decode(ctx.Body, &user)
|
err = mapstructure.Decode(ctx.Body, &user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
|
@@ -10,6 +10,39 @@ import (
|
|||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// used in constructing instance cards in index
|
||||||
|
type Node struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in constructing instance cards in index
|
||||||
|
type InstanceCard struct {
|
||||||
|
VMID uint
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Node string
|
||||||
|
NodeStatus string
|
||||||
|
ConfigPath string
|
||||||
|
ConsolePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in retriving cluster tasks
|
||||||
|
type Task struct {
|
||||||
|
Type string
|
||||||
|
Node string
|
||||||
|
User string
|
||||||
|
ID string
|
||||||
|
VMID uint
|
||||||
|
Status string
|
||||||
|
EndTime uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceStatus struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
func HandleGETIndex(c *gin.Context) {
|
func HandleGETIndex(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuth(c)
|
||||||
if err == nil { // user should be authed, try to return index with population
|
if err == nil { // user should be authed, try to return index with population
|
||||||
@@ -17,11 +50,12 @@ func HandleGETIndex(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusOK, "html/index.html", gin.H{
|
page := gin.H{
|
||||||
"global": common.Global,
|
"global": common.Global,
|
||||||
"page": "index",
|
"page": "index",
|
||||||
"instances": instances,
|
"instances": instances,
|
||||||
})
|
}
|
||||||
|
c.HTML(http.StatusOK, "html/index.html", page)
|
||||||
} else { // return index without populating
|
} else { // return index without populating
|
||||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||||
}
|
}
|
||||||
@@ -35,7 +69,7 @@ func HandleGETInstancesFragment(c *gin.Context) {
|
|||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
}
|
}
|
||||||
c.Header("Content-Type", "text/plain")
|
c.Header("Content-Type", "text/plain")
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.frag", gin.H{
|
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
|
||||||
"instances": instances,
|
"instances": instances,
|
||||||
})
|
})
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
@@ -45,36 +79,6 @@ func HandleGETInstancesFragment(c *gin.Context) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// used in constructing instance cards in index
|
|
||||||
type Node struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// used in constructing instance cards in index
|
|
||||||
type InstanceCard struct {
|
|
||||||
VMID uint
|
|
||||||
Name string
|
|
||||||
Type string
|
|
||||||
Status string
|
|
||||||
Node string
|
|
||||||
NodeStatus string
|
|
||||||
}
|
|
||||||
|
|
||||||
// used in retriving cluster tasks
|
|
||||||
type Task struct {
|
|
||||||
Type string
|
|
||||||
Node string
|
|
||||||
User string
|
|
||||||
ID string
|
|
||||||
VMID uint
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
type InstanceStatus struct {
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
||||||
ctx := common.RequestContext{
|
ctx := common.RequestContext{
|
||||||
Cookies: map[string]string{
|
Cookies: map[string]string{
|
||||||
@@ -116,6 +120,12 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
for vmid, instance := range instances {
|
for vmid, instance := range instances {
|
||||||
nodestatus := nodes[instance.Node].Status
|
nodestatus := nodes[instance.Node].Status
|
||||||
instance.NodeStatus = nodestatus
|
instance.NodeStatus = nodestatus
|
||||||
|
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||||
|
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)
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
instances[vmid] = instance
|
instances[vmid] = instance
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +138,9 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
|
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
most_recent_task := map[uint]uint{}
|
||||||
|
expected_state := map[uint]string{}
|
||||||
|
|
||||||
for _, v := range ctx.Body["data"].([]any) {
|
for _, v := range ctx.Body["data"].([]any) {
|
||||||
task := Task{}
|
task := Task{}
|
||||||
err := mapstructure.Decode(v, &task)
|
err := mapstructure.Decode(v, &task)
|
||||||
@@ -151,8 +164,21 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
|
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
|
||||||
continue
|
continue
|
||||||
} else { // recent task is a start or stop task for user instance which is running or "OK"
|
} else { // recent task is a start or stop task for user instance which is running or "OK"
|
||||||
|
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
|
||||||
|
most_recent_task[task.VMID] = task.EndTime // update the most recent task
|
||||||
|
if task.Type == "qmstart" || task.Type == "vzstart" { // if the task was a start task, update the expected state to running
|
||||||
|
expected_state[task.VMID] = "running"
|
||||||
|
} else if task.Type == "qmstop" || task.Type == "vzstop" { // if the task was a stop task, update the expected state to stopped
|
||||||
|
expected_state[task.VMID] = "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// get /status/current which is updated faster than /cluster/resources
|
||||||
instance := instances[task.VMID]
|
instance := instances[vmid]
|
||||||
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
|
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
|
||||||
ctx.Body = map[string]any{}
|
ctx.Body = map[string]any{}
|
||||||
res, code, err := common.RequestGetAPI(path, ctx)
|
res, code, err := common.RequestGetAPI(path, ctx)
|
||||||
@@ -167,7 +193,7 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
mapstructure.Decode(ctx.Body["data"], &status)
|
mapstructure.Decode(ctx.Body["data"], &status)
|
||||||
|
|
||||||
instance.Status = status.Status
|
instance.Status = status.Status
|
||||||
instances[task.VMID] = instance
|
instances[vmid] = instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -9,6 +9,18 @@ import (
|
|||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// used when requesting GET /access/domains
|
||||||
|
type GetRealmsBody struct {
|
||||||
|
Data []Realm `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stores each realm's data
|
||||||
|
type Realm struct {
|
||||||
|
Default int `json:"default"`
|
||||||
|
Realm string `json:"realm"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
func GetLoginRealms() ([]Realm, error) {
|
func GetLoginRealms() ([]Realm, error) {
|
||||||
realms := []Realm{}
|
realms := []Realm{}
|
||||||
|
|
||||||
@@ -37,18 +49,6 @@ func GetLoginRealms() ([]Realm, error) {
|
|||||||
return realms, nil
|
return realms, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// used when requesting GET /access/domains
|
|
||||||
type GetRealmsBody struct {
|
|
||||||
Data []Realm `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// stores each realm's data
|
|
||||||
type Realm struct {
|
|
||||||
Default int `json:"default"`
|
|
||||||
Realm string `json:"realm"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleGETLogin(c *gin.Context) {
|
func HandleGETLogin(c *gin.Context) {
|
||||||
realms, err := GetLoginRealms()
|
realms, err := GetLoginRealms()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
26
go.mod
26
go.mod
@@ -3,9 +3,11 @@ module proxmoxaas-dashboard
|
|||||||
go 1.24
|
go 1.24
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||||
github.com/tdewolff/minify v2.3.6+incompatible
|
github.com/tdewolff/minify v2.3.6+incompatible
|
||||||
|
github.com/tdewolff/minify/v2 v2.23.5
|
||||||
proxmoxaas-fabric v0.0.0
|
proxmoxaas-fabric v0.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,17 +18,20 @@ require (
|
|||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/diskfs/go-diskfs v1.5.2 // indirect
|
github.com/diskfs/go-diskfs v1.6.0 // indirect
|
||||||
github.com/djherbis/times v1.6.0 // indirect
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/elliotwutingfeng/asciiset v0.0.0-20240214025120-24af97c84155 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/jinzhu/copier v0.4.0 // indirect
|
github.com/jinzhu/copier v0.4.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/luthermonson/go-proxmox v0.2.2 // indirect
|
github.com/luthermonson/go-proxmox v0.2.2 // indirect
|
||||||
@@ -35,17 +40,20 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/tdewolff/minify/v2 v2.23.1 // indirect
|
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
||||||
|
github.com/pkg/xattr v0.4.10 // indirect
|
||||||
|
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
|
||||||
github.com/tdewolff/parse v2.3.4+incompatible // indirect
|
github.com/tdewolff/parse v2.3.4+incompatible // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.7.23 // indirect
|
github.com/tdewolff/parse/v2 v2.8.0 // indirect
|
||||||
github.com/tdewolff/test v1.0.11 // indirect
|
github.com/tdewolff/test v1.0.11 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.16.0 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
golang.org/x/arch v0.17.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/crypto v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/net v0.40.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.25.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
@@ -77,6 +77,5 @@ input[type="radio"] {
|
|||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
max-width: calc(min(50%, 80ch));
|
max-width: calc(min(50%, 80ch));
|
||||||
background-color: var(--main-bg-color);
|
|
||||||
color: var(--main-text-color);
|
color: var(--main-text-color);
|
||||||
}
|
}
|
@@ -26,7 +26,7 @@
|
|||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
|
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
|
||||||
<form>
|
<form id="config-form">
|
||||||
<fieldset class="w3-card w3-padding">
|
<fieldset class="w3-card w3-padding">
|
||||||
<legend>Resources</legend>
|
<legend>Resources</legend>
|
||||||
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
|
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="w3-container w3-center" id="form-actions">
|
<div class="w3-container w3-center" id="form-actions">
|
||||||
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button>
|
<button class="w3-button w3-margin" id="exit" type="submit">EXIT</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
@@ -35,7 +35,7 @@
|
|||||||
<h2>Instances</h2>
|
<h2>Instances</h2>
|
||||||
<div class="w3-card w3-padding">
|
<div class="w3-card w3-padding">
|
||||||
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
||||||
<form id="vm-search" role="search" class="flex row nowrap">
|
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
|
||||||
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
|
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
|
||||||
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
|
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
|
||||||
</form>
|
</form>
|
||||||
@@ -64,4 +64,55 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
<modal-dialog id="create-instance-dialog">
|
||||||
|
<template shadowrootmode="open">
|
||||||
|
<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" style="text-align: center;">
|
||||||
|
Create New Instance
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="type">Instance Type</label>
|
||||||
|
<select class="w3-select w3-border" name="type" id="type" selected-index="-1" required>
|
||||||
|
<option value="lxc">Container</option>
|
||||||
|
<option value="qemu">Virtual Machine</option>
|
||||||
|
</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>
|
||||||
|
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16" step="1" required>
|
||||||
|
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
|
||||||
|
<label class="container-specific none" for="swap">Swap (MiB)</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
|
||||||
|
<label class="container-specific none" for="template-image">Template Image</label>
|
||||||
|
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
|
||||||
|
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
|
||||||
|
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
|
||||||
|
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
|
||||||
|
<label class="container-specific none" for="password">Password</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
|
||||||
|
<label class="container-specific none" for="confirm-password">Confirm Password</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<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>
|
||||||
|
</modal-dialog>
|
||||||
</html>
|
</html>
|
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -19,7 +19,7 @@ async function init () {
|
|||||||
initNetworks();
|
initNetworks();
|
||||||
initDevices();
|
initDevices();
|
||||||
|
|
||||||
document.querySelector("#exit").addEventListener("click", handleFormExit);
|
document.querySelector("#config-form").addEventListener("submit", handleFormExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
class VolumeAction extends HTMLElement {
|
class VolumeAction extends HTMLElement {
|
||||||
@@ -530,7 +530,8 @@ async function refreshBoot () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFormExit () {
|
async function handleFormExit (event) {
|
||||||
|
event.preventDefault();
|
||||||
const body = {
|
const body = {
|
||||||
cores: document.querySelector("#cores").value,
|
cores: document.querySelector("#cores").value,
|
||||||
memory: document.querySelector("#ram").value
|
memory: document.querySelector("#ram").value
|
||||||
|
@@ -1,3 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Custom modal dialog with form support. Assumes the following structure:
|
||||||
|
* <modal-dialog><template shadowrootmode="open">
|
||||||
|
* <p id="prompt"></p>
|
||||||
|
* <div id="body">
|
||||||
|
* <form id="form"> ... </form>
|
||||||
|
* </div>
|
||||||
|
* <div id="controls">
|
||||||
|
* <button value="..." form=""
|
||||||
|
* </div>
|
||||||
|
* </modal-dialog></template>
|
||||||
|
* Where prompt is the modal dialog's prompt or header,
|
||||||
|
* body contains an optional form or other information,
|
||||||
|
* and controls contains a series of buttons which controls the form
|
||||||
|
*/
|
||||||
|
class ModalDialog extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
dialog = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
// setup shadowDOM
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
// add dialog handler to each control button with the return value corresponding to their value attribute
|
||||||
|
const controls = this.shadowRoot.querySelector("#controls");
|
||||||
|
for (const button of controls.childNodes) {
|
||||||
|
button.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dialog.close(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.setOnClose(); // default behavior to just close the dialog, should call setOnClose to override this behavior
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal () {
|
||||||
|
this.dialog.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector (query) {
|
||||||
|
return this.shadowRoot.querySelector(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll (query) {
|
||||||
|
return this.shadowRoot.querySelectorAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnClose (callback = (result, form) => {}) {
|
||||||
|
this.dialog.addEventListener("close", () => {
|
||||||
|
const formElem = this.dialog.querySelector("form");
|
||||||
|
const formData = formElem ? new FormData(formElem) : null;
|
||||||
|
callback(this.dialog.returnValue, formData);
|
||||||
|
formElem.reset();
|
||||||
|
this.dialog.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("modal-dialog", ModalDialog);
|
||||||
|
|
||||||
export function dialog (header, body, onclose = async (result, form) => { }) {
|
export function dialog (header, body, onclose = async (result, form) => { }) {
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, requestDash, setSVGSrc, setSVGAlt } from "./utils.js";
|
import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setSVGSrc, setSVGAlt } from "./utils.js";
|
||||||
import { alert, dialog } from "./dialog.js";
|
import { alert, dialog } from "./dialog.js";
|
||||||
import { setupClientSync } from "./clientsync.js";
|
import { setupClientSync } from "./clientsync.js";
|
||||||
import wfaInit from "../modules/wfa.js";
|
import wfaInit from "../modules/wfa.js";
|
||||||
@@ -122,46 +122,47 @@ class InstanceCard extends HTMLElement {
|
|||||||
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
||||||
if (powerButton.classList.contains("clickable")) {
|
if (powerButton.classList.contains("clickable")) {
|
||||||
powerButton.onclick = this.handlePowerButton.bind(this);
|
powerButton.onclick = this.handlePowerButton.bind(this);
|
||||||
}
|
powerButton.onkeydown = (event) => {
|
||||||
|
console.log(event.key, event.key === "Enter");
|
||||||
const configButton = this.shadowRoot.querySelector("#configure-btn");
|
if (event.key === "Enter") {
|
||||||
if (configButton.classList.contains("clickable")) {
|
event.preventDefault();
|
||||||
configButton.onclick = this.handleConfigButton.bind(this);
|
this.handlePowerButton();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const consoleButton = this.shadowRoot.querySelector("#console-btn");
|
|
||||||
if (consoleButton.classList.contains("clickable")) {
|
|
||||||
consoleButton.classList.add("clickable");
|
|
||||||
consoleButton.onclick = this.handleConsoleButton.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||||
if (deleteButton.classList.contains("clickable")) {
|
if (deleteButton.classList.contains("clickable")) {
|
||||||
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||||
|
deleteButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleDeleteButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatusLoading() {
|
setStatusLoading () {
|
||||||
this.status = "loading"
|
this.status = "loading";
|
||||||
let statusicon = this.shadowRoot.querySelector("#status")
|
const statusicon = this.shadowRoot.querySelector("#status");
|
||||||
let powerbtn = this.shadowRoot.querySelector("#power-btn")
|
const powerbtn = this.shadowRoot.querySelector("#power-btn");
|
||||||
setSVGSrc(statusicon, "images/status/loading.svg")
|
setSVGSrc(statusicon, "images/status/loading.svg");
|
||||||
setSVGAlt(statusicon, "instance is loading")
|
setSVGAlt(statusicon, "instance is loading");
|
||||||
setSVGSrc(powerbtn, "images/status/loading.svg")
|
setSVGSrc(powerbtn, "images/status/loading.svg");
|
||||||
setSVGAlt(powerbtn, "")
|
setSVGAlt(powerbtn, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePowerButton () {
|
async handlePowerButton () {
|
||||||
if (!this.actionLock) {
|
if (!this.actionLock) {
|
||||||
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`;
|
const dialog = this.shadowRoot.querySelector("#power-dialog");
|
||||||
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`;
|
dialog.setOnClose(async (result, form) => {
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.actionLock = true;
|
this.actionLock = true;
|
||||||
const targetAction = this.status === "running" ? "stop" : "start";
|
const targetAction = this.status === "running" ? "stop" : "start";
|
||||||
|
|
||||||
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
|
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
|
||||||
this.setStatusLoading()
|
this.setStatusLoading();
|
||||||
|
|
||||||
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
|
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
|
||||||
@@ -183,27 +184,14 @@ class InstanceCard extends HTMLElement {
|
|||||||
refreshInstances();
|
refreshInstances();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
dialog.showModal();
|
||||||
}
|
|
||||||
|
|
||||||
handleConfigButton () {
|
|
||||||
if (!this.actionLock && this.status === "stopped") { // if the action lock is false, and the node is stopped, then navigate to the config page with the node info in the search query
|
|
||||||
goToPage("config", { node: this.node.name, type: this.type, vmid: this.vmid });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleConsoleButton () {
|
|
||||||
if (!this.actionLock && this.status === "running") {
|
|
||||||
const data = { console: `${this.type === "qemu" ? "kvm" : "lxc"}`, vmid: this.vmid, vmname: this.name, node: this.node.name, resize: "off", cmd: "" };
|
|
||||||
data[`${this.type === "qemu" ? "novnc" : "xtermjs"}`] = 1;
|
|
||||||
goToURL(window.PVE, data, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDeleteButton () {
|
handleDeleteButton () {
|
||||||
if (!this.actionLock && this.status === "stopped") {
|
if (!this.actionLock && this.status === "stopped") {
|
||||||
const header = `Delete VM ${this.vmid}`;
|
const header = `Delete ${this.vmid}`;
|
||||||
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`;
|
const body = `<p>Are you sure you want to <strong>delete</strong> ${this.vmid}</p>`;
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
dialog(header, body, async (result, form) => {
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
@@ -325,46 +313,9 @@ function sortInstances () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleInstanceAdd () {
|
async function handleInstanceAdd () {
|
||||||
const header = "Create New Instance";
|
const d = document.querySelector("#create-instance-dialog");
|
||||||
|
|
||||||
const body = `
|
d.setOnClose(async (result, form) => {
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="type">Instance Type</label>
|
|
||||||
<select class="w3-select w3-border" name="type" id="type" required>
|
|
||||||
<option value="lxc">Container</option>
|
|
||||||
<option value="qemu">Virtual Machine</option>
|
|
||||||
</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" 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>
|
|
||||||
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required>
|
|
||||||
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
|
|
||||||
<label class="container-specific none" for="swap">Swap (MiB)</label>
|
|
||||||
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
|
|
||||||
<label class="container-specific none" for="template-image">Template Image</label>
|
|
||||||
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
|
|
||||||
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
|
|
||||||
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
|
|
||||||
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
|
|
||||||
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
|
|
||||||
<label class="container-specific none" for="password">Password</label>
|
|
||||||
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
|
|
||||||
<label class="container-specific none" for="confirm-password">Confirm Password</label>
|
|
||||||
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const templates = await requestAPI("/user/ct-templates", "GET");
|
|
||||||
|
|
||||||
const d = dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const body = {
|
const body = {
|
||||||
name: form.get("name"),
|
name: form.get("name"),
|
||||||
@@ -393,6 +344,8 @@ async function handleInstanceAdd () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||||
|
|
||||||
const typeSelect = d.querySelector("#type");
|
const typeSelect = d.querySelector("#type");
|
||||||
typeSelect.selectedIndex = -1;
|
typeSelect.selectedIndex = -1;
|
||||||
typeSelect.addEventListener("change", () => {
|
typeSelect.addEventListener("change", () => {
|
||||||
@@ -409,6 +362,10 @@ async function handleInstanceAdd () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
|
element.classList.add("none");
|
||||||
|
element.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
const rootfsContent = "rootdir";
|
const rootfsContent = "rootdir";
|
||||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||||
@@ -480,4 +437,6 @@ async function handleInstanceAdd () {
|
|||||||
|
|
||||||
password.addEventListener("change", validatePassword);
|
password.addEventListener("change", validatePassword);
|
||||||
confirmPassword.addEventListener("keyup", validatePassword);
|
confirmPassword.addEventListener("keyup", validatePassword);
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
}
|
}
|
||||||
|
@@ -114,20 +114,6 @@ export function goToPage (page, data = null) {
|
|||||||
window.location.href = `${page}${data ? "?" : ""}${params}`;
|
window.location.href = `${page}${data ? "?" : ""}${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function goToURL (href, data = {}, newwindow = false) {
|
|
||||||
const url = new URL(href);
|
|
||||||
for (const k in data) {
|
|
||||||
url.searchParams.append(k, data[k]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newwindow) {
|
|
||||||
window.open(url, document.title, "height=480,width=848");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
window.location.assign(url.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getURIData () {
|
export function getURIData () {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
return Object.fromEntries(url.searchParams);
|
return Object.fromEntries(url.searchParams);
|
||||||
|
@@ -237,51 +237,42 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "boot"}}
|
{{define "boot"}}
|
||||||
<draggable-container id="enabled" data-group="boot">
|
{{template "boot-container" Map "ID" "enabled" "Name" "Enabled" "Targets" .Enabled}}
|
||||||
<template shadowrootmode="open">
|
|
||||||
{{template "boot-style"}}
|
|
||||||
<label>Enabled</label>
|
|
||||||
<div id="wrapper" style="padding-bottom: 1em;">
|
|
||||||
{{range .Enabled}}
|
|
||||||
{{template "boot-target" .}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable-container>
|
|
||||||
<hr style="padding: 0; margin: 0;">
|
<hr style="padding: 0; margin: 0;">
|
||||||
<draggable-container id="disabled" data-group="boot">
|
{{template "boot-container" Map "ID" "disabled" "Name" "Disabled" "Targets" .Disabled}}
|
||||||
<template shadowrootmode="open">
|
|
||||||
{{template "boot-style"}}
|
|
||||||
<label>Disabled</label>
|
|
||||||
<div id="wrapper" style="padding-bottom: 1em;">
|
|
||||||
{{range .Disabled}}
|
|
||||||
{{template "boot-target" .}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable-container>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "boot-style"}}
|
{{define "boot-container"}}
|
||||||
<style>
|
<draggable-container id="{{.ID}}" data-group="boot">
|
||||||
div.draggable-item.ghost {
|
<template shadowrootmode="open">
|
||||||
border: 1px dashed var(--main-text-color);
|
<style>
|
||||||
border-radius: 5px;
|
* {
|
||||||
margin: -1px;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
div.draggable-item {
|
div.draggable-item.ghost {
|
||||||
cursor: grab;
|
border: 1px dashed var(--main-text-color);
|
||||||
}
|
border-radius: 5px;
|
||||||
div.draggable-item svg {
|
margin: -1px;
|
||||||
height: 1em;
|
}
|
||||||
width: 1em;
|
div.draggable-item {
|
||||||
}
|
cursor: grab;
|
||||||
* {
|
}
|
||||||
-webkit-box-sizing: border-box;
|
div.draggable-item svg {
|
||||||
-moz-box-sizing: border-box;
|
height: 1em;
|
||||||
box-sizing: border-box;
|
width: 1em;
|
||||||
}
|
}
|
||||||
</style>
|
#wrapper {
|
||||||
|
padding-bottom: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<label>{{.Name}}</label>
|
||||||
|
<div id="wrapper">
|
||||||
|
{{range .Targets}}
|
||||||
|
{{template "boot-target" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable-container>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "boot-target"}}
|
{{define "boot-target"}}
|
||||||
|
@@ -39,24 +39,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
|
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
|
||||||
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
|
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
|
||||||
<svg id="power-btn" class="clickable" aria-label="shutdown instance"><use href="images/actions/instance/stop.svg#symb"></svg>
|
<svg id="power-btn" class="clickable" aria-label="shutdown instance" role="button" tabindex=0><use href="images/actions/instance/stop.svg#symb"></svg>
|
||||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
||||||
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
|
<a href="{{.ConsolePath}}" target="_blank">
|
||||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
|
||||||
|
</a>
|
||||||
|
<svg id="delete-btn" aria-disabled="true" role="none"><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
||||||
{{else if and (eq .NodeStatus "online") (eq .Status "stopped")}}
|
{{else if and (eq .NodeStatus "online") (eq .Status "stopped")}}
|
||||||
<svg id="power-btn" class="clickable" aria-label="start instance"><use href="images/actions/instance/start.svg#symb"></svg>
|
<svg id="power-btn" class="clickable" aria-label="start instance" role="button" tabindex=0><use href="images/actions/instance/start.svg#symb"></svg>
|
||||||
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
|
<a href="{{.ConfigPath}}">
|
||||||
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
|
||||||
<svg id="delete-btn" class="clickable" aria-label="delete instance"><use href="images/actions/instance/delete-active.svg#symb"></svg>
|
</a>
|
||||||
|
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
||||||
|
<svg id="delete-btn" class="clickable" aria-label="delete instance" role="button" tabindex=0><use href="images/actions/instance/delete-active.svg#symb"></svg>
|
||||||
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
|
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
|
||||||
<svg id="power-btn" aria-label=""><use href="images/actions/instance/loading.svg#symb"></svg>
|
<svg id="power-btn" aria-disabled="true" role="none"><use href="images/actions/instance/loading.svg#symb"></svg>
|
||||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
||||||
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
||||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
<svg id="delete-btn" aria-disabled="true" role="none"><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<modal-dialog id="power-dialog">
|
||||||
|
<template shadowrootmode="open">
|
||||||
|
<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" style="text-align: center;">
|
||||||
|
{{if eq .Status "running"}}
|
||||||
|
Stop {{.VMID}}
|
||||||
|
{{else if eq .Status "stopped"}}
|
||||||
|
Start {{.VMID}}
|
||||||
|
{{else}}
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>
|
||||||
|
{{if eq .Status "running"}}
|
||||||
|
Are you sure you want to <strong>stop</strong> {{.VMID}}?
|
||||||
|
{{else if eq .Status "stopped"}}
|
||||||
|
Are you sure you want to <strong>start</strong> {{.VMID}}?
|
||||||
|
{{else}}
|
||||||
|
{{end}}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<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>
|
||||||
|
</modal-dialog>
|
||||||
</template>
|
</template>
|
||||||
</instance-card>
|
</instance-card>
|
||||||
{{end}}
|
{{end}}
|
@@ -13,15 +13,13 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
padding: 10px 10px 10px 10px;
|
padding: 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
progress {
|
progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: 0;
|
||||||
height: 1em;
|
height: 1em;
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
#caption {
|
#caption {
|
||||||
@@ -30,14 +28,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
progress::-moz-progress-bar {
|
||||||
|
background: #{{.ColorHex}};
|
||||||
|
}
|
||||||
|
progress::-webkit-progress-bar {
|
||||||
|
background: var(--main-text-color);
|
||||||
|
}
|
||||||
|
progress::-webkit-progress-value {
|
||||||
|
background: #{{.ColorHex}};
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<div id="container">
|
<div id="container">
|
||||||
<progress value="{{.Used}}" max="{{.Max}}"></progress>
|
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
|
||||||
<p id="caption">
|
<label id="caption" for="resource">
|
||||||
<span>{{.Name}}</span>
|
<span>{{.Name}}</span>
|
||||||
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||||
</p>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</resource-chart>
|
</resource-chart>
|
||||||
{{end}}-
|
{{end}}
|
Reference in New Issue
Block a user