diff --git a/app/app.go b/app/app.go index fd78ed3..bb8119a 100644 --- a/app/app.go +++ b/app/app.go @@ -1,18 +1,46 @@ package app import ( - "encoding/json" "flag" "fmt" - "io" + "html/template" "log" "net/http" "proxmoxaas-dashboard/dist/web" // go will complain here until the first build "github.com/gin-gonic/gin" + "github.com/go-viper/mapstructure/v2" "github.com/tdewolff/minify" ) +var tmpl *template.Template +var global Config + +func Run() { + gin.SetMode(gin.ReleaseMode) + + configPath := flag.String("config", "config.json", "path to config.json file") + flag.Parse() + global = GetConfig(*configPath) + + router := gin.Default() + m := InitMinify() + ServeStatic(router, m) + html := MinifyStatic(m, web.Templates) + tmpl = LoadHTMLToGin(router, html) + + router.GET("/account.html", handle_GET_Account) + router.GET("/", handle_GET_Index) + router.GET("/index.html", handle_GET_Index) + router.GET("/instance.html", handle_GET_Instance) + router.GET("/login.html", handle_GET_Login) + router.GET("/settings.html", handle_GET_Settings) + + router.GET("/instances_fragment", handle_GET_Instances_Fragment) + + log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", global.Port))) +} + func ServeStatic(router *gin.Engine, m *minify.M) { css := MinifyStatic(m, web.CSS_fs) router.GET("/css/*css", func(c *gin.Context) { @@ -40,84 +68,106 @@ func ServeStatic(router *gin.Engine, m *minify.M) { }) } -func Run() { - gin.SetMode(gin.ReleaseMode) - - configPath := flag.String("config", "config.json", "path to config.json file") - flag.Parse() - - global := GetConfig(*configPath) - - router := gin.Default() - m := InitMinify() - - ServeStatic(router, m) - - html := MinifyStatic(m, web.Templates) - LoadHTMLToGin(router, html) - - router.GET("/account.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/account.html", gin.H{ - "global": global, - "page": "account", - }) +func handle_GET_Account(c *gin.Context) { + c.HTML(http.StatusOK, "html/account.html", gin.H{ + "global": global, + "page": "account", + }) +} + +func handle_GET_Index(c *gin.Context) { + _, err := c.Cookie("auth") + token, _ := c.Cookie("PVEAuthCookie") + csrf, _ := c.Cookie("CSRFPreventionToken") + if err == nil { // user should be authed, try to return index with population + instances, _, err := get_API_resources(token, csrf) + if err != nil { + HandleNonFatalError(c, err) + } + c.HTML(http.StatusOK, "html/index.html", gin.H{ + "global": global, + "page": "index", + "instances": instances, + }) + } else { // return index without populating + c.HTML(http.StatusOK, "html/index.html", gin.H{ + "global": global, + "page": "index", + }) + } +} + +func handle_GET_Instances_Fragment(c *gin.Context) { + _, err := c.Cookie("auth") + token, _ := c.Cookie("PVEAuthCookie") + csrf, _ := c.Cookie("CSRFPreventionToken") + if err == nil { // user should be authed, try to return index with population + instances, _, err := get_API_resources(token, csrf) + if err != nil { + HandleNonFatalError(c, err) + } + c.Header("Content-Type", "text/plain") + tmpl.ExecuteTemplate(c.Writer, "templates/instances.frag", gin.H{ + "instances": instances, + }) + c.Status(http.StatusOK) + } else { // return index without populating + c.Status(http.StatusUnauthorized) + } + +} + +func handle_GET_Instance(c *gin.Context) { + c.HTML(http.StatusOK, "html/instance.html", gin.H{ + "global": global, + "page": "instance", + }) +} + +func handle_GET_Login(c *gin.Context) { + ctx := RequestContext{ + Cookies: nil, + Body: map[string]interface{}{}, + } + res, err := RequestGetAPI("/proxmox/access/domains", ctx) + if err != nil { + HandleNonFatalError(c, err) + return + } + if res.StatusCode != 200 { // we expect /access/domains to always be avaliable + HandleNonFatalError(c, err) + return + } + + realms := Select{ + ID: "realm", + Name: "realm", + } + + for _, v := range ctx.Body["data"].([]interface{}) { + v = v.(map[string]interface{}) + realm := Realm{} + err := mapstructure.Decode(v, &realm) + if err != nil { + HandleNonFatalError(c, err) + } + realms.Options = append(realms.Options, Option{ + Selected: realm.Default != 0, + Value: realm.Realm, + Display: realm.Comment, + }) + } + + c.HTML(http.StatusOK, "html/login.html", gin.H{ + "global": global, + "page": "login", + "realms": realms, + }) +} + +func handle_GET_Settings(c *gin.Context) { + c.HTML(http.StatusOK, "html/settings.html", gin.H{ + "global": global, + "page": "settings", }) - - router.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/index.html", gin.H{ - "global": global, - "page": "index", - }) - }) - - router.GET("/index.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/index.html", gin.H{ - "global": global, - "page": "index", - }) - }) - - router.GET("/instance.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/instance.html", gin.H{ - "global": global, - "page": "instance", - }) - }) - - router.GET("/login.html", func(c *gin.Context) { - response, err := http.Get(global.API + "/proxmox/access/domains") - if err != nil { - log.Fatal(err.Error()) - } - data, err := io.ReadAll(response.Body) - response.Body.Close() - body := GetRealmsBody{} - json.Unmarshal(data, &body) - realms := Select{ - ID: "realm", - Name: "realm", - } - for _, realm := range body.Data { - realms.Options = append(realms.Options, Option{ - Selected: realm.Default != 0, - Value: realm.Realm, - Display: realm.Comment, - }) - } - - c.HTML(http.StatusOK, "html/login.html", gin.H{ - "global": global, - "page": "login", - "realms": realms, - }) - }) - - router.GET("/settings.html", func(c *gin.Context) { - c.HTML(http.StatusOK, "html/settings.html", gin.H{ - "global": global, - "page": "settings", - }) - }) - - router.Run(fmt.Sprintf("0.0.0.0:%d", global.Port)) } diff --git a/app/meta.go b/app/meta.go new file mode 100644 index 0000000..6c754f0 --- /dev/null +++ b/app/meta.go @@ -0,0 +1,161 @@ +package app + +import ( + "io" + + "github.com/tdewolff/minify" + "github.com/tdewolff/minify/css" + "github.com/tdewolff/minify/html" +) + +type MimeType struct { + Type string + Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error +} + +var MimeTypes = map[string]MimeType{ + "css": { + Type: "text/css", + Minifier: css.Minify, + }, + "html": { + Type: "text/html", + Minifier: html.Minify, + }, + "tmpl": { + Type: "text/plain", + Minifier: TemplateMinifier, + }, + "frag": { + Type: "text/plain", + Minifier: TemplateMinifier, + }, + "svg": { + Type: "image/svg+xml", + Minifier: nil, + }, + "js": { + Type: "application/javascript", + Minifier: nil, + }, + "wasm": { + Type: "application/wasm", + Minifier: nil, + }, + "*": { + Type: "text/plain", + Minifier: nil, + }, +} + +type Icon struct { + ID string + Src string + Alt string + Clickable bool +} + +var Icons = map[string]map[string]Icon{ + "running": { + "status": { + Src: "images/status/active.svg", + Alt: "Instance is running", + Clickable: false, + }, + "power": { + Src: "images/actions/instance/stop.svg", + Alt: "Shutdown Instance", + Clickable: true, + }, + "config": { + Src: "images/actions/instance/config-inactive.svg", + Alt: "Change Configuration (Inactive)", + Clickable: false, + }, + "console": { + Src: "images/actions/instance/console-active.svg", + Alt: "Open Console", + Clickable: true, + }, + "delete": { + Src: "images/actions/delete-inactive.svg", + Alt: "Delete Instance (Inactive)", + Clickable: false, + }, + }, + "stopped": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Instance is stopped", + Clickable: false, + }, + "power": { + Src: "images/actions/instance/start.svg", + Alt: "Start Instance", + Clickable: true, + }, + "config": { + Src: "images/actions/instance/config-active.svg", + Alt: "Change Configuration", + Clickable: true, + }, + "console": { + Src: "images/actions/instance/console-inactive.svg", + Alt: "Open Console (Inactive)", + Clickable: false, + }, + "delete": { + Src: "images/actions/delete-active.svg", + Alt: "Delete Instance", + Clickable: true, + }, + }, + "loading": { + "status": { + Src: "images/status/loading.svg", + Alt: "Instance is loading", + Clickable: false, + }, + "power": { + Src: "images/status/loading.svg", + Alt: "Loading Instance", + Clickable: false, + }, + "config": { + Src: "images/actions/instance/config-inactive.svg", + Alt: "Change Configuration (Inactive)", + Clickable: false, + }, + "console": { + Src: "images/actions/instance/console-inactive.svg", + Alt: "Open Console (Inactive)", + Clickable: false, + }, + "delete": { + Src: "images/actions/delete-inactive.svg", + Alt: "Delete Instance (Inactive)", + Clickable: false, + }, + }, + "online": { + "status": { + Src: "images/status/active.svg", + Alt: "Node is online", + Clickable: false, + }, + }, + "offline": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Node is offline", + Clickable: false, + }, + }, + "uknown": { + "status": { + Src: "images/status/inactive.svg", + Alt: "Node is offline", + Clickable: false, + }, + }, +} diff --git a/app/types.go b/app/types.go index ed64ab5..1a72cda 100644 --- a/app/types.go +++ b/app/types.go @@ -1,49 +1,16 @@ package app -import ( - "io" - - "github.com/tdewolff/minify" - "github.com/tdewolff/minify/css" - "github.com/tdewolff/minify/html" - "github.com/tdewolff/minify/js" -) - -type MimeType struct { - Type string - Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error +type Config struct { + Port int `json:"listenPort"` + Organization string `json:"organization"` + DASH string `json:"dashurl"` + PVE string `json:"pveurl"` + API string `json:"apiurl"` } -var PlainTextMimeType = MimeType{ - Type: "text/plain", - Minifier: nil, -} - -var MimeTypes = map[string]MimeType{ - "css": { - Type: "text/css", - Minifier: css.Minify, - }, - "html": { - Type: "text/html", - Minifier: html.Minify, - }, - "tmpl": { - Type: "text/plain", - Minifier: TemplateMinifier, - }, - "svg": { - Type: "image/svg+xml", - Minifier: nil, - }, - "js": { - Type: "application/javascript", - Minifier: js.Minify, - }, - "wasm": { - Type: "application/wasm", - Minifier: nil, - }, +type StaticFile struct { + Data string + MimeType MimeType } // used when requesting GET /access/domains @@ -71,3 +38,32 @@ type Option struct { Value string Display string } + +type RequestType int + +type RequestContext struct { + Cookies map[string]string + Body map[string]interface{} +} + +// 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 Instance struct { + VMID uint + Name string + Type string + Status string + Node string + StatusIcon Icon + NodeStatus string + NodeStatusIcon Icon + PowerBtnIcon Icon + ConsoleBtnIcon Icon + ConfigureBtnIcon Icon + DeleteBtnIcon Icon +} diff --git a/app/utils.go b/app/utils.go index c42acab..8b958d9 100644 --- a/app/utils.go +++ b/app/utils.go @@ -4,24 +4,20 @@ import ( "bufio" "embed" "encoding/json" + "fmt" "html/template" "io" "io/fs" "log" + "net/http" "os" "strings" "github.com/gin-gonic/gin" + "github.com/go-viper/mapstructure/v2" "github.com/tdewolff/minify" ) -type Config struct { - Port int `json:"listenPort"` - Organization string `json:"organization"` - PVE string `json:"pveurl"` - API string `json:"apiurl"` -} - func GetConfig(configPath string) Config { content, err := os.ReadFile(configPath) if err != nil { @@ -45,11 +41,6 @@ func InitMinify() *minify.M { return m } -type StaticFile struct { - Data string - MimeType MimeType -} - func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { minified := make(map[string]StaticFile) fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { @@ -80,9 +71,10 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { } } } else { // if the file has no extension, skip minify + mimetype := MimeTypes["*"] minified[path] = StaticFile{ Data: string(v), - MimeType: PlainTextMimeType, + MimeType: mimetype, } } } @@ -91,10 +83,11 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { return minified } -func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) { +func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template { root := template.New("") tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html)) engine.SetHTMLTemplate(tmpl) + return tmpl } func LoadAndAddToRoot(FuncMap template.FuncMap, root *template.Template, html map[string]StaticFile) error { @@ -128,3 +121,103 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string } return nil } + +func HandleNonFatalError(c *gin.Context, err error) { + log.Printf("[Error] encountered an error: %s", err.Error()) + c.Status(http.StatusInternalServerError) +} + +func RequestGetAPI(path string, context RequestContext) (*http.Response, error) { + req, err := http.NewRequest("GET", global.API+path, nil) + if err != nil { + return nil, err + } + for k, v := range context.Cookies { + req.AddCookie(&http.Cookie{Name: k, Value: v}) + } + + client := &http.Client{} + response, err := client.Do(req) + if err != nil { + return nil, err + } else if response.StatusCode != 200 { + return response, nil + } + + data, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + err = response.Body.Close() + if err != nil { + return nil, err + } + + err = json.Unmarshal(data, &context.Body) + if err != nil { + return nil, err + } + + return response, nil + +} + +func get_API_resources(token string, csrf string) (map[uint]Instance, map[string]Node, error) { + ctx := RequestContext{ + Cookies: map[string]string{ + "PVEAuthCookie": token, + "CSRFPreventionToken": csrf, + }, + Body: map[string]interface{}{}, + } + res, err := RequestGetAPI("/proxmox/cluster/resources", ctx) + if err != nil { + return nil, nil, err + } + + instances := map[uint]Instance{} + nodes := map[string]Node{} + + if res.StatusCode == 200 { // if we successfully retrieved the resources, then process it and return index + for _, v := range ctx.Body["data"].([]interface{}) { + m := v.(map[string]interface{}) + if m["type"] == "node" { + 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" { + instance := Instance{} + err := mapstructure.Decode(v, &instance) + if err != nil { + return nil, nil, err + } + instances[instance.VMID] = instance + } + } + for vmid, instance := range instances { + status := instance.Status + icons := Icons[status] + instance.StatusIcon = icons["status"] + instance.PowerBtnIcon = icons["power"] + instance.PowerBtnIcon.ID = "power-btn" + instance.ConfigureBtnIcon = icons["config"] + instance.ConfigureBtnIcon.ID = "configure-btn" + instance.ConsoleBtnIcon = icons["console"] + instance.ConsoleBtnIcon.ID = "console-btn" + instance.DeleteBtnIcon = icons["delete"] + instance.DeleteBtnIcon.ID = "delete-btn" + nodestatus := nodes[instance.Node].Status + icons = Icons[nodestatus] + instance.NodeStatus = nodestatus + instance.NodeStatusIcon = icons["status"] + instances[vmid] = instance + } + return instances, nodes, nil + } else { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow + return nil, nil, fmt.Errorf("request to /cluster/resources/ resulted in %+v", res) + } +} diff --git a/configs/.htmlvalidate.json b/configs/.htmlvalidate.json index 830dceb..6db1c12 100644 --- a/configs/.htmlvalidate.json +++ b/configs/.htmlvalidate.json @@ -4,5 +4,13 @@ ], "rules": { "no-inline-style": "off" - } + }, + "elements": [ + "html5", + { + "head": { + "requiredContent": [] + } + } + ] } \ No newline at end of file diff --git a/configs/template.config.json b/configs/template.config.json index 24fb653..f8a0bac 100644 --- a/configs/template.config.json +++ b/configs/template.config.json @@ -1,6 +1,7 @@ { "listenPort": 8080, "organization": "myorg", + "dashurl": "https://paas.mydomain.example", "apiurl": "https://paas.mydomain.example/api", "pveurl": "https://pve.mydomain.example" } \ No newline at end of file diff --git a/go.mod b/go.mod index 1a19365..ca22a17 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24 require ( github.com/gin-gonic/gin v1.10.0 + github.com/go-viper/mapstructure/v2 v2.2.1 github.com/tdewolff/minify v2.3.6+incompatible ) diff --git a/web/html/index.html b/web/html/index.html index 339c943..1ee8113 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -54,7 +54,11 @@
- +{{.VMID}}
+{{.Name}}
+ +{{.Status}}
+