implement SSR for instance config page

This commit is contained in:
2025-04-22 17:09:27 +00:00
parent 099f9c4e42
commit 8530b50f9a
25 changed files with 1062 additions and 739 deletions

View File

@@ -35,6 +35,9 @@ func Run() {
router.GET("/index/instances", routes.HandleGETInstancesFragment)
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/tdewolff/minify"
"github.com/tdewolff/minify/css"
"github.com/tdewolff/minify/html"
"github.com/tdewolff/minify/js"
)
type MimeType struct {
@@ -36,7 +37,7 @@ var MimeTypes = map[string]MimeType{
},
"js": {
Type: "application/javascript",
Minifier: nil,
Minifier: js.Minify,
},
"wasm": {
Type: "application/wasm",

View File

@@ -4,6 +4,7 @@ import (
"bufio"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
@@ -101,6 +102,20 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
}
return s
},
"Map": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
},
}
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
engine.SetHTMLTemplate(tmpl)

View File

@@ -1,20 +1,347 @@
package routes
import (
"fmt"
"net/http"
"proxmoxaas-dashboard/app/common"
"slices"
"sort"
fabric "proxmoxaas-fabric/app"
"github.com/gin-gonic/gin"
"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
}
func HandleGETConfig(c *gin.Context) {
_, err := common.GetAuth(c)
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
config.ProctypeSelect = common.Select{}
if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true
}
}
c.HTML(http.StatusOK, "html/config.html", gin.H{
"global": common.Global,
"page": "config",
"config": config,
})
} else {
c.Redirect(http.StatusFound, "/login.html")
c.Redirect(http.StatusFound, "/login")
}
}
func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
common.HandleNonFatalError(c, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid))
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
config, err := GetInstanceConfig(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{
"config": config,
})
c.Status(http.StatusOK)
} else {
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceConfig(vm 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,
},
Body: map[string]any{},
}
res, code, err := common.RequestGetAPI(path, ctx)
if err != nil {
return config, err
}
if code != 200 {
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(ctx.Body, &config)
if err != nil {
return config, err
}
config.Memory = config.Memory / (1024 * 1024) // memory in MiB
config.Swap = config.Swap / (1024 * 1024) // swap in MiB
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) {
cputypes := common.Select{
ID: "proctype",
}
// get global resource config
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
Body: map[string]any{},
}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
global := GlobalConfig{}
err = mapstructure.Decode(ctx.Body["resources"], &global)
if err != nil {
return cputypes, err
}
// get user resource config
ctx.Body = map[string]any{}
path = "/user/config/resources"
res, code, err = common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
user := UserConfig{}
err = mapstructure.Decode(ctx.Body, &user)
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]
} else {
userCPU = user.CPU.Global
}
if global.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
} else { // cpu is a blacklist
// get the supported cpu types from the node
ctx.Body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []CPUConfig
}{}
err = mapstructure.Decode(ctx.Body, supported)
if err != nil {
return cputypes, err
}
// 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 {
return c.Name == cpu.Name
})
if !contains {
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
Value: cpu.Name,
})
}
}
}
// sort the options by lexicographical order
sort.Slice(cputypes.Options, func(i, j int) bool {
return cputypes.Options[i].Display < cputypes.Options[j].Display
})
return cputypes, nil
}