Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 196cf900cc | |||
| 31d097bc19 | |||
| 0822b9f26c | |||
| 8710da338b | |||
| 415d60d228 | |||
| cede3b4b7f | |||
| 3a7c4cd2ae | |||
| 428d5d15a5 | |||
| 3f2964db55 | |||
| 666fd92b30 | |||
| 2ef8837a03 | |||
| b58e28ebb5 | |||
| 8f72aaf255 | |||
| 10ef24e76b | |||
| 00fa5f3152 | |||
| 94233000df | |||
| d88a208da5 | |||
| 3b1b20b506 | |||
| 66747fa657 | |||
| f40638598d | |||
| 0082f2f3e5 | |||
| 549316ab73 | |||
| fc58861046 | |||
| 9b7404c8d6 | |||
| 08cd4dfaaa | |||
| 26a21e6cc7 | |||
| c3fe936e05 | |||
| eb201de26b | |||
| 545061b2eb | |||
| 0692c1b96c | |||
| 4f9d19eb39 | |||
| c92c4c4e6d | |||
| f44df0446e | |||
| 7475ae30b7 | |||
| b2820a3051 | |||
| 7aadc03fc9 | |||
| 9606d62e78 | |||
| 3f754c65b9 | |||
| 5788e1b493 | |||
| 3636878bb7 | |||
| 6b094690f9 | |||
| 3d8e4b416d | |||
| 7a5888be82 | |||
| d06f62f5e5 | |||
| 72b1179fc9 | |||
| 32c23daf9d | |||
| b86be4c749 | |||
| 338ea3342e | |||
| 3b7d3ba01f | |||
| 9e1fca8597 | |||
| c8ca49b85c | |||
| 2877f7709a | |||
| 7db0bea35c | |||
| ff98eb318e | |||
| 05ced39598 | |||
| 4e2b6278d8 | |||
| 75e098b7b4 | |||
| 8c378a3b49 | |||
| 3d5989a946 | |||
| 06afdcec37 | |||
| 2f21b23535 | |||
| e8dd28b519 | |||
| e0c7a53d85 | |||
| 118b7dac53 | |||
| db32f318b9 | |||
| c13a4c8539 | |||
| 8d490cd336 | |||
| 343c149330 | |||
| d95a82f248 | |||
| fc42de2c49 | |||
| 3f723394c4 | |||
| e7627b5787 | |||
| 7732da0642 | |||
| 87c42495ad | |||
| 89065254d2 | |||
| 08e5f8b392 | |||
| 8be935a421 | |||
| f94dca7e0c | |||
| 8905886065 | |||
| df6772c72b | |||
| f3b6c0abf4 | |||
| 69fae92313 | |||
| a79dd96d2a | |||
| ee397c48e1 | |||
| 33b0a4b5ff | |||
| 65c8fbdca8 | |||
| e932165a98 | |||
| 8c339794b3 | |||
| a62fc83386 | |||
| 756aef587d | |||
| ca555a7116 | |||
| 85c3ab49fc | |||
| 9ec277ce65 | |||
| e41c8d2a07 | |||
| 308d133e6e | |||
| 99d58eb250 | |||
| acd6eba520 | |||
| 478ca20451 | |||
| 28c60aecc9 | |||
| 3d677a46ee | |||
| e170d7f93d | |||
| 85bd81ef30 | |||
| 53832b67a2 | |||
| e6cd1fbb3d | |||
| 3f21f3c4a4 | |||
| 1bcbed6828 | |||
| 31bfa79e66 |
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "proxmoxaas-common-lib"]
|
||||||
|
path = proxmoxaas-common-lib
|
||||||
|
url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib
|
||||||
|
[submodule "WFA-JS"]
|
||||||
|
path = WFA-JS
|
||||||
|
url = https://git.tronnet.net/alu/WFA-JS
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
.PHONY: build test clean
|
.PHONY: build test clean wfa-js
|
||||||
|
|
||||||
build: clean
|
build: clean wfa-js
|
||||||
@echo "======================== Building Binary ======================="
|
@echo "======================== Building Binary ======================="
|
||||||
|
mkdir -p dist
|
||||||
# resolve symbolic links in web by copying it into dist/web/
|
# resolve symbolic links in web by copying it into dist/web/
|
||||||
cp -rL web/ dist/web/
|
cp -rL web/ dist/web/
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
|
CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
|
||||||
|
|
||||||
|
wfa-js:
|
||||||
|
$(MAKE) -C WFA-JS
|
||||||
|
cp -f WFA-JS/dist/* web/modules
|
||||||
|
|
||||||
test: clean
|
test: clean
|
||||||
go run .
|
go run .
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
|
|||||||
|
|
||||||
## ProxmoxAAS System Installation Overview
|
## ProxmoxAAS System Installation Overview
|
||||||
|
|
||||||
The ProxmoxAAS project is large and is split into multiple components. There are three required components, the Dashboard, API, and Fabric. There is also an optional LDAP component for organizations that want to use LDAP as their authentication backend. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
|
The ProxmoxAAS project is large and is split into multiple components. There are four required components, the Dashboard, API, Fabric, and access-manager-api. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
|
||||||
|
|
||||||
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs.
|
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs.
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ We will assume different hosts for each component which are accessible by unique
|
|||||||
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net |
|
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net |
|
||||||
| ProxmoxAAS-API | api.local | paas.domain.net/api/ |
|
| ProxmoxAAS-API | api.local | paas.domain.net/api/ |
|
||||||
| ProxmoxAAS-Fabric | fabric.local | N/A |
|
| ProxmoxAAS-Fabric | fabric.local | N/A |
|
||||||
| ProxmoxAAS-LDAP | ldap.local | N/A|
|
| access-manager-api | access.local | N/A|
|
||||||
|
|
||||||
## Prerequisites - Dashboard
|
## Prerequisites - Dashboard
|
||||||
- Proxmox VE Cluster (v7.0+)
|
- Proxmox VE Cluster (v7.0+)
|
||||||
@@ -53,9 +53,9 @@ To install the API component, go to [ProxmoxAAS-API](https://git.tronnet.net/tro
|
|||||||
|
|
||||||
To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function. The Fabric installation will also have steps for setting up the reverse proxy server.
|
To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function. The Fabric installation will also have steps for setting up the reverse proxy server.
|
||||||
|
|
||||||
## Installation - LDAP
|
## Installation - access-manager-api
|
||||||
|
|
||||||
To install the LDAP component, go to [ProxmoxAAS-LDAP](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP).This is an optional component which adds a lightweight REST API server ontop of a simplified LDAP environment. It is only used by the API as a potential authentication backend.
|
To install the access-manager-api component, go to [access-manager-api](https://git.tronnet.net/tronnet/access-manager-api). This is required for the app to function. The access-manager-api installation will also have steps for setting up the reverse proxy server.
|
||||||
|
|
||||||
## Installation - Reverse Proxy
|
## Installation - Reverse Proxy
|
||||||
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. The configuration should include at least the following, ensuring that the configured ports are adjusted appropriately:
|
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. The configuration should include at least the following, ensuring that the configured ports are adjusted appropriately:
|
||||||
|
|||||||
@@ -4,43 +4,48 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
|
|
||||||
|
|
||||||
"proxmoxaas-dashboard/app/common"
|
"proxmoxaas-dashboard/app/common"
|
||||||
"proxmoxaas-dashboard/app/routes"
|
"proxmoxaas-dashboard/app/routes"
|
||||||
|
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tdewolff/minify/v2"
|
"github.com/tdewolff/minify/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Run() {
|
func Run() {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
|
||||||
|
|
||||||
configPath := flag.String("config", "config.json", "path to config.json file")
|
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
common.Global = common.GetConfig(*configPath)
|
common.Global = common.GetConfig(*configPath)
|
||||||
|
|
||||||
|
// setup static resources
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
m := common.InitMinify()
|
m := common.InitMinify()
|
||||||
ServeStatic(router, m)
|
ServeStatic(router, m)
|
||||||
html := common.MinifyStatic(m, web.Templates)
|
html := common.MinifyStatic(m, web.Templates)
|
||||||
common.TMPL = common.LoadHTMLToGin(router, html)
|
common.TMPL = common.LoadHTMLToGin(router, html)
|
||||||
|
|
||||||
router.GET("/account", routes.HandleGETAccount)
|
// dynamic routes for pages and page fragments
|
||||||
router.GET("/", routes.HandleGETIndex)
|
router.GET("/", routes.HandleGETIndex)
|
||||||
router.GET("/index", routes.HandleGETIndex)
|
router.GET("/index", routes.HandleGETIndex)
|
||||||
router.GET("/index/instances", routes.HandleGETInstancesFragment)
|
router.GET("/index/instances", routes.HandleGETInstancesFragment)
|
||||||
|
router.GET("/account", routes.HandleGETAccount)
|
||||||
router.GET("/config", routes.HandleGETConfig)
|
router.GET("/config", routes.HandleGETConfig)
|
||||||
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
|
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
|
||||||
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
|
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
|
||||||
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
|
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
|
||||||
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
|
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
|
||||||
|
router.GET("/backups", routes.HandleGETBackups)
|
||||||
|
router.GET("/backups/backups", routes.HandleGETBackupsFragment)
|
||||||
router.GET("/login", routes.HandleGETLogin)
|
router.GET("/login", routes.HandleGETLogin)
|
||||||
router.GET("/settings", routes.HandleGETSettings)
|
router.GET("/settings", routes.HandleGETSettings)
|
||||||
|
|
||||||
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
|
// run on all interfaces with port
|
||||||
|
log.Fatal("[ERR ] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setup static resources under web (css, images, modules, scripts)
|
||||||
func ServeStatic(router *gin.Engine, m *minify.M) {
|
func ServeStatic(router *gin.Engine, m *minify.M) {
|
||||||
css := common.MinifyStatic(m, web.CSS_fs)
|
css := common.MinifyStatic(m, web.CSS_fs)
|
||||||
router.GET("/css/*css", func(c *gin.Context) {
|
router.GET("/css/*css", func(c *gin.Context) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/tdewolff/minify/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defines mime type and associated minifier
|
||||||
|
type MimeType struct {
|
||||||
|
Type string
|
||||||
|
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// debug mime types
|
||||||
|
var MimeTypes = map[string]MimeType{
|
||||||
|
"css": {
|
||||||
|
Type: "text/css",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
Type: "text/html",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"tmpl": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"frag": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"svg": {
|
||||||
|
Type: "image/svg+xml",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"png": {
|
||||||
|
Type: "image/png",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"js": {
|
||||||
|
Type: "application/javascript",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"wasm": {
|
||||||
|
Type: "application/wasm",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"*": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
//go:build release
|
||||||
|
|
||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,6 +9,7 @@ import (
|
|||||||
"github.com/tdewolff/minify/v2/css"
|
"github.com/tdewolff/minify/v2/css"
|
||||||
"github.com/tdewolff/minify/v2/html"
|
"github.com/tdewolff/minify/v2/html"
|
||||||
"github.com/tdewolff/minify/v2/js"
|
"github.com/tdewolff/minify/v2/js"
|
||||||
|
"github.com/tdewolff/minify/v2/svg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defines mime type and associated minifier
|
// defines mime type and associated minifier
|
||||||
@@ -35,6 +38,10 @@ var MimeTypes = map[string]MimeType{
|
|||||||
},
|
},
|
||||||
"svg": {
|
"svg": {
|
||||||
Type: "image/svg+xml",
|
Type: "image/svg+xml",
|
||||||
|
Minifier: svg.Minify,
|
||||||
|
},
|
||||||
|
"png": {
|
||||||
|
Type: "image/png",
|
||||||
Minifier: nil,
|
Minifier: nil,
|
||||||
},
|
},
|
||||||
"js": {
|
"js": {
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
var Global Config
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port int `json:"listenPort"`
|
Port int `json:"listenPort"`
|
||||||
Organization string `json:"organization"`
|
Organization string `json:"organization"`
|
||||||
@@ -8,6 +12,11 @@ type Config struct {
|
|||||||
API string `json:"apiurl"`
|
API string `json:"apiurl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variable for html template root
|
||||||
|
// generated from LoadHTMLToGin
|
||||||
|
var TMPL *template.Template
|
||||||
|
|
||||||
|
// static served file type containing data and mimetype
|
||||||
type StaticFile struct {
|
type StaticFile struct {
|
||||||
Data string
|
Data string
|
||||||
MimeType MimeType
|
MimeType MimeType
|
||||||
@@ -26,23 +35,3 @@ type Option struct {
|
|||||||
Value string
|
Value string
|
||||||
Display string
|
Display string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestType int
|
|
||||||
|
|
||||||
type RequestContext struct {
|
|
||||||
Cookies map[string]string
|
|
||||||
Body map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
type Auth struct {
|
|
||||||
Username string
|
|
||||||
Token string
|
|
||||||
CSRF string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Icon struct {
|
|
||||||
ID string
|
|
||||||
Src string
|
|
||||||
Alt string
|
|
||||||
Clickable bool
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,28 +13,38 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tdewolff/minify/v2"
|
"github.com/tdewolff/minify/v2"
|
||||||
|
|
||||||
|
paas "proxmoxaas-common-lib"
|
||||||
)
|
)
|
||||||
|
|
||||||
var TMPL *template.Template
|
// get config file from configPath
|
||||||
var Global Config
|
|
||||||
|
|
||||||
func GetConfig(configPath string) Config {
|
func GetConfig(configPath string) Config {
|
||||||
content, err := os.ReadFile(configPath)
|
root, err := os.OpenRoot(".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error when opening config file: ", err)
|
log.Fatal("[ERR ] error opening root dir: ", err)
|
||||||
}
|
}
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
content, err := root.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[ERR ] error opening config file: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
err = json.Unmarshal(content, &config)
|
err = json.Unmarshal(content, &config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error during parsing config file: ", err)
|
log.Fatal("[ERR ] error parsing config file: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initialize minifier using the meta types specified
|
||||||
func InitMinify() *minify.M {
|
func InitMinify() *minify.M {
|
||||||
m := minify.New()
|
m := minify.New()
|
||||||
for _, v := range MimeTypes {
|
for _, v := range MimeTypes {
|
||||||
@@ -47,14 +57,14 @@ func InitMinify() *minify.M {
|
|||||||
|
|
||||||
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
||||||
minified := make(map[string]StaticFile)
|
minified := make(map[string]StaticFile)
|
||||||
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !entry.IsDir() {
|
if !entry.IsDir() {
|
||||||
v, err := files.ReadFile(path)
|
v, err := files.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error parsing template file %s: %s", path, err.Error())
|
log.Fatalf("[ERR ] error parsing template file %s: %s", path, err.Error())
|
||||||
}
|
}
|
||||||
x := strings.Split(entry.Name(), ".")
|
x := strings.Split(entry.Name(), ".")
|
||||||
if len(x) >= 2 { // file has extension
|
if len(x) >= 2 { // file has extension
|
||||||
@@ -62,7 +72,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
|
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
|
min, err := m.String(mimetype.Type, string(v)) // try to minify
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("error minifying file %s: %s", path, err.Error())
|
log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
|
||||||
}
|
}
|
||||||
minified[path] = StaticFile{
|
minified[path] = StaticFile{
|
||||||
Data: min,
|
Data: min,
|
||||||
@@ -84,7 +94,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERR ] error in MinifyStatic: %s", err)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
return minified
|
return minified
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
|
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
|
||||||
@@ -93,20 +109,20 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
|
|||||||
"MapKeys": func(x any, sep string) string {
|
"MapKeys": func(x any, sep string) string {
|
||||||
v := reflect.ValueOf(x)
|
v := reflect.ValueOf(x)
|
||||||
keys := v.MapKeys()
|
keys := v.MapKeys()
|
||||||
s := ""
|
var s strings.Builder
|
||||||
for i := 0; i < len(keys); i++ {
|
for i := range keys {
|
||||||
if i != 0 {
|
if i != 0 {
|
||||||
s += sep
|
s.WriteString(sep)
|
||||||
}
|
}
|
||||||
s += keys[i].String()
|
s.WriteString(keys[i].String())
|
||||||
}
|
}
|
||||||
return s
|
return s.String()
|
||||||
},
|
},
|
||||||
"Map": func(values ...any) (map[string]any, error) {
|
"Map": func(values ...any) (map[string]any, error) {
|
||||||
if len(values)%2 != 0 {
|
if len(values)%2 != 0 {
|
||||||
return nil, errors.New("invalid dict call")
|
return nil, errors.New("invalid dict call")
|
||||||
}
|
}
|
||||||
dict := make(map[string]interface{}, len(values)/2)
|
dict := make(map[string]any, len(values)/2)
|
||||||
for i := 0; i < len(values); i += 2 {
|
for i := 0; i < len(values); i += 2 {
|
||||||
key, ok := values[i].(string)
|
key, ok := values[i].(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -118,7 +134,7 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
|
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
|
||||||
engine.SetHTMLTemplate(tmpl)
|
engine.SetHTMLTemplate(root)
|
||||||
return tmpl
|
return tmpl
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,17 +171,20 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HandleNonFatalError(c *gin.Context, err error) {
|
func HandleNonFatalError(c *gin.Context, err error) {
|
||||||
log.Printf("[Error] encountered an error: %s", err.Error())
|
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
|
||||||
c.Status(http.StatusInternalServerError)
|
c.Status(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
func RequestGetAPI(path string, context RequestContext) (*http.Response, int, error) {
|
func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
|
||||||
req, err := http.NewRequest("GET", Global.API+path, nil)
|
req, err := http.NewRequest("GET", Global.API+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
for k, v := range context.Cookies {
|
|
||||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
if auth != nil {
|
||||||
|
for k, v := range GetRequestContextFromCookies(*auth) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
@@ -185,23 +204,62 @@ func RequestGetAPI(path string, context RequestContext) (*http.Response, int, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.StatusCode, err
|
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)
|
||||||
err = json.Unmarshal(data, &context.Body)
|
case *map[string]any:
|
||||||
|
err = json.Unmarshal(data, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.StatusCode, err
|
return nil, response.StatusCode, err
|
||||||
}
|
}
|
||||||
|
case *[]any:
|
||||||
|
err = json.Unmarshal(data, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.StatusCode, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
return response, response.StatusCode, nil
|
return response, response.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAuth(c *gin.Context) (Auth, error) {
|
func GetAuthFromRequest(c *gin.Context) (paas.Auth, error) {
|
||||||
_, errAuth := c.Cookie("auth")
|
_, errAuth := c.Cookie("auth")
|
||||||
username, errUsername := c.Cookie("username")
|
username, errUsername := c.Cookie("username")
|
||||||
token, errToken := c.Cookie("PVEAuthCookie")
|
token, errToken := c.Cookie("PVEAuthCookie")
|
||||||
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
|
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
|
||||||
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
|
access, errAccess := c.Cookie("PAASAccessManagerTicket")
|
||||||
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
|
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
|
||||||
|
return paas.Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
|
||||||
} else {
|
} else {
|
||||||
return Auth{username, token, csrf}, nil
|
return paas.Auth{Username: username, Token: token, CSRF: csrf, AccessManagerTicket: access}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstancePathFromRequest(c *gin.Context) (paas.InstancePath, error) {
|
||||||
|
req_node := c.Query("node")
|
||||||
|
req_type := c.Query("type")
|
||||||
|
req_vmid := c.Query("vmid")
|
||||||
|
if req_node == "" || req_type == "" || req_vmid == "" {
|
||||||
|
return paas.InstancePath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
|
||||||
|
}
|
||||||
|
|
||||||
|
vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return paas.InstancePath{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vm_path := paas.InstancePath{
|
||||||
|
NodeName: req_node,
|
||||||
|
InstanceType: paas.InstanceType(req_type),
|
||||||
|
InstanceID: paas.InstanceID(vmid_int),
|
||||||
|
}
|
||||||
|
return vm_path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"username": auth.Username,
|
||||||
|
"PVEAuthCookie": auth.Token,
|
||||||
|
"CSRFPreventionToken": auth.CSRF,
|
||||||
|
"PAASAccessManagerTicket": auth.AccessManagerTicket,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,28 +2,125 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
paas "proxmoxaas-common-lib"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Account struct {
|
||||||
|
paas.User
|
||||||
|
Pools map[string]paas.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// numerical constraint
|
||||||
|
type Constraint struct {
|
||||||
|
Max int64
|
||||||
|
Used int64
|
||||||
|
Avail int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// match constraint
|
||||||
|
type Match struct {
|
||||||
|
Name string
|
||||||
|
Match string
|
||||||
|
Max int64
|
||||||
|
Used int64
|
||||||
|
Avail int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type NumericResource struct {
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Multiplier int64
|
||||||
|
Base uint64
|
||||||
|
Compact bool
|
||||||
|
Unit string
|
||||||
|
Display bool
|
||||||
|
Global Constraint
|
||||||
|
Nodes map[string]Constraint
|
||||||
|
Total Constraint
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageResource struct {
|
||||||
|
Type string
|
||||||
|
Name string
|
||||||
|
Multiplier int64
|
||||||
|
Base uint64
|
||||||
|
Compact bool
|
||||||
|
Unit string
|
||||||
|
Display bool
|
||||||
|
Disks []string
|
||||||
|
Global Constraint
|
||||||
|
Nodes map[string]Constraint
|
||||||
|
Total Constraint
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListResource struct {
|
||||||
|
Type string
|
||||||
|
Whitelist bool
|
||||||
|
Display bool
|
||||||
|
Global []Match
|
||||||
|
Nodes map[string][]Match
|
||||||
|
Total []Match
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceChart struct {
|
||||||
|
Type string
|
||||||
|
Display bool
|
||||||
|
Name string
|
||||||
|
Used int64
|
||||||
|
Max int64
|
||||||
|
Avail string
|
||||||
|
Prefix 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) {
|
func HandleGETAccount(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
account, err := GetUserAccount(auth)
|
|
||||||
|
account, err := GetUser(auth)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range account.Resources {
|
pools, err := GetUserPools(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for poolname, pool := range pools {
|
||||||
|
// for each resource category
|
||||||
|
for category := range pool.Resources {
|
||||||
|
// for each resource in each category
|
||||||
|
for resource, v := range pool.Resources[category].(map[string]any) {
|
||||||
|
// create a resource chart for resource depending on resource type
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case NumericResource:
|
case NumericResource:
|
||||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||||
account.Resources[k] = ResourceChart{
|
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||||
Type: t.Type,
|
Type: t.Type,
|
||||||
Display: t.Display,
|
Display: t.Display,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
@@ -32,10 +129,11 @@ func HandleGETAccount(c *gin.Context) {
|
|||||||
Avail: avail,
|
Avail: avail,
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
Unit: t.Unit,
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
}
|
}
|
||||||
case StorageResource:
|
case StorageResource:
|
||||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||||
account.Resources[k] = ResourceChart{
|
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||||
Type: t.Type,
|
Type: t.Type,
|
||||||
Display: t.Display,
|
Display: t.Display,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
@@ -44,6 +142,7 @@ func HandleGETAccount(c *gin.Context) {
|
|||||||
Avail: avail,
|
Avail: avail,
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
Unit: t.Unit,
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
}
|
}
|
||||||
case ListResource:
|
case ListResource:
|
||||||
l := struct {
|
l := struct {
|
||||||
@@ -57,19 +156,25 @@ func HandleGETAccount(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, r := range t.Total {
|
for _, r := range t.Total {
|
||||||
|
avail := fmt.Sprintf("%d", r.Avail)
|
||||||
l.Resources = append(l.Resources, ResourceChart{
|
l.Resources = append(l.Resources, ResourceChart{
|
||||||
Type: t.Type,
|
Type: t.Type,
|
||||||
Display: t.Display,
|
Display: t.Display,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Used: r.Used,
|
Used: r.Used,
|
||||||
Max: r.Max,
|
Max: r.Max,
|
||||||
Avail: float64(r.Avail), // usually an int
|
Avail: avail, // usually an int
|
||||||
Unit: "",
|
Unit: "",
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
account.Resources[k] = l
|
pools[poolname].Resources[category].(map[string]any)[resource] = l
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
account.Pools = pools
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||||
"global": common.Global,
|
"global": common.Global,
|
||||||
@@ -81,183 +186,110 @@ func HandleGETAccount(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Account struct {
|
func GetUser(auth paas.Auth) (Account, error) {
|
||||||
Username string
|
account := Account{}
|
||||||
Pools map[string]bool
|
body := map[string]any{}
|
||||||
Nodes map[string]bool
|
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
|
||||||
VMID struct {
|
|
||||||
Min int
|
|
||||||
Max int
|
|
||||||
}
|
|
||||||
Resources map[string]any
|
|
||||||
}
|
|
||||||
|
|
||||||
type Constraint struct {
|
|
||||||
Max int64
|
|
||||||
Used int64
|
|
||||||
Avail int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type Match struct {
|
|
||||||
Name string
|
|
||||||
Match string
|
|
||||||
Max int64
|
|
||||||
Used int64
|
|
||||||
Avail int64
|
|
||||||
}
|
|
||||||
|
|
||||||
type NumericResource struct {
|
|
||||||
Type string
|
|
||||||
Name string
|
|
||||||
Multiplier int64
|
|
||||||
Base int64
|
|
||||||
Compact bool
|
|
||||||
Unit string
|
|
||||||
Display bool
|
|
||||||
Global Constraint
|
|
||||||
Nodes map[string]Constraint
|
|
||||||
Total Constraint
|
|
||||||
}
|
|
||||||
|
|
||||||
type StorageResource struct {
|
|
||||||
Type string
|
|
||||||
Name string
|
|
||||||
Multiplier int64
|
|
||||||
Base int64
|
|
||||||
Compact bool
|
|
||||||
Unit string
|
|
||||||
Display bool
|
|
||||||
Disks []string
|
|
||||||
Global Constraint
|
|
||||||
Nodes map[string]Constraint
|
|
||||||
Total Constraint
|
|
||||||
}
|
|
||||||
|
|
||||||
type ListResource struct {
|
|
||||||
Type string
|
|
||||||
Whitelist bool
|
|
||||||
Display bool
|
|
||||||
Global []Match
|
|
||||||
Nodes map[string][]Match
|
|
||||||
Total []Match
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResourceChart struct {
|
|
||||||
Type string
|
|
||||||
Display bool
|
|
||||||
Name string
|
|
||||||
Used int64
|
|
||||||
Max int64
|
|
||||||
Avail float64
|
|
||||||
Prefix string
|
|
||||||
Unit string
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUserAccount(auth common.Auth) (Account, error) {
|
|
||||||
account := Account{
|
|
||||||
Resources: map[string]any{},
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := common.RequestContext{
|
|
||||||
Cookies: map[string]string{
|
|
||||||
"username": auth.Username,
|
|
||||||
"PVEAuthCookie": auth.Token,
|
|
||||||
"CSRFPreventionToken": auth.CSRF,
|
|
||||||
},
|
|
||||||
Body: map[string]any{},
|
|
||||||
}
|
|
||||||
|
|
||||||
// get user account basic data
|
|
||||||
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return account, err
|
return account, err
|
||||||
}
|
}
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
|
return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
|
||||||
}
|
}
|
||||||
err = mapstructure.Decode(ctx.Body, &account)
|
err = mapstructure.Decode(body, &account)
|
||||||
if err != nil {
|
|
||||||
return account, err
|
return account, err
|
||||||
} else {
|
}
|
||||||
account.Username = auth.Username
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Body = map[string]any{}
|
func GetUserPools(auth paas.Auth) (map[string]paas.Pool, error) {
|
||||||
// get user resources
|
pools := map[string]paas.Pool{}
|
||||||
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx)
|
|
||||||
|
// get all pools
|
||||||
|
body := map[string]any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return account, err
|
return pools, err
|
||||||
}
|
}
|
||||||
if code != 200 {
|
if code != 200 {
|
||||||
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
|
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
|
||||||
|
}
|
||||||
|
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
|
||||||
|
if err != nil {
|
||||||
|
return pools, err
|
||||||
}
|
}
|
||||||
resources := ctx.Body
|
|
||||||
|
|
||||||
ctx.Body = map[string]any{}
|
// get global config for resource type metadata
|
||||||
|
body = map[string]any{}
|
||||||
// get resource meta data
|
// get resource meta data
|
||||||
res, code, err = common.RequestGetAPI("/global/config/resources", ctx)
|
res, code, err = common.RequestGetAPI("/global/config/resources", &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return account, err
|
return pools, err
|
||||||
}
|
}
|
||||||
if code != 200 {
|
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 := ctx.Body["resources"].(map[string]any)
|
meta := body["resources"].(map[string]any)
|
||||||
|
|
||||||
// build each resource by its meta type
|
// for each pool
|
||||||
for k, v := range meta {
|
for poolname, pool := range pools {
|
||||||
m := v.(map[string]any)
|
// for each resource in pool data
|
||||||
|
for k, v := range pool.Resources {
|
||||||
|
m := meta[k].(map[string]any)
|
||||||
t := m["type"].(string)
|
t := m["type"].(string)
|
||||||
r := resources[k].(map[string]any)
|
r := v.(map[string]any)
|
||||||
if t == "numeric" {
|
category := m["category"].(string)
|
||||||
|
|
||||||
|
// create a category if it does not already exist
|
||||||
|
if _, ok := pool.Resources[category]; !ok {
|
||||||
|
pool.Resources[category] = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// depending on type, decode the pool data into the corresponding resource type
|
||||||
|
switch t {
|
||||||
|
case "numeric":
|
||||||
n := NumericResource{}
|
n := NumericResource{}
|
||||||
n.Type = t
|
n.Type = t
|
||||||
err_m := mapstructure.Decode(m, &n)
|
err_m := mapstructure.Decode(m, &n)
|
||||||
err_r := mapstructure.Decode(r, &n)
|
err_r := mapstructure.Decode(r, &n)
|
||||||
if err_m != nil || err_r != nil {
|
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[k] = n
|
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||||
} else if t == "storage" {
|
case "storage":
|
||||||
n := StorageResource{}
|
n := StorageResource{}
|
||||||
n.Type = t
|
n.Type = t
|
||||||
err_m := mapstructure.Decode(m, &n)
|
err_m := mapstructure.Decode(m, &n)
|
||||||
err_r := mapstructure.Decode(r, &n)
|
err_r := mapstructure.Decode(r, &n)
|
||||||
if err_m != nil || err_r != nil {
|
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[k] = n
|
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||||
} else if t == "list" {
|
case "list":
|
||||||
n := ListResource{}
|
n := ListResource{}
|
||||||
n.Type = t
|
n.Type = t
|
||||||
err_m := mapstructure.Decode(m, &n)
|
err_m := mapstructure.Decode(m, &n)
|
||||||
err_r := mapstructure.Decode(r, &n)
|
err_r := mapstructure.Decode(r, &n)
|
||||||
if err_m != nil || err_r != nil {
|
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[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
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormatNumber(val int64, base int64) (float64, string) {
|
// interpolate between min and max by normalized (0 - 1) val
|
||||||
valf := float64(val)
|
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
|
||||||
basef := float64(base)
|
minhsl := min.ToHSL()
|
||||||
steps := 0
|
maxhsl := max.ToHSL()
|
||||||
for math.Abs(valf) > basef && steps < 4 {
|
interphsl := color.HSL{
|
||||||
valf /= basef
|
H: (1-val)*minhsl.H + (val)*maxhsl.H,
|
||||||
steps++
|
S: (1-val)*minhsl.S + (val)*maxhsl.S,
|
||||||
}
|
L: (1-val)*minhsl.L + (val)*maxhsl.L,
|
||||||
|
|
||||||
if base == 1000 {
|
|
||||||
prefixes := []string{"", "K", "M", "G", "T"}
|
|
||||||
return valf, prefixes[steps]
|
|
||||||
} else if base == 1024 {
|
|
||||||
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
|
|
||||||
return valf, prefixes[steps]
|
|
||||||
} else {
|
|
||||||
return 0, ""
|
|
||||||
}
|
}
|
||||||
|
return interphsl.ToRGB()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
paas "proxmoxaas-common-lib"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceBackup struct {
|
||||||
|
Volid string `json:"volid"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CTime int64 `json:"ctime"`
|
||||||
|
SizeFormatted string
|
||||||
|
TimeFormatted string
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETBackups(c *gin.Context) {
|
||||||
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, err := GetInstanceBackups(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth) // only used for the VM's name
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "html/backups.html", gin.H{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "backups",
|
||||||
|
"backups": backups,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
c.Redirect(http.StatusFound, "/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETBackupsFragment(c *gin.Context) {
|
||||||
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
|
if err == nil { // user should be authed, try to return index with population
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, err := GetInstanceBackups(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/plain")
|
||||||
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
|
||||||
|
"backups": backups,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
} else { // return 401
|
||||||
|
c.Status(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
|
||||||
|
backups := []InstanceBackup{}
|
||||||
|
path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return backups, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return backups, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mapstructure.Decode(body, &backups)
|
||||||
|
if err != nil {
|
||||||
|
return backups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range backups {
|
||||||
|
size, prefix := paas.FormatNumber(paas.SafeUint64(backups[i].Size), paas.Base1024)
|
||||||
|
backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
|
||||||
|
|
||||||
|
t := time.Unix(backups[i].CTime, 0)
|
||||||
|
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
return backups, nil
|
||||||
|
}
|
||||||
@@ -3,22 +3,43 @@ package routes
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
paas "proxmoxaas-common-lib"
|
||||||
"proxmoxaas-dashboard/app/common"
|
"proxmoxaas-dashboard/app/common"
|
||||||
"slices"
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
fabric "proxmoxaas-fabric/app"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// imported types from fabric
|
||||||
|
|
||||||
|
type InstanceConfig struct {
|
||||||
|
paas.Instance `mapstructure:",squash"`
|
||||||
|
// overrides
|
||||||
|
ProctypeSelect common.Select
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalConfig struct {
|
||||||
|
CPU struct {
|
||||||
|
Whitelist bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolConfig struct {
|
||||||
|
CPU struct {
|
||||||
|
Global []paas.MatchLimit
|
||||||
|
Nodes map[string][]paas.MatchLimit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func HandleGETConfig(c *gin.Context) {
|
func HandleGETConfig(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vm_path, err := ExtractVMPath(c)
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := GetInstanceConfig(vm_path, auth)
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
@@ -27,7 +48,7 @@ func HandleGETConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.Type == "VM" { // if VM, fetch CPU types from node
|
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 {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
||||||
}
|
}
|
||||||
@@ -49,11 +70,12 @@ func HandleGETConfig(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func HandleGETConfigVolumesFragment(c *gin.Context) {
|
func HandleGETConfigVolumesFragment(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vm_path, err := ExtractVMPath(c)
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := GetInstanceConfig(vm_path, auth)
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
@@ -62,21 +84,26 @@ 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{
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleGETConfigNetsFragment(c *gin.Context) {
|
func HandleGETConfigNetsFragment(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vm_path, err := ExtractVMPath(c)
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := GetInstanceConfig(vm_path, auth)
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
@@ -85,21 +112,26 @@ 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{
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleGETConfigDevicesFragment(c *gin.Context) {
|
func HandleGETConfigDevicesFragment(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vm_path, err := ExtractVMPath(c)
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := GetInstanceConfig(vm_path, auth)
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
@@ -108,21 +140,26 @@ 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{
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleGETConfigBootFragment(c *gin.Context) {
|
func HandleGETConfigBootFragment(c *gin.Context) {
|
||||||
auth, err := common.GetAuth(c)
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
vm_path, err := ExtractVMPath(c)
|
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.HandleNonFatalError(c, err)
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := GetInstanceConfig(vm_path, auth)
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
@@ -131,65 +168,24 @@ 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{
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
|
||||||
"config": config,
|
"config": config,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
c.Status(http.StatusUnauthorized)
|
c.Status(http.StatusUnauthorized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExtractVMPath(c *gin.Context) (VMPath, error) {
|
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
|
||||||
req_node := c.Query("node")
|
|
||||||
req_type := c.Query("type")
|
|
||||||
req_vmid := c.Query("vmid")
|
|
||||||
if req_node == "" || req_type == "" || req_vmid == "" {
|
|
||||||
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
|
|
||||||
}
|
|
||||||
vm_path := VMPath{
|
|
||||||
Node: req_node,
|
|
||||||
Type: req_type,
|
|
||||||
VMID: req_vmid,
|
|
||||||
}
|
|
||||||
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) {
|
|
||||||
config := InstanceConfig{}
|
config := InstanceConfig{}
|
||||||
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
|
||||||
ctx := common.RequestContext{
|
body := map[string]any{}
|
||||||
Cookies: map[string]string{
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
"username": auth.Username,
|
|
||||||
"PVEAuthCookie": auth.Token,
|
|
||||||
"CSRFPreventionToken": auth.CSRF,
|
|
||||||
},
|
|
||||||
Body: map[string]any{},
|
|
||||||
}
|
|
||||||
res, code, err := common.RequestGetAPI(path, ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
@@ -197,7 +193,7 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
|||||||
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
|
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mapstructure.Decode(ctx.Body, &config)
|
err = mapstructure.Decode(body, &config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
}
|
}
|
||||||
@@ -208,77 +204,53 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type GlobalConfig struct {
|
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
|
||||||
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{
|
cputypes := common.Select{
|
||||||
ID: "proctype",
|
ID: "proctype",
|
||||||
Required: true,
|
Required: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// get global resource config
|
// get global resource config
|
||||||
ctx := common.RequestContext{
|
body := map[string]any{}
|
||||||
Cookies: map[string]string{
|
|
||||||
"username": auth.Username,
|
|
||||||
"PVEAuthCookie": auth.Token,
|
|
||||||
"CSRFPreventionToken": auth.CSRF,
|
|
||||||
},
|
|
||||||
Body: map[string]any{},
|
|
||||||
}
|
|
||||||
path := "/global/config/resources"
|
path := "/global/config/resources"
|
||||||
res, code, err := common.RequestGetAPI(path, ctx)
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
global := GlobalConfig{}
|
globalConfig := GlobalConfig{}
|
||||||
err = mapstructure.Decode(ctx.Body["resources"], &global)
|
err = mapstructure.Decode(body["resources"], &globalConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// get user resource config
|
// get pool resource config
|
||||||
ctx.Body = map[string]any{}
|
body = map[string]any{}
|
||||||
path = "/user/config/resources"
|
path = fmt.Sprintf("/access/pools/%s", pool)
|
||||||
res, code, err = common.RequestGetAPI(path, ctx)
|
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
}
|
}
|
||||||
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{}
|
poolCPUConfig := PoolConfig{}
|
||||||
err = mapstructure.Decode(ctx.Body, &user)
|
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// use node specific rules if present, otherwise use global rules
|
// use node specific rules if present, otherwise use global rules
|
||||||
var userCPU []CPUConfig
|
var userCPU []paas.MatchLimit
|
||||||
if _, ok := user.CPU.Nodes[vm.Node]; ok {
|
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
|
||||||
userCPU = user.CPU.Nodes[vm.Node]
|
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
|
||||||
} else {
|
} 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
|
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
|
||||||
cputypes.Options = append(cputypes.Options, common.Option{
|
cputypes.Options = append(cputypes.Options, common.Option{
|
||||||
Display: cpu.Name,
|
Display: cpu.Name,
|
||||||
@@ -287,9 +259,9 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
|||||||
}
|
}
|
||||||
} else { // cpu is a blacklist
|
} else { // cpu is a blacklist
|
||||||
// get the supported cpu types from the node
|
// get the supported cpu types from the node
|
||||||
ctx.Body = map[string]any{}
|
body = map[string]any{}
|
||||||
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
|
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
|
||||||
res, code, err = common.RequestGetAPI(path, ctx)
|
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
return cputypes, err
|
||||||
}
|
}
|
||||||
@@ -297,16 +269,16 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
|||||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
}
|
}
|
||||||
supported := struct {
|
supported := struct {
|
||||||
data []CPUConfig
|
data []paas.MatchLimit
|
||||||
}{}
|
}{}
|
||||||
err = mapstructure.Decode(ctx.Body, supported)
|
err = mapstructure.Decode(body, supported)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cputypes, err
|
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 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 {
|
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
|
return c.Name == cpu.Name
|
||||||
})
|
})
|
||||||
if !contains {
|
if !contains {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package routes
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
paas "proxmoxaas-common-lib"
|
||||||
"proxmoxaas-dashboard/app/common"
|
"proxmoxaas-dashboard/app/common"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -10,48 +11,7 @@ import (
|
|||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func HandleGETIndex(c *gin.Context) {
|
// primary type used in constructing instance cards in index
|
||||||
auth, err := common.GetAuth(c)
|
|
||||||
if err == nil { // user should be authed, try to return index with population
|
|
||||||
instances, _, err := GetClusterResources(auth)
|
|
||||||
if err != nil {
|
|
||||||
common.HandleNonFatalError(c, err)
|
|
||||||
}
|
|
||||||
c.HTML(http.StatusOK, "html/index.html", gin.H{
|
|
||||||
"global": common.Global,
|
|
||||||
"page": "index",
|
|
||||||
"instances": instances,
|
|
||||||
})
|
|
||||||
} else { // return index without populating
|
|
||||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func HandleGETInstancesFragment(c *gin.Context) {
|
|
||||||
Auth, err := common.GetAuth(c)
|
|
||||||
if err == nil { // user should be authed, try to return index with population
|
|
||||||
instances, _, err := GetClusterResources(Auth)
|
|
||||||
if err != nil {
|
|
||||||
common.HandleNonFatalError(c, err)
|
|
||||||
}
|
|
||||||
c.Header("Content-Type", "text/plain")
|
|
||||||
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.frag", gin.H{
|
|
||||||
"instances": instances,
|
|
||||||
})
|
|
||||||
c.Status(http.StatusOK)
|
|
||||||
} else { // return 401
|
|
||||||
c.Status(http.StatusUnauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
type InstanceCard struct {
|
||||||
VMID uint
|
VMID uint
|
||||||
Name string
|
Name string
|
||||||
@@ -59,6 +19,15 @@ type InstanceCard struct {
|
|||||||
Status string
|
Status string
|
||||||
Node string
|
Node string
|
||||||
NodeStatus string
|
NodeStatus string
|
||||||
|
ConfigPath string
|
||||||
|
ConsolePath string
|
||||||
|
BackupsPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in constructing instance cards in index
|
||||||
|
type Node struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// used in retriving cluster tasks
|
// used in retriving cluster tasks
|
||||||
@@ -69,42 +38,80 @@ type Task struct {
|
|||||||
ID string
|
ID string
|
||||||
VMID uint
|
VMID uint
|
||||||
Status string
|
Status string
|
||||||
|
EndTime uint
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstanceStatus struct {
|
type InstanceStatus struct {
|
||||||
Status string
|
Status string
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
func HandleGETIndex(c *gin.Context) {
|
||||||
ctx := common.RequestContext{
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
Cookies: map[string]string{
|
if err == nil { // user should be authed, try to return index with population
|
||||||
"PVEAuthCookie": auth.Token,
|
instances, _, err := GetClusterResources(auth)
|
||||||
"CSRFPreventionToken": auth.CSRF,
|
if err != nil {
|
||||||
},
|
common.HandleNonFatalError(c, err)
|
||||||
Body: map[string]any{},
|
return
|
||||||
}
|
}
|
||||||
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx)
|
page := gin.H{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "index",
|
||||||
|
"instances": instances,
|
||||||
|
}
|
||||||
|
c.HTML(http.StatusOK, "html/index.html", page)
|
||||||
|
} else { // return index without populating
|
||||||
|
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETInstancesFragment(c *gin.Context) {
|
||||||
|
auth, err := common.GetAuthFromRequest(c)
|
||||||
|
if err == nil { // user should be authed, try to return index with population
|
||||||
|
instances, _, err := GetClusterResources(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/plain")
|
||||||
|
err = common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
|
||||||
|
"instances": instances,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
} else { // return 401
|
||||||
|
c.Status(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow
|
if code != 200 { // if we did not successfully retrieve resources, then return the error because auth was 1 but was invalid somehow
|
||||||
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
|
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
|
||||||
}
|
}
|
||||||
|
|
||||||
instances := map[uint]InstanceCard{}
|
instances := map[uint]InstanceCard{}
|
||||||
nodes := map[string]Node{}
|
nodes := map[string]Node{}
|
||||||
|
|
||||||
// if we successfully retrieved the resources, then process it and return index
|
// parse /proxmox/cluster/resources to separate instances and nodes
|
||||||
for _, v := range ctx.Body["data"].([]any) {
|
for _, v := range body {
|
||||||
m := v.(map[string]any)
|
m := v.(map[string]any)
|
||||||
if m["type"] == "node" {
|
switch m["type"] {
|
||||||
|
case "node": // if type is node -> parse as Node object
|
||||||
node := Node{}
|
node := Node{}
|
||||||
err := mapstructure.Decode(v, &node)
|
err := mapstructure.Decode(v, &node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
nodes[node.Node] = node
|
nodes[node.Node] = node
|
||||||
} else if m["type"] == "lxc" || m["type"] == "qemu" {
|
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
|
||||||
instance := InstanceCard{}
|
instance := InstanceCard{}
|
||||||
err := mapstructure.Decode(v, &instance)
|
err := mapstructure.Decode(v, &instance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,14 +120,27 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
instances[instance.VMID] = instance
|
instances[instance.VMID] = instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// once all basic instance and node stuff is parsed, go back and fill in cross referenced data
|
||||||
for vmid, instance := range instances {
|
for vmid, instance := range instances {
|
||||||
nodestatus := nodes[instance.Node].Status
|
// set instance's node status
|
||||||
instance.NodeStatus = nodestatus
|
instance.NodeStatus = nodes[instance.Node].Status
|
||||||
|
// set instance's config link path
|
||||||
|
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||||
|
// set the instance's console link path
|
||||||
|
switch instance.Type {
|
||||||
|
case "qemu":
|
||||||
|
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
|
||||||
|
case "lxc":
|
||||||
|
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
|
||||||
|
instance.BackupsPath = fmt.Sprintf("backups?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||||
|
// save back to instances map
|
||||||
instances[vmid] = instance
|
instances[vmid] = instance
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Body = map[string]any{}
|
body = []any{}
|
||||||
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx)
|
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -128,17 +148,25 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, v := range ctx.Body["data"].([]any) {
|
most_recent_task := map[uint]uint{}
|
||||||
|
expected_states := map[uint]string{}
|
||||||
|
|
||||||
|
// iterate through recent user accessible tasks to find the task most recently made on an instance
|
||||||
|
for _, v := range body {
|
||||||
|
// parse task as Task object
|
||||||
task := Task{}
|
task := Task{}
|
||||||
err := mapstructure.Decode(v, &task)
|
err := mapstructure.Decode(v, &task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// try to get task vmid but continue if it would be an invalid vmid
|
||||||
x, err := strconv.Atoi(task.ID)
|
x, err := strconv.Atoi(task.ID)
|
||||||
task.VMID = uint(x)
|
// if there was an error converting the task's vmid, skip it
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
continue
|
||||||
}
|
}
|
||||||
|
task.VMID = uint(x)
|
||||||
|
|
||||||
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
|
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
|
||||||
continue
|
continue
|
||||||
@@ -151,11 +179,26 @@ 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
|
||||||
|
switch task.Type {
|
||||||
|
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
|
||||||
|
expected_states[task.VMID] = "running"
|
||||||
|
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
|
||||||
|
expected_states[task.VMID] = "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterate through the instances with recent tasks, refetch their state from a more reliable source
|
||||||
|
for vmid, expected_state := range expected_states { // for the expected states from recent tasks
|
||||||
|
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{}
|
body := map[string]any{}
|
||||||
res, code, err := common.RequestGetAPI(path, ctx)
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
@@ -163,11 +206,15 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
|||||||
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
|
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attempt to decode task status as instance status
|
||||||
status := InstanceStatus{}
|
status := InstanceStatus{}
|
||||||
mapstructure.Decode(ctx.Body["data"], &status)
|
err = mapstructure.Decode(body, &status)
|
||||||
|
if err != nil { // did not successfully decode task status, just skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
instance.Status = status.Status
|
instance.Status = status.Status
|
||||||
instances[task.VMID] = instance
|
instances[vmid] = instance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,34 +9,6 @@ import (
|
|||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetLoginRealms() ([]Realm, error) {
|
|
||||||
realms := []Realm{}
|
|
||||||
|
|
||||||
ctx := common.RequestContext{
|
|
||||||
Cookies: nil,
|
|
||||||
Body: map[string]any{},
|
|
||||||
}
|
|
||||||
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx)
|
|
||||||
if err != nil {
|
|
||||||
return realms, err
|
|
||||||
}
|
|
||||||
if code != 200 { // we expect /access/domains to always be avaliable
|
|
||||||
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range ctx.Body["data"].([]any) {
|
|
||||||
v = v.(map[string]any)
|
|
||||||
realm := Realm{}
|
|
||||||
err := mapstructure.Decode(v, &realm)
|
|
||||||
if err != nil {
|
|
||||||
return realms, err
|
|
||||||
}
|
|
||||||
realms = append(realms, realm)
|
|
||||||
}
|
|
||||||
|
|
||||||
return realms, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// used when requesting GET /access/domains
|
// used when requesting GET /access/domains
|
||||||
type GetRealmsBody struct {
|
type GetRealmsBody struct {
|
||||||
Data []Realm `json:"data"`
|
Data []Realm `json:"data"`
|
||||||
@@ -49,6 +21,31 @@ type Realm struct {
|
|||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetLoginRealms() ([]Realm, error) {
|
||||||
|
realms := []Realm{}
|
||||||
|
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
|
||||||
|
if err != nil {
|
||||||
|
return realms, err
|
||||||
|
}
|
||||||
|
if code != 200 { // we expect /access/domains to always be avaliable
|
||||||
|
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range body {
|
||||||
|
v = v.(map[string]any)
|
||||||
|
realm := Realm{}
|
||||||
|
err := mapstructure.Decode(v, &realm)
|
||||||
|
if err != nil {
|
||||||
|
return realms, err
|
||||||
|
}
|
||||||
|
realms = append(realms, realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return realms, nil
|
||||||
|
}
|
||||||
|
|
||||||
func HandleGETLogin(c *gin.Context) {
|
func HandleGETLogin(c *gin.Context) {
|
||||||
realms, err := GetLoginRealms()
|
realms, err := GetLoginRealms()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func HandleGETSettings(c *gin.Context) {
|
func HandleGETSettings(c *gin.Context) {
|
||||||
_, err := common.GetAuth(c)
|
_, err := common.GetAuthFromRequest(c)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.HTML(http.StatusOK, "html/settings.html", gin.H{
|
c.HTML(http.StatusOK, "html/settings.html", gin.H{
|
||||||
"global": common.Global,
|
"global": common.Global,
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es2021": true
|
|
||||||
},
|
|
||||||
"extends": "standard",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-tabs": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allowIndentationTabs": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indent": [
|
|
||||||
"error",
|
|
||||||
"tab"
|
|
||||||
],
|
|
||||||
"linebreak-style": [
|
|
||||||
"error",
|
|
||||||
"unix"
|
|
||||||
],
|
|
||||||
"quotes": [
|
|
||||||
"error",
|
|
||||||
"double"
|
|
||||||
],
|
|
||||||
"semi": [
|
|
||||||
"error",
|
|
||||||
"always"
|
|
||||||
],
|
|
||||||
"brace-style": [
|
|
||||||
"error",
|
|
||||||
"stroustrup",
|
|
||||||
{
|
|
||||||
"allowSingleLine": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
|
||||||
|
export default defineConfig([js.configs.recommended,{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-tabs": ["error", {
|
||||||
|
allowIndentationTabs: true,
|
||||||
|
}],
|
||||||
|
indent: ["error", "tab"],
|
||||||
|
"linebreak-style": ["error", "unix"],
|
||||||
|
quotes: ["error", "double"],
|
||||||
|
semi: ["error", "always"],
|
||||||
|
"brace-style": ["error", "stroustrup", { allowSingleLine: false }],
|
||||||
|
"no-unused-vars": ["warn", {
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_"
|
||||||
|
}],
|
||||||
|
"prefer-const": ["error"]
|
||||||
|
},
|
||||||
|
}]);
|
||||||
@@ -1,51 +1,46 @@
|
|||||||
module proxmoxaas-dashboard
|
module proxmoxaas-dashboard
|
||||||
|
|
||||||
go 1.24
|
go 1.26.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/tdewolff/minify v2.3.6+incompatible
|
github.com/go-viper/mapstructure/v2 v2.5.0
|
||||||
proxmoxaas-fabric v0.0.0
|
github.com/tdewolff/minify/v2 v2.24.13
|
||||||
|
proxmoxaas-common-lib v0.0.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace proxmoxaas-fabric => ../ProxmoxAAS-Fabric
|
replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/buger/goterm v1.0.4 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.13.2 // indirect
|
github.com/bytedance/sonic v1.15.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
github.com/diskfs/go-diskfs v1.5.2 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/djherbis/times v1.6.0 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // 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.30.3 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/goccy/go-yaml v1.19.2 // 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/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // 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/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/magefile/mage v1.15.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
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.4.0 // indirect
|
||||||
github.com/tdewolff/minify/v2 v2.23.1 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/tdewolff/parse v2.3.4+incompatible // indirect
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/tdewolff/parse/v2 v2.7.23 // indirect
|
github.com/tdewolff/parse/v2 v2.8.13 // 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.3.1 // indirect
|
||||||
golang.org/x/arch v0.16.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.1 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
golang.org/x/arch v0.28.0 // indirect
|
||||||
golang.org/x/net v0.39.0 // indirect
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/sys v0.32.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.6 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "proxmoxaas-dashboard",
|
"name": "proxmoxaas-dashboard",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"description": "Front-end for ProxmoxAAS",
|
"description": "Front-end for ProxmoxAAS",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "html-validate --config configs/.htmlvalidate.json web/html/*; stylelint --config configs/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config configs/.eslintrc.json --fix web/scripts/",
|
"lint": "html-validate --config dev_config/.htmlvalidate.json web/html/*; stylelint --config dev_config/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config dev_config/eslint.config.mjs --fix web/scripts/",
|
||||||
"update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm"
|
"update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.43.0",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"eslint-config-standard": "^17.1.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"eslint-plugin-import": "^2.27.5",
|
"eslint": "^10.2.1",
|
||||||
"eslint-plugin-n": "^16.0.1",
|
"globals": "^17.5.0",
|
||||||
"eslint-plugin-promise": "^6.1.1",
|
"html-validate": "^9.4.0",
|
||||||
"stylelint": "^15.9.0",
|
"stylelint": "^15.9.0",
|
||||||
"stylelint-config-standard": "^33.0.0",
|
"stylelint-config-standard": "^33.0.0"
|
||||||
"html-validate": "^9.4.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
input, select, textarea {
|
||||||
|
background-color: var(--main-input-bg-color);
|
||||||
|
color: var(--main-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.input-grid {
|
.input-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px 10px;
|
gap: 0.5em 1em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -8,15 +13,15 @@
|
|||||||
.input-grid * {
|
.input-grid * {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
padding-top: 8px;
|
padding-top: 0.5em;
|
||||||
padding-bottom: 8px;
|
padding-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-grid input {
|
.input-grid input {
|
||||||
padding: 8px;
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-grid svg {
|
.input-grid img {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,17 +34,17 @@ legend {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
line-height: 1.5em;
|
margin-top: 0.25em;
|
||||||
margin-top: 0.25lh;
|
margin-bottom: 0.25em;
|
||||||
margin-bottom: 0.25lh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset > *:last-child {
|
fieldset > *:last-child {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset > .input-grid {
|
fieldset > .input-grid {
|
||||||
@@ -61,7 +66,7 @@ input[type="radio"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.w3-select, select {
|
.w3-select, select {
|
||||||
padding: 8px;
|
padding: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w3-check {
|
.w3-check {
|
||||||
@@ -72,11 +77,25 @@ input[type="radio"] {
|
|||||||
:not(.input-grid) .input-grid + * {
|
:not(.input-grid) .input-grid + * {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 5px;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
max-width: calc(min(50%, 80ch));
|
margin: auto;
|
||||||
background-color: var(--main-bg-color);
|
max-width: calc(min(100% - 16px, 80ch));
|
||||||
color: var(--main-text-color);
|
color: var(--main-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dialog #prompt {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog button[value="confirm"] {
|
||||||
|
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog button[value="cancel"] {
|
||||||
|
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 18px;
|
font-size: var(--small-font-size);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: var(--nav-header-bg-color);
|
background-color: var(--nav-header-bg-color);
|
||||||
color: var(--nav-header-text-color);
|
color: var(--nav-header-text-color);
|
||||||
@@ -61,15 +61,15 @@ header h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: larger;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a, header h1, label[for="navtoggle"] {
|
nav a, header h1, label[for="navtoggle"] {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 8px;
|
padding-left: 0.5em;
|
||||||
padding-right: 8px;
|
padding-right: 0.5em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
@@ -80,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width >= 600px){
|
@media screen and (width >= 601px){
|
||||||
header {
|
header {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width <= 600px){
|
@media screen and (width <= 601px){
|
||||||
header {
|
header {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
--positive-color: #0f0;
|
--positive-color: #0f0;
|
||||||
--highlight-color: yellow;
|
--highlight-color: yellow;
|
||||||
--lightbg-text-color: black;
|
--lightbg-text-color: black;
|
||||||
|
--large-font-size: 32px;
|
||||||
|
--medium-font-size: 24px;
|
||||||
|
--small-font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (prefers-color-scheme: dark) {
|
@media screen and (prefers-color-scheme: dark) {
|
||||||
@@ -11,7 +14,6 @@
|
|||||||
--main-text-color: white;
|
--main-text-color: white;
|
||||||
--main-card-bg-color: #202020;
|
--main-card-bg-color: #202020;
|
||||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
||||||
--main-table-header-bg-color: black;
|
|
||||||
--main-input-bg-color: #404040;
|
--main-input-bg-color: #404040;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +22,6 @@
|
|||||||
--main-text-color: black;
|
--main-text-color: black;
|
||||||
--main-card-bg-color: white;
|
--main-card-bg-color: white;
|
||||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||||
--main-table-header-bg-color: #808080;
|
|
||||||
--main-input-bg-color: white;
|
--main-input-bg-color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +32,6 @@
|
|||||||
--main-text-color: black;
|
--main-text-color: black;
|
||||||
--main-card-bg-color: white;
|
--main-card-bg-color: white;
|
||||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||||
--main-table-header-bg-color: #808080;
|
|
||||||
--main-input-bg-color: white;
|
--main-input-bg-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +40,43 @@
|
|||||||
--main-text-color: white;
|
--main-text-color: white;
|
||||||
--main-card-bg-color: #202020;
|
--main-card-bg-color: #202020;
|
||||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
||||||
--main-table-header-bg-color: black;
|
|
||||||
--main-input-bg-color: #404040;
|
--main-input-bg-color: #404040;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
*, h1, h2, h3, h4, h5, h6, p {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--main-bg-color);
|
font-family: monospace;
|
||||||
|
line-height: normal;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
h2 {
|
||||||
font-family: monospace;
|
font-size: var(--large-font-size);
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--medium-font-size);
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4, legend {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--main-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -77,33 +102,24 @@ main {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: var(--main-table-header-bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
background-color: var(--main-card-bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
input, select, textarea {
|
|
||||||
background-color: var(--main-input-bg-color);
|
|
||||||
color: var(--main-text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
img.clickable, svg.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
img, svg {
|
img, svg {
|
||||||
height: 1em;
|
height: 1em;
|
||||||
width: 1em;
|
width: 1em;
|
||||||
color: var(--main-text-color)
|
color: var(--main-text-color)
|
||||||
}
|
}
|
||||||
|
|
||||||
hr, * {
|
a img {
|
||||||
|
vertical-align: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
border-color: var(--main-text-color);
|
border-color: var(--main-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -114,6 +130,12 @@ hr, * {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.column-reverse {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
row-gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
row-gap: 10px;
|
row-gap: 10px;
|
||||||
@@ -156,18 +178,26 @@ hr, * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* add hide large class similar to w3-hide-medium and w3-hide-small */
|
/* add hide large class similar to w3-hide-medium and w3-hide-small */
|
||||||
@media (width >=993px) {
|
@media screen and (width >=993px) {
|
||||||
.w3-hide-large {
|
.hide-large {display: none !important;}
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* fix edge case in w3-hide-medium where width between 992 and 993 */
|
/* fixes edge case in w3-hide-medium where width between 992 and 993 */
|
||||||
@media (width <=993px) and (width >=601px){
|
@media screen and (width <=993px) and (width >=601px){
|
||||||
.w3-hide-medium{display:none!important}
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* fix edge case in w3-hide-small when width between 600 and 601 */
|
/* fixes edge case in w3-hide-small when width between 600 and 601 */
|
||||||
@media (width <=601px) {
|
@media screen and (width <=601px) {
|
||||||
.w3-hide-small{display:none!important}
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (width <= 440px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
.hide-tiny { display: none !important;}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="modulepreload" href="scripts/dialog.js">
|
<link rel="modulepreload" href="scripts/dialog.js">
|
||||||
<style>
|
<style>
|
||||||
@media screen and (width >= 1264px){
|
@media screen and (width >= 1264px){
|
||||||
#resource-container {
|
.resource-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, calc(100% / 6));
|
grid-template-columns: repeat(auto-fill, calc(100% / 6));
|
||||||
grid-gap: 0;
|
grid-gap: 0;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (width <= 1264px) and (width >= 680px) {
|
@media screen and (width <= 1264px) and (width >= 680px) {
|
||||||
#resource-container {
|
.resource-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, 200px);
|
grid-template-columns: repeat(auto-fill, 200px);
|
||||||
grid-gap: 0;
|
grid-gap: 0;
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (width <= 680px) {
|
@media screen and (width <= 680px) {
|
||||||
#resource-container {
|
.resource-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -34,44 +34,40 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
</header>
|
|
||||||
<main>
|
<main>
|
||||||
<h2>Account</h2>
|
<h2>Account</h2>
|
||||||
<section class="w3-card w3-padding">
|
<section class="w3-card w3-padding">
|
||||||
<h3>Account Details</h3>
|
<h3>Account Details</h3>
|
||||||
<p id="username">Username: {{.account.Username}}</p>
|
<p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
|
||||||
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
|
<p id="email">Email: {{.account.Mail}}</p>
|
||||||
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
|
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
|
||||||
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
|
|
||||||
</section>
|
</section>
|
||||||
<section class="w3-card w3-padding">
|
{{range $poolname, $pool := .account.Pools}}
|
||||||
<div class="flex row nowrap">
|
{{template "pool-resources" $pool}}
|
||||||
<h3>Password</h3>
|
|
||||||
<button class="w3-button w3-margin" id="change-password" type="button">Change Password</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="w3-card w3-padding">
|
|
||||||
<h3>Cluster Resources</h3>
|
|
||||||
<div id="resource-container">
|
|
||||||
{{range .account.Resources}}
|
|
||||||
{{if .Display}}
|
|
||||||
{{if eq .Type "numeric"}}
|
|
||||||
{{template "resource-chart" .}}
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Type "storage"}}
|
|
||||||
{{template "resource-chart" .}}
|
|
||||||
{{end}}
|
|
||||||
{{if eq .Type "list"}}
|
|
||||||
{{range .Resources}}
|
|
||||||
{{template "resource-chart" .}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
<template id="change-password-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Change Password
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="new-password">New Password</label>
|
||||||
|
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" required>
|
||||||
|
<label for="confirm-password">Confirm Password</label>
|
||||||
|
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{range $i, $x := .backups}}
|
||||||
|
{{template "backup-card" $x}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="scripts/backups.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="scripts/dialog.js">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main>
|
||||||
|
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
|
||||||
|
<section class="w3-card w3-padding">
|
||||||
|
<div class="w3-row" style="border-bottom: 1px solid;">
|
||||||
|
<p class="w3-col l2 m4 s8">Time</p>
|
||||||
|
<p class="w3-col l6 m6 hide-small">Notes</p>
|
||||||
|
<p class="w3-col l2 hide-medium">Size</p>
|
||||||
|
<p class="w3-col l2 m2 s4">Actions</p>
|
||||||
|
</div>
|
||||||
|
<div id="backups-container">
|
||||||
|
{{range $i, $x := .backups}}
|
||||||
|
{{template "backup-card" $x}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="w3-container w3-center">
|
||||||
|
{{template "backups-add-backup" .}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="w3-container w3-center">
|
||||||
|
<a class="w3-button w3-margin" id="exit" href="index">EXIT</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{template "volumes" .config.Volumes}}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
<script src="scripts/config.js" type="module"></script>
|
<script src="scripts/config.js" type="module"></script>
|
||||||
<script src="scripts/draggable.js" type="module"></script>
|
<script src="scripts/draggable.js" type="module"></script>
|
||||||
<script src="modules/Sortable.min.js"></script>
|
<script src="modules/Sortable.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
|
||||||
<link rel="modulepreload" href="scripts/utils.js">
|
<link rel="modulepreload" href="scripts/utils.js">
|
||||||
<link rel="modulepreload" href="scripts/dialog.js">
|
<link rel="modulepreload" href="scripts/dialog.js">
|
||||||
<style>
|
<style>
|
||||||
@@ -20,13 +19,11 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
</header>
|
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
|
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</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;">
|
||||||
@@ -43,18 +40,14 @@
|
|||||||
<fieldset class="w3-card w3-padding">
|
<fieldset class="w3-card w3-padding">
|
||||||
<legend>Volumes</legend>
|
<legend>Volumes</legend>
|
||||||
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
|
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
|
||||||
{{template "volumes" .config.Volumes}}
|
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-container w3-center">
|
<div class="w3-container w3-center">
|
||||||
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
|
<!--Add Disk Button & Dialog Template-->
|
||||||
<span class="large" style="margin: 0;">Add Disk</span>
|
{{template "volumes-add-disk" .}}
|
||||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg>
|
<!--Add CD Button & Dialog Template-->
|
||||||
</button>
|
|
||||||
{{if eq .config.Type "VM"}}
|
{{if eq .config.Type "VM"}}
|
||||||
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD">
|
{{template "volumes-add-cd"}}
|
||||||
<span class="large" style="margin: 0;">Mount CD</span>
|
|
||||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
|
|
||||||
</button>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -64,10 +57,8 @@
|
|||||||
{{template "nets" .config.Nets}}
|
{{template "nets" .config.Nets}}
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-container w3-center">
|
<div class="w3-container w3-center">
|
||||||
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
|
<!--Add Net Button & Dialog Template-->
|
||||||
<span class="large" style="margin: 0;">Add Network</span>
|
{{template "nets-add-net"}}
|
||||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Network Interface"><use href="images/actions/network/add.svg#symb"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{{if eq .config.Type "VM"}}
|
{{if eq .config.Type "VM"}}
|
||||||
@@ -77,10 +68,8 @@
|
|||||||
{{template "devices" .config.Devices}}
|
{{template "devices" .config.Devices}}
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-container w3-center">
|
<div class="w3-container w3-center">
|
||||||
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
|
<!--Add Device Button & Dialog Template-->
|
||||||
<span class="large" style="margin: 0;">Add Device</span>
|
{{template "devices-add-device"}}
|
||||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New PCIe Device"><use href="images/actions/device/add.svg#symb"></use></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset class="w3-card w3-padding">
|
<fieldset class="w3-card w3-padding">
|
||||||
@@ -91,7 +80,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>
|
||||||
|
|||||||
@@ -8,12 +8,6 @@
|
|||||||
<link rel="modulepreload" href="scripts/dialog.js">
|
<link rel="modulepreload" href="scripts/dialog.js">
|
||||||
<link rel="modulepreload" href="scripts/clientsync.js">
|
<link rel="modulepreload" href="scripts/clientsync.js">
|
||||||
<style>
|
<style>
|
||||||
#instance-container > div {
|
|
||||||
border-bottom: 1px solid white;
|
|
||||||
}
|
|
||||||
#instance-container > div:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
@media screen and (width >= 440px) {
|
@media screen and (width >= 440px) {
|
||||||
#vm-search {
|
#vm-search {
|
||||||
max-width: calc(100% - 10px - 152px);
|
max-width: calc(100% - 10px - 152px);
|
||||||
@@ -24,36 +18,125 @@
|
|||||||
max-width: calc(100% - 10px - 47px);
|
max-width: calc(100% - 10px - 47px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media screen and (width >= 993px) {
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 993px) and (width >= 601px){
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 601px) and (width >= 440px){
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 440px) {
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
</header>
|
|
||||||
<main>
|
<main>
|
||||||
<section>
|
<section>
|
||||||
<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>
|
<button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button>
|
||||||
<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>
|
||||||
|
<!--Add Instance Button & Dialog Template-->
|
||||||
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
||||||
<span class="large" style="margin: 0;">Create Instance</span>
|
<span class="large" style="margin: 0;">Create Instance</span>
|
||||||
<svg class="small" style="height: 1lh; width: 1lh;" role="img" aria-label="Create New Instance"><use href="images/actions/instance/add.svg#symb"></use></svg>
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Create Instance" aria-label="Create Instance" src="images/actions/instance/add.svg#symb">
|
||||||
</button>
|
</button>
|
||||||
|
<template id="create-instance-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
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="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="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>
|
||||||
<div>
|
<div id="controls" class="w3-center w3-container">
|
||||||
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;">
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
<p class="w3-col l1 m2 w3-hide-small">ID</p>
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
<p class="w3-col l2 m3 w3-hide-small">Name</p>
|
|
||||||
<p class="w3-col l1 m2 w3-hide-small">Type</p>
|
|
||||||
<p class="w3-col l2 m3 w3-hide-small">Status</p>
|
|
||||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Name</p>
|
|
||||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Status</p>
|
|
||||||
<p class="w3-col l2 m2 w3-hide-small">Actions</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div id="instance-table">
|
||||||
|
<div id="instance-table-header">
|
||||||
|
<p>ID</p>
|
||||||
|
<p>Name</p>
|
||||||
|
<p class="hide-tiny">Type</p>
|
||||||
|
<p class="hide-small">Status</p>
|
||||||
|
<p class="hide-medium">Host Name</p>
|
||||||
|
<p class="hide-medium">Host Status</p>
|
||||||
|
<p>Actions</p>
|
||||||
|
</div>
|
||||||
|
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
|
||||||
<div id="instance-container">
|
<div id="instance-container">
|
||||||
{{range .instances}}
|
{{range .instances}}
|
||||||
{{template "instance-card" .}}
|
{{template "instance-card" .}}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@
|
|||||||
<link rel="modulepreload" href="scripts/dialog.js">
|
<link rel="modulepreload" href="scripts/dialog.js">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
</header>
|
|
||||||
<main class="flex" style="justify-content: center; align-items: center;">
|
<main class="flex" style="justify-content: center; align-items: center;">
|
||||||
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||||
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
||||||
|
|||||||
@@ -5,30 +5,25 @@
|
|||||||
<script src="scripts/settings.js" type="module"></script>
|
<script src="scripts/settings.js" type="module"></script>
|
||||||
<link rel="modulepreload" href="scripts/utils.js">
|
<link rel="modulepreload" href="scripts/utils.js">
|
||||||
<style>
|
<style>
|
||||||
legend {
|
|
||||||
margin-bottom: 25px;
|
|
||||||
}
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: left;
|
justify-content: left;
|
||||||
column-gap: 10px;
|
column-gap: 0.5em;
|
||||||
}
|
}
|
||||||
label + p {
|
label + p {
|
||||||
margin-top: 5px;
|
margin-top: 0.5em;
|
||||||
margin-bottom: 25px;
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
p:last-child {
|
p:last-child {
|
||||||
margin-bottom: 0px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
|
||||||
{{template "header" .}}
|
{{template "header" .}}
|
||||||
</header>
|
|
||||||
<main>
|
<main>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form id="settings">
|
<form id="settings">
|
||||||
@@ -42,6 +37,8 @@
|
|||||||
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
|
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
|
||||||
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
|
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
|
||||||
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
|
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
|
||||||
|
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
|
||||||
|
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>App Sync Frequency</legend>
|
<legend>App Sync Frequency</legend>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/config.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" aria-label="config" viewBox="4 2 17 19" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M10 8H5V3m.291 13.357a8 8 0 10.188-8.991" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="#808080"/></svg>
|
||||||
|
After Width: | Height: | Size: 540 B |
@@ -1 +0,0 @@
|
|||||||
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 32 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/config-inactive.svg
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 32 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="instance console" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg>
|
<svg id="symb" role="img" aria-label="instance console" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg>
|
||||||
|
Before Width: | Height: | Size: 565 B After Width: | Height: | Size: 565 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg>
|
<svg id="symb" role="img" aria-label="" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg>
|
||||||
|
Before Width: | Height: | Size: 458 B After Width: | Height: | Size: 458 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="2.8 2.4 12 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg>
|
<svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="4.2 2.4 9 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg>
|
||||||
|
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 237 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 380 380"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg>
|
<svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 350 350"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg>
|
||||||
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 310 B |
|
After Width: | Height: | Size: 87 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="cpu" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>
|
||||||
|
After Width: | Height: | Size: 104 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="config device" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg>
|
<svg id="symb" role="img" aria-label="config" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="delete" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg id="symb" role="img" aria-label="delete" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Before Width: | Height: | Size: 310 B After Width: | Height: | Size: 310 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
<svg id="symb" role="img" aria-label="" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
Before Width: | Height: | Size: 307 B After Width: | Height: | Size: 307 B |
@@ -1 +1 @@
|
|||||||
<svg id="symb" xmlns="http://www.w3.org/2000/svg"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg>
|
<svg id="symb" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg>
|
||||||
|
Before Width: | Height: | Size: 195 B After Width: | Height: | Size: 215 B |
@@ -1,4 +1,4 @@
|
|||||||
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
|
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
|
||||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
|
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
|
||||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
|
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
|
||||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||||
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
|
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
|
||||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
|
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
|
||||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
|
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
|
||||||
|
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
|
||||||
|
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
|
||||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
|
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
|
||||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
|
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
|
||||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
|
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
|
||||||
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
|
.w3-button:hover{color:#000!important;background-color:#ccc!important}
|
||||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
||||||
.w3-hover-none:hover{box-shadow:none!important}
|
.w3-hover-none:hover{box-shadow:none!important}
|
||||||
|
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
|
||||||
/* Colors */
|
/* Colors */
|
||||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
|
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
|
||||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
|
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
|
||||||
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
|
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
|
||||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
|
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
|
||||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
|
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
|
||||||
|
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
|
||||||
|
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
|
||||||
|
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
|
||||||
|
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
|
||||||
|
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
|
||||||
|
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
|
||||||
|
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
|
||||||
|
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
|
||||||
|
.w3-danger{color:#fff!important;background-color:#dd0000!important}
|
||||||
|
.w3-note{color:#000!important;background-color:#fff599!important}
|
||||||
|
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
|
||||||
|
.w3-warning{color:#000!important;background-color:#ffb305!important}
|
||||||
|
.w3-success{color:#fff!important;background-color:#008a00!important}
|
||||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
|
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
|
||||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
|
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
|
||||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
|
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import { dialog } from "./dialog.js";
|
|
||||||
import { requestAPI, setAppearance } from "./utils.js";
|
import { requestAPI, setAppearance } from "./utils.js";
|
||||||
|
import { dialog } from "./dialog.js";
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", init);
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
async function init () {
|
async function init () {
|
||||||
setAppearance();
|
setAppearance();
|
||||||
|
|
||||||
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm);
|
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePasswordChangeForm () {
|
function handlePasswordChangeButton () {
|
||||||
const body = `
|
const template = document.querySelector("#change-password-dialog");
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
const d = dialog(template, async (result, form) => {
|
||||||
<label for="new-password">New Password</label>
|
|
||||||
<input class="w3-input w3-border" id="new-password" name="new-password" type="password"required>
|
|
||||||
<label for="confirm-password">Confirm Password</label>
|
|
||||||
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
const d = dialog("Change Password", body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
|
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200) {
|
||||||
@@ -29,11 +22,9 @@ function handlePasswordChangeForm () {
|
|||||||
|
|
||||||
const password = d.querySelector("#new-password");
|
const password = d.querySelector("#new-password");
|
||||||
const confirmPassword = d.querySelector("#confirm-password");
|
const confirmPassword = d.querySelector("#confirm-password");
|
||||||
|
|
||||||
function validatePassword () {
|
function validatePassword () {
|
||||||
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
password.addEventListener("change", validatePassword);
|
password.addEventListener("change", validatePassword);
|
||||||
confirmPassword.addEventListener("keyup", validatePassword);
|
confirmPassword.addEventListener("keyup", validatePassword);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
|
||||||
|
import { alert, dialog } from "./dialog.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
let node;
|
||||||
|
let type;
|
||||||
|
let vmid;
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
const uriData = getURIData();
|
||||||
|
node = uriData.node;
|
||||||
|
type = uriData.type;
|
||||||
|
vmid = uriData.vmid;
|
||||||
|
|
||||||
|
document.querySelector("#backup-add").addEventListener("click", handleBackupAddButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupCard extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
|
||||||
|
const editButton = this.shadowRoot.querySelector("#edit-btn");
|
||||||
|
if (editButton.classList.contains("clickable")) {
|
||||||
|
editButton.onclick = this.handleEditButton.bind(this);
|
||||||
|
editButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.editButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||||
|
if (deleteButton.classList.contains("clickable")) {
|
||||||
|
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||||
|
deleteButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleDeleteButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreButton = this.shadowRoot.querySelector("#restore-btn");
|
||||||
|
if (restoreButton.classList.contains("clickable")) {
|
||||||
|
restoreButton.onclick = this.handleRestoreButton.bind(this);
|
||||||
|
restoreButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleRestoreButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get volid () {
|
||||||
|
return this.dataset.volid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#edit-dialog");
|
||||||
|
dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid,
|
||||||
|
notes: form.get("notes")
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
alert(`Attempted to edit backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
alert(`Attempted to delete backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRestoreButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#restore-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
alert(`Attempted to delete backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("backup-card", BackupCard);
|
||||||
|
|
||||||
|
async function getBackupsFragment () {
|
||||||
|
return await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBackups () {
|
||||||
|
let backups = await getBackupsFragment();
|
||||||
|
if (backups.status !== 200) {
|
||||||
|
alert("Error fetching backups.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
backups = backups.data;
|
||||||
|
const container = document.querySelector("#backups-container");
|
||||||
|
container.setHTMLUnsafe(backups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupAddButton () {
|
||||||
|
const template = document.querySelector("#create-backup-dialog");
|
||||||
|
dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
notes: form.get("notes")
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
alert(`Attempted to create backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { getSyncSettings, requestAPI } from "./utils.js";
|
|||||||
|
|
||||||
export async function setupClientSync (callback) {
|
export async function setupClientSync (callback) {
|
||||||
const { scheme, rate } = getSyncSettings();
|
const { scheme, rate } = getSyncSettings();
|
||||||
|
if (scheme === "never") {
|
||||||
if (scheme === "always") {
|
return;
|
||||||
|
}
|
||||||
|
else if (scheme === "always") {
|
||||||
window.setInterval(callback, rate * 1000);
|
window.setInterval(callback, rate * 1000);
|
||||||
}
|
}
|
||||||
else if (scheme === "hash") {
|
else if (scheme === "hash") {
|
||||||
@@ -19,7 +21,7 @@ export async function setupClientSync (callback) {
|
|||||||
}
|
}
|
||||||
else if (scheme === "interrupt") {
|
else if (scheme === "interrupt") {
|
||||||
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/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.send(`rate ${rate}`);
|
||||||
});
|
});
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setSVGSrc, requestDash } from "./utils.js";
|
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js";
|
||||||
import { alert, dialog } from "./dialog.js";
|
import { alert, dialog } from "./dialog.js";
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", init);
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -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 {
|
||||||
@@ -29,6 +29,7 @@ class VolumeAction extends HTMLElement {
|
|||||||
super();
|
super();
|
||||||
const internals = this.attachInternals();
|
const internals = this.attachInternals();
|
||||||
this.shadowRoot = internals.shadowRoot;
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
if (this.dataset.type === "move") {
|
if (this.dataset.type === "move") {
|
||||||
this.addEventListener("click", this.handleDiskMove);
|
this.addEventListener("click", this.handleDiskMove);
|
||||||
}
|
}
|
||||||
@@ -47,15 +48,13 @@ class VolumeAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setStatusLoading () {
|
async setStatusLoading () {
|
||||||
const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`);
|
const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
|
||||||
setSVGSrc(svg, "images/status/loading.svg");
|
setIconSrc(icon, "images/status/loading.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDiskDetach () {
|
async handleDiskDetach () {
|
||||||
const disk = this.dataset.volume;
|
const disk = this.dataset.volume;
|
||||||
const header = `Detach ${disk}`;
|
dialog(this.template, async (result, _form) => {
|
||||||
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
||||||
@@ -69,20 +68,13 @@ class VolumeAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleDiskAttach () {
|
async handleDiskAttach () {
|
||||||
const header = `Attach ${this.dataset.volume}`;
|
dialog(this.template, async (result, form) => {
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label>
|
|
||||||
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" required>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const device = form.get("device");
|
const device = form.get("device");
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const body = {
|
const body = {
|
||||||
source: this.dataset.volume.replace("unused", "")
|
source: this.dataset.volume.replace("unused", ""),
|
||||||
|
mp: form.get("mp")
|
||||||
};
|
};
|
||||||
const prefix = type === "qemu" ? "scsi" : "mp";
|
const prefix = type === "qemu" ? "scsi" : "mp";
|
||||||
const disk = `${prefix}${device}`;
|
const disk = `${prefix}${device}`;
|
||||||
@@ -97,15 +89,7 @@ class VolumeAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleDiskResize () {
|
async handleDiskResize () {
|
||||||
const header = `Resize ${this.dataset.volume}`;
|
dialog(this.template, async (result, form) => {
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="size-increment">Size Increment (GiB)</label>
|
|
||||||
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072">
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const disk = this.dataset.volume;
|
const disk = this.dataset.volume;
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
@@ -123,25 +107,7 @@ class VolumeAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleDiskMove () {
|
async handleDiskMove () {
|
||||||
const content = type === "qemu" ? "images" : "rootdir";
|
const d = dialog(this.template, async (result, form) => {
|
||||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
|
||||||
const header = `Move ${this.dataset.volume}`;
|
|
||||||
let options = "";
|
|
||||||
storage.data.forEach((element) => {
|
|
||||||
if (element.content.includes(content)) {
|
|
||||||
options += `<option value="${element.storage}">${element.storage}</option>"`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"><option hidden disabled selected value></option>${options}</select>`;
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
${select}
|
|
||||||
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const disk = this.dataset.volume;
|
const disk = this.dataset.volume;
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
@@ -157,13 +123,20 @@ class VolumeAction extends HTMLElement {
|
|||||||
refreshBoot();
|
refreshBoot();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const content = type === "qemu" ? "images" : "rootdir";
|
||||||
|
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||||
|
const select = d.querySelector("#storage-select");
|
||||||
|
storage.data.forEach((element) => {
|
||||||
|
if (element.content.includes(content)) {
|
||||||
|
select.add(new Option(element.storage));
|
||||||
|
}
|
||||||
|
select.selectedIndex = -1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDiskDelete () {
|
async handleDiskDelete () {
|
||||||
const disk = this.dataset.volume;
|
const disk = this.dataset.volume;
|
||||||
const header = `Delete ${disk}`;
|
dialog(this.template, async (result, _form) => {
|
||||||
const body = `<p>Are you sure you want to <strong>delete</strong> disk ${disk}</p>`;
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
||||||
@@ -201,26 +174,8 @@ async function refreshVolumes () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDiskAdd () {
|
async function handleDiskAdd () {
|
||||||
const content = type === "qemu" ? "images" : "rootdir";
|
const template = document.querySelector("#add-disk-dialog");
|
||||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
const d = dialog(template, async (result, form) => {
|
||||||
const header = "Create New Disk";
|
|
||||||
let options = "";
|
|
||||||
storage.data.forEach((element) => {
|
|
||||||
if (element.content.includes(content)) {
|
|
||||||
options += `<option value="${element.storage}">${element.storage}</option>"`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${options}</select>`;
|
|
||||||
|
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" value="0" required>
|
|
||||||
${select}
|
|
||||||
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const body = {
|
const body = {
|
||||||
storage: form.get("storage-select"),
|
storage: form.get("storage-select"),
|
||||||
@@ -237,19 +192,21 @@ async function handleDiskAdd () {
|
|||||||
refreshBoot();
|
refreshBoot();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const content = type === "qemu" ? "images" : "rootdir";
|
||||||
|
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||||
|
const select = d.querySelector("#storage-select");
|
||||||
|
storage.data.forEach((element) => {
|
||||||
|
if (element.content.includes(content)) {
|
||||||
|
select.add(new Option(element.storage));
|
||||||
|
}
|
||||||
|
select.selectedIndex = -1;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCDAdd () {
|
async function handleCDAdd () {
|
||||||
const isos = await requestAPI("/user/vm-isos", "GET");
|
const template = document.querySelector("#add-cd-dialog");
|
||||||
const header = "Mount a CDROM";
|
const d = dialog(template, async (result, form) => {
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required>
|
|
||||||
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const d = dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const body = {
|
const body = {
|
||||||
iso: form.get("iso-select")
|
iso: form.get("iso-select")
|
||||||
@@ -264,12 +221,13 @@ async function handleCDAdd () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isoSelect = d.querySelector("#iso-select");
|
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) {
|
||||||
isoSelect.append(new Option(iso.name, iso.volid));
|
select.add(new Option(iso.name, iso.volid));
|
||||||
}
|
}
|
||||||
isoSelect.selectedIndex = -1;
|
select.selectedIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
class NetworkAction extends HTMLElement {
|
class NetworkAction extends HTMLElement {
|
||||||
@@ -279,6 +237,7 @@ class NetworkAction extends HTMLElement {
|
|||||||
super();
|
super();
|
||||||
const internals = this.attachInternals();
|
const internals = this.attachInternals();
|
||||||
this.shadowRoot = internals.shadowRoot;
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
if (this.dataset.type === "config") {
|
if (this.dataset.type === "config") {
|
||||||
this.addEventListener("click", this.handleNetworkConfig);
|
this.addEventListener("click", this.handleNetworkConfig);
|
||||||
}
|
}
|
||||||
@@ -288,21 +247,14 @@ class NetworkAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setStatusLoading () {
|
async setStatusLoading () {
|
||||||
const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`);
|
const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
|
||||||
setSVGSrc(svg, "images/status/loading.svg");
|
setIconSrc(icon, "images/status/loading.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleNetworkConfig () {
|
async handleNetworkConfig () {
|
||||||
const netID = this.dataset.network;
|
|
||||||
const netDetails = this.dataset.value;
|
const netDetails = this.dataset.value;
|
||||||
const header = `Edit ${netID}`;
|
const netID = this.dataset.network;
|
||||||
const body = `
|
const d = dialog(this.template, async (result, form) => {
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const d = dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const body = {
|
const body = {
|
||||||
@@ -323,11 +275,9 @@ class NetworkAction extends HTMLElement {
|
|||||||
|
|
||||||
async handleNetworkDelete () {
|
async handleNetworkDelete () {
|
||||||
const netID = this.dataset.network;
|
const netID = this.dataset.network;
|
||||||
const header = `Delete ${netID}`;
|
dialog(this.template, async (result, _form) => {
|
||||||
const body = "";
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
|
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
|
||||||
const net = `${netID}`;
|
const net = `${netID}`;
|
||||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
|
||||||
if (result.status !== 200) {
|
if (result.status !== 200) {
|
||||||
@@ -361,17 +311,8 @@ async function refreshNetworks () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleNetworkAdd () {
|
async function handleNetworkAdd () {
|
||||||
const header = "Create Network Interface";
|
const template = document.querySelector("#add-net-dialog");
|
||||||
let body = `
|
dialog(template, async (result, form) => {
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
|
|
||||||
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
|
||||||
`;
|
|
||||||
if (type === "lxc") {
|
|
||||||
body += "<label for=\"name\">Interface Name</label><input type=\"text\" id=\"name\" name=\"name\" class=\"w3-input w3-border\">";
|
|
||||||
}
|
|
||||||
body += "</form>";
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const body = {
|
const body = {
|
||||||
rate: form.get("rate")
|
rate: form.get("rate")
|
||||||
@@ -398,6 +339,7 @@ class DeviceAction extends HTMLElement {
|
|||||||
super();
|
super();
|
||||||
const internals = this.attachInternals();
|
const internals = this.attachInternals();
|
||||||
this.shadowRoot = internals.shadowRoot;
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
if (this.dataset.type === "config") {
|
if (this.dataset.type === "config") {
|
||||||
this.addEventListener("click", this.handleDeviceConfig);
|
this.addEventListener("click", this.handleDeviceConfig);
|
||||||
}
|
}
|
||||||
@@ -407,22 +349,15 @@ class DeviceAction extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setStatusLoading () {
|
async setStatusLoading () {
|
||||||
const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`);
|
const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
|
||||||
setSVGSrc(svg, "images/status/loading.svg");
|
setIconSrc(icon, "images/status/loading.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDeviceConfig () {
|
async handleDeviceConfig () {
|
||||||
const deviceID = this.dataset.device;
|
const deviceID = this.dataset.device;
|
||||||
const deviceDetails = this.dataset.value;
|
const deviceDetails = this.dataset.value;
|
||||||
const deviceName = this.dataset.name;
|
const deviceName = this.dataset.name;
|
||||||
const header = `Edit Expansion Card ${deviceID}`;
|
const d = dialog(this.template, async (result, form) => {
|
||||||
const body = `
|
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const d = dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const body = {
|
const body = {
|
||||||
@@ -440,7 +375,7 @@ class DeviceAction extends HTMLElement {
|
|||||||
|
|
||||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||||
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
|
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("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||||
}
|
}
|
||||||
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
||||||
@@ -448,9 +383,7 @@ class DeviceAction extends HTMLElement {
|
|||||||
|
|
||||||
async handleDeviceDelete () {
|
async handleDeviceDelete () {
|
||||||
const deviceID = this.dataset.device;
|
const deviceID = this.dataset.device;
|
||||||
const header = `Remove Expansion Card ${deviceID}`;
|
dialog(this.template, async (result, _form) => {
|
||||||
const body = "";
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.setStatusLoading();
|
this.setStatusLoading();
|
||||||
const device = `${deviceID}`;
|
const device = `${deviceID}`;
|
||||||
@@ -487,15 +420,8 @@ async function refreshDevices () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeviceAdd () {
|
async function handleDeviceAdd () {
|
||||||
const header = "Add Expansion Card";
|
const template = document.querySelector("#add-device-dialog");
|
||||||
const body = `
|
const d = dialog(template, async (result, form) => {
|
||||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
|
||||||
<label for="hostpci">Device Bus</label><input type="number" id="hostpci" name="hostpci" class="w3-input w3-border">
|
|
||||||
<label for="device">Device</label><select id="device" name="device" required></select>
|
|
||||||
<label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
|
|
||||||
</form>
|
|
||||||
`;
|
|
||||||
const d = dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
const hostpci = form.get("hostpci");
|
const hostpci = form.get("hostpci");
|
||||||
const body = {
|
const body = {
|
||||||
@@ -511,8 +437,8 @@ async function handleDeviceAdd () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
|
||||||
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("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||||
}
|
}
|
||||||
d.querySelector("#pcie").checked = true;
|
d.querySelector("#pcie").checked = true;
|
||||||
@@ -523,14 +449,15 @@ async function refreshBoot () {
|
|||||||
if (boot.status !== 200) {
|
if (boot.status !== 200) {
|
||||||
alert("Error fetching instance boot order.");
|
alert("Error fetching instance boot order.");
|
||||||
}
|
}
|
||||||
else {
|
else if (type === "qemu") {
|
||||||
boot = boot.data;
|
boot = boot.data;
|
||||||
const order = document.querySelector("#boot-order");
|
const order = document.querySelector("#boot-order");
|
||||||
order.setHTMLUnsafe(boot);
|
order.setHTMLUnsafe(boot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,55 +1,155 @@
|
|||||||
export function dialog (header, body, onclose = async (result, form) => { }) {
|
/**
|
||||||
const dialog = document.createElement("dialog");
|
* Spawn modal dialog from template node. Assumes the following structure:
|
||||||
dialog.innerHTML = `
|
* <template>
|
||||||
<p class="w3-large" id="prompt" style="text-align: center;"></p>
|
* <dialog>
|
||||||
<div id="body"></div>
|
* <p id="prompt"></p>
|
||||||
<div class="w3-center w3-container">
|
* <div id="body">
|
||||||
<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>
|
* <form id="form"> ... </form>
|
||||||
<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>
|
||||||
</div>
|
* <div id="controls">
|
||||||
`;
|
* <button value="..." form="form"
|
||||||
dialog.className = "w3-container w3-card w3-border-0";
|
* <button value="..." form="form"
|
||||||
dialog.querySelector("#prompt").innerText = header;
|
* ...
|
||||||
dialog.querySelector("#body").innerHTML = body;
|
* </div>
|
||||||
|
* </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
|
||||||
|
*/
|
||||||
|
export function dialog (template, onclose = async (_result, _form) => { }) {
|
||||||
|
const dialog = template.content.querySelector("dialog").cloneNode(true);
|
||||||
|
document.body.append(dialog);
|
||||||
dialog.addEventListener("close", async () => {
|
dialog.addEventListener("close", async () => {
|
||||||
const formElem = dialog.querySelector("form");
|
const formElem = dialog.querySelector("form");
|
||||||
const formData = formElem ? new FormData(formElem) : null;
|
const formData = formElem ? new FormData(formElem) : null;
|
||||||
await onclose(dialog.returnValue, formData);
|
await onclose(dialog.returnValue, formData);
|
||||||
|
formElem.reset();
|
||||||
|
dialog.close();
|
||||||
dialog.parentElement.removeChild(dialog);
|
dialog.parentElement.removeChild(dialog);
|
||||||
});
|
});
|
||||||
if (!dialog.querySelector("form")) {
|
if (!dialog.querySelector("form")) {
|
||||||
dialog.querySelector("#confirm").addEventListener("click", async (e) => {
|
for (const control of dialog.querySelector("#controls").childNodes) {
|
||||||
e.preventDefault();
|
control.addEventListener("click", async (e) => {
|
||||||
dialog.close(e.target.value);
|
|
||||||
});
|
|
||||||
dialog.querySelector("#cancel").addEventListener("click", async (e) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dialog.close(e.target.value);
|
dialog.close(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
document.body.append(dialog);
|
document.body.append(dialog);
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
return dialog;
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function alert (message) {
|
export function alert (message) {
|
||||||
|
const dialog = document.querySelector("#alert-dialog");
|
||||||
|
if (dialog == null) {
|
||||||
const dialog = document.createElement("dialog");
|
const dialog = document.createElement("dialog");
|
||||||
|
dialog.id = "alert-dialog";
|
||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
<p class="w3-center" style="margin-bottom: 0px;">${message}</p>
|
<p class="w3-large" id="prompt">Alert</p>
|
||||||
|
<p class="w3-center" style="margin-bottom: 0;">${message}</p>
|
||||||
<div class="w3-center">
|
<div class="w3-center">
|
||||||
<button class="w3-button w3-margin" id="submit">OK</button>
|
<button class="w3-button w3-margin" id="submit">OK</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
dialog.className = "w3-container w3-card w3-border-0";
|
dialog.className = "w3-container w3-card w3-border-0";
|
||||||
|
|
||||||
document.body.append(dialog);
|
document.body.append(dialog);
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
|
|
||||||
dialog.addEventListener("close", () => {
|
dialog.addEventListener("close", () => {
|
||||||
dialog.parentElement.removeChild(dialog);
|
dialog.parentElement.removeChild(dialog);
|
||||||
});
|
});
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error("Attempted to create a new alert while one already exists!");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorDialog extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
dialog = null;
|
||||||
|
errors = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
this.shadowRoot = this.attachShadow({ mode: "open" });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<style>
|
||||||
|
#errors {
|
||||||
|
margin-bottom: 0;
|
||||||
|
max-height: 20lh;
|
||||||
|
min-height: 20lh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
#errors * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<form method="dialog">
|
||||||
|
<p class="w3-large" id="prompt">Error</p>
|
||||||
|
<div id="errors" class="flex column-reverse"></div>
|
||||||
|
<div class="w3-center" id="controls">
|
||||||
|
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
|
||||||
|
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
`;
|
||||||
|
this.dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
this.errors = this.shadowRoot.querySelector("#errors");
|
||||||
|
|
||||||
|
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
|
||||||
|
control.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dialog.close(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialog.addEventListener("close", () => {
|
||||||
|
if (this.dialog.returnValue === "copy") {
|
||||||
|
let errors = "";
|
||||||
|
for (const error of this.errors.childNodes) {
|
||||||
|
errors += `${error.innerText}\n`;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(errors);
|
||||||
|
}
|
||||||
|
this.parentElement.removeChild(this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appendError (error) {
|
||||||
|
error = `${(new Date()).toUTCString()}: ${error}`;
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.innerText = error;
|
||||||
|
this.errors.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal () {
|
||||||
|
this.dialog.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("error-dialog", ErrorDialog);
|
||||||
|
|
||||||
|
export function error (message) {
|
||||||
|
let dialog = document.querySelector("error-dialog");
|
||||||
|
if (dialog == null) {
|
||||||
|
dialog = document.createElement("error-dialog");
|
||||||
|
document.body.append(dialog);
|
||||||
|
dialog.appendError(message);
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dialog.appendError(message);
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
return dialog;
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const blank = document.createElement("img");
|
const blank = document.createElement("img");
|
||||||
|
blank.src = "images/common/blank.png"; // for whatever reason an svg does NOT work here
|
||||||
|
|
||||||
class DraggableContainer extends HTMLElement {
|
class DraggableContainer extends HTMLElement {
|
||||||
shadowRoot = null;
|
shadowRoot = null;
|
||||||
@@ -12,7 +13,7 @@ class DraggableContainer extends HTMLElement {
|
|||||||
window.Sortable.create(this.content, {
|
window.Sortable.create(this.content, {
|
||||||
group: this.dataset.group,
|
group: this.dataset.group,
|
||||||
ghostClass: "ghost",
|
ghostClass: "ghost",
|
||||||
setData: function (dataTransfer, dragEl) {
|
setData: function (dataTransfer, _dragEl) {
|
||||||
dataTransfer.setDragImage(blank, 0, 0);
|
dataTransfer.setDragImage(blank, 0, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -48,7 +49,7 @@ class DraggableContainer extends HTMLElement {
|
|||||||
|
|
||||||
get value () {
|
get value () {
|
||||||
const value = [];
|
const value = [];
|
||||||
this.content.childNodes.forEach((element) => {
|
this.content.querySelectorAll(".draggable-item").forEach((element) => {
|
||||||
if (element.dataset.value) {
|
if (element.dataset.value) {
|
||||||
value.push(element.dataset.value);
|
value.push(element.dataset.value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, requestDash, setSVGSrc, setSVGAlt } from "./utils.js";
|
import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js";
|
||||||
import { alert, dialog } from "./dialog.js";
|
import { alert, dialog, error } 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";
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ async function init () {
|
|||||||
wfaInit("modules/wfa.wasm");
|
wfaInit("modules/wfa.wasm");
|
||||||
initInstances();
|
initInstances();
|
||||||
|
|
||||||
document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd);
|
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
|
||||||
document.querySelector("#vm-search").addEventListener("input", sortInstances);
|
document.querySelector("#vm-search").addEventListener("input", sortInstances);
|
||||||
|
|
||||||
setupClientSync(refreshInstances);
|
setupClientSync(refreshInstances);
|
||||||
@@ -120,48 +120,52 @@ class InstanceCard extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
||||||
|
if (powerButton !== null) {
|
||||||
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) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handlePowerButton();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
const configButton = this.shadowRoot.querySelector("#configure-btn");
|
|
||||||
if (configButton.classList.contains("clickable")) {
|
|
||||||
configButton.onclick = this.handleConfigButton.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 !== null) {
|
||||||
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")
|
setIconSrc(statusicon, "images/status/loading.svg");
|
||||||
setSVGAlt(statusicon, "instance is loading")
|
setIconAlt(statusicon, "instance is loading");
|
||||||
setSVGSrc(powerbtn, "images/status/loading.svg")
|
setIconSrc(powerbtn, "images/status/loading.svg");
|
||||||
setSVGAlt(powerbtn, "")
|
setIconAlt(powerbtn, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
async handlePowerButton () {
|
async handlePowerButton () {
|
||||||
if (!this.actionLock) {
|
if (!this.actionLock) {
|
||||||
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`;
|
const template = this.shadowRoot.querySelector("#power-dialog");
|
||||||
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`;
|
dialog(template, 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));
|
||||||
|
|
||||||
@@ -186,26 +190,10 @@ class InstanceCard extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 template = this.shadowRoot.querySelector("#delete-dialog");
|
||||||
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`;
|
dialog(template, async (result, _form) => {
|
||||||
|
|
||||||
dialog(header, body, async (result, form) => {
|
|
||||||
if (result === "confirm") {
|
if (result === "confirm") {
|
||||||
this.actionLock = true;
|
this.actionLock = true;
|
||||||
|
|
||||||
@@ -235,7 +223,7 @@ async function getInstancesFragment () {
|
|||||||
async function refreshInstances () {
|
async function refreshInstances () {
|
||||||
let instances = await getInstancesFragment();
|
let instances = await getInstancesFragment();
|
||||||
if (instances.status !== 200) {
|
if (instances.status !== 200) {
|
||||||
alert("Error fetching instances.");
|
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
instances = instances.data;
|
instances = instances.data;
|
||||||
@@ -259,7 +247,7 @@ function sortInstances () {
|
|||||||
const searchQuery = document.querySelector("#search").value || null;
|
const searchQuery = document.querySelector("#search").value || null;
|
||||||
let criteria;
|
let criteria;
|
||||||
if (!searchQuery) {
|
if (!searchQuery) {
|
||||||
criteria = (item, query = null) => {
|
criteria = (item, _query = null) => {
|
||||||
return { score: item.vmid, alignment: null };
|
return { score: item.vmid, alignment: null };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -269,9 +257,9 @@ function sortInstances () {
|
|||||||
if (substrInc) {
|
if (substrInc) {
|
||||||
const substrStartIndex = item.indexOf(query);
|
const substrStartIndex = item.indexOf(query);
|
||||||
const queryLength = query.length;
|
const queryLength = query.length;
|
||||||
const remaining = item.length - substrInc - queryLength;
|
const remaining = item.length - substrInc - queryLength + 1;
|
||||||
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
|
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
|
||||||
return { score: 1, alignment };
|
return { score: -1, alignment };
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const alignment = `${"X".repeat(item.length)}`;
|
const alignment = `${"X".repeat(item.length)}`;
|
||||||
@@ -288,8 +276,8 @@ function sortInstances () {
|
|||||||
};
|
};
|
||||||
criteria = (item, query) => {
|
criteria = (item, query) => {
|
||||||
// lower is better
|
// lower is better
|
||||||
const { score, CIGAR } = global.wfAlign(query, item, penalties, true);
|
const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
|
||||||
const alignment = global.DecodeCIGAR(CIGAR);
|
const alignment = global.wfa.DecodeCIGAR(CIGAR);
|
||||||
return { score: score / item.length, alignment };
|
return { score: score / item.length, alignment };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,47 +312,9 @@ function sortInstances () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleInstanceAdd () {
|
async function handleInstanceAddButton () {
|
||||||
const header = "Create New Instance";
|
const template = document.querySelector("#create-instance-dialog");
|
||||||
|
const d = dialog(template, async (result, form) => {
|
||||||
const 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" 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,8 +343,10 @@ async function handleInstanceAdd () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// setup type select
|
||||||
const typeSelect = d.querySelector("#type");
|
const typeSelect = d.querySelector("#type");
|
||||||
typeSelect.selectedIndex = -1;
|
typeSelect.selectedIndex = -1;
|
||||||
|
// on type change, reveal or hide the container specific section
|
||||||
typeSelect.addEventListener("change", () => {
|
typeSelect.addEventListener("change", () => {
|
||||||
if (typeSelect.value === "qemu") {
|
if (typeSelect.value === "qemu") {
|
||||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
@@ -409,75 +361,79 @@ async function handleInstanceAdd () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
|
element.classList.add("none");
|
||||||
|
element.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
const rootfsContent = "rootdir";
|
// setup pool select
|
||||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
const poolSelect = d.querySelector("#pool");
|
||||||
rootfsStorage.selectedIndex = -1;
|
poolSelect.innerHTML = "";
|
||||||
|
// add user pools to selector
|
||||||
const userResources = await requestAPI("/user/dynamic/resources", "GET");
|
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
|
||||||
const userCluster = await requestAPI("/user/config/cluster", "GET");
|
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");
|
const nodeSelect = d.querySelector("#node");
|
||||||
const clusterNodes = await requestPVE("/nodes", "GET");
|
nodeSelect.innerHTML = "";
|
||||||
const allowedNodes = Object.keys(userCluster.nodes);
|
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
|
||||||
clusterNodes.data.forEach((element) => {
|
const allowedNodes = Object.keys(pool["nodes-allowed"]);
|
||||||
|
clusterNodes.forEach((element) => {
|
||||||
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||||
nodeSelect.add(new Option(element.node));
|
nodeSelect.add(new Option(element.node));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
nodeSelect.selectedIndex = -1;
|
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
|
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
|
||||||
const node = nodeSelect.value;
|
const node = nodeSelect.value;
|
||||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
|
||||||
storage.data.forEach((element) => {
|
rootfsStorage.innerHTML = "";
|
||||||
|
storage.forEach((element) => {
|
||||||
if (element.content.includes(rootfsContent)) {
|
if (element.content.includes(rootfsContent)) {
|
||||||
rootfsStorage.add(new Option(element.storage));
|
rootfsStorage.add(new Option(element.storage));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
rootfsStorage.selectedIndex = -1;
|
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
|
// setup root dir select
|
||||||
d.querySelector("#vmid").min = userCluster.vmid.min;
|
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||||
d.querySelector("#vmid").max = userCluster.vmid.max;
|
rootfsStorage.selectedIndex = -1;
|
||||||
|
// set rootfs content type (rootdir)
|
||||||
// add user pools to selector
|
const rootfsContent = "rootdir";
|
||||||
const poolSelect = d.querySelector("#pool");
|
|
||||||
const userPools = Object.keys(userCluster.pools);
|
|
||||||
userPools.forEach((element) => {
|
|
||||||
poolSelect.add(new Option(element));
|
|
||||||
});
|
|
||||||
poolSelect.selectedIndex = -1;
|
|
||||||
|
|
||||||
|
// setup templateImage depending on selected image storage
|
||||||
|
const templateImage = d.querySelector("#template-image");
|
||||||
// add template images to selector
|
// add template images to selector
|
||||||
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
|
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||||
for (const template of templates) {
|
for (const template of templates.data) {
|
||||||
templateImage.append(new Option(template.name, template.volid));
|
templateImage.append(new Option(template.name, template.volid));
|
||||||
}
|
}
|
||||||
templateImage.selectedIndex = -1;
|
templateImage.selectedIndex = -1;
|
||||||
|
|
||||||
|
// setup custom password checker for containers
|
||||||
const password = d.querySelector("#password");
|
const password = d.querySelector("#password");
|
||||||
const confirmPassword = d.querySelector("#confirm-password");
|
const confirmPassword = d.querySelector("#confirm-password");
|
||||||
|
|
||||||
function validatePassword () {
|
function validatePassword () {
|
||||||
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
password.addEventListener("change", validatePassword);
|
password.addEventListener("change", validatePassword);
|
||||||
confirmPassword.addEventListener("keyup", validatePassword);
|
confirmPassword.addEventListener("keyup", validatePassword);
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ window.addEventListener("DOMContentLoaded", init);
|
|||||||
|
|
||||||
function init () {
|
function init () {
|
||||||
setAppearance();
|
setAppearance();
|
||||||
|
|
||||||
const { scheme, rate } = getSyncSettings();
|
const { scheme, rate } = getSyncSettings();
|
||||||
if (scheme) {
|
if (scheme) {
|
||||||
document.querySelector(`#sync-${scheme}`).checked = true;
|
document.querySelector(`#sync-${scheme}`).checked = true;
|
||||||
|
|||||||
@@ -80,28 +80,34 @@ async function request (url, content) {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url, content);
|
const response = await fetch(url, content);
|
||||||
const contentType = response.headers.get("Content-Type");
|
const contentType = response.headers.get("Content-Type");
|
||||||
let data = null;
|
const res = {};
|
||||||
if (contentType.includes("application/json")) {
|
|
||||||
data = await response.json();
|
if (contentType === null) {
|
||||||
data.status = response.status;
|
res.data = null;
|
||||||
|
res.status = response.status;
|
||||||
|
}
|
||||||
|
else if (contentType.includes("application/json")) {
|
||||||
|
res.data = await response.json();
|
||||||
|
res.status = response.status;
|
||||||
}
|
}
|
||||||
else if (contentType.includes("text/html")) {
|
else if (contentType.includes("text/html")) {
|
||||||
data = { data: await response.text() };
|
res.data = await response.text();
|
||||||
data.status = response.status;
|
res.status = response.status;
|
||||||
}
|
}
|
||||||
else if (contentType.includes("text/plain")) {
|
else if (contentType.includes("text/plain")) {
|
||||||
data = { data: await response.text() };
|
res.data = await response.text();
|
||||||
data.status = response.status;
|
res.status = response.status;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
data = response;
|
res.data = null;
|
||||||
|
res.status = response.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
else {
|
||||||
data.status = response.status;
|
return res;
|
||||||
return data || response;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -114,20 +120,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);
|
||||||
@@ -201,15 +193,10 @@ export function setAppearance () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assumes href is path to svg, and id to grab is #symb
|
// assumes href is path to svg, and id to grab is #symb
|
||||||
export function setSVGSrc (svgElem, href) {
|
export function setIconSrc (icon, path) {
|
||||||
let useElem = svgElem.querySelector("use");
|
icon.setAttribute("src", path);
|
||||||
if (!useElem) {
|
|
||||||
useElem = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
|
||||||
}
|
|
||||||
useElem.setAttribute("href", `${href}#symb`);
|
|
||||||
svgElem.append(useElem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSVGAlt (svgElem, alt) {
|
export function setIconAlt (icon, alt) {
|
||||||
svgElem.setAttribute("aria-label", alt);
|
icon.setAttribute("alt", alt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{{define "backup-card"}}
|
||||||
|
<backup-card data-volid="{{.Volid}}">
|
||||||
|
<template shadowrootmode="open">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
|
||||||
|
<p class="w3-col l2 m4 s8">{{.TimeFormatted}}</p>
|
||||||
|
<p class="w3-col l6 m6 hide-small">{{.Notes}}</p>
|
||||||
|
<p class="w3-col l2 hide-medium">{{.SizeFormatted}}</p>
|
||||||
|
<div class="w3-col l2 m2 s4 flex row nowrap" style="height: 1lh;">
|
||||||
|
<img id="edit-btn" class="clickable" alt="change notes" src="images/actions/backups/config.svg#symb">
|
||||||
|
<img id="delete-btn" class="clickable" alt="delete backup" role="button" tabindex=0 src="images/actions/backups/delete-active.svg#symb">
|
||||||
|
<img id="restore-btn" class="clickable" alt="restore from backup" role="button" tabindex=0 src="images/actions/backups/restore.svg#symb">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template id="edit-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Edit Backup
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
|
||||||
|
<label for="rate">Notes</label>
|
||||||
|
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
<template id="delete-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Delete Backup
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to <strong>delete</strong> the backup made at {{.TimeFormatted}}?
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
<template id="restore-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Restore From Backup?
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to <strong>restore</strong> from the backup made at {{.TimeFormatted}}?
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<strong>WARNING: Restoring from a backup will WIPE disks NOT contained in the backup!!!</strong>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</backup-card>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "backups-add-backup"}}
|
||||||
|
<button type="button" id="backup-add" class="w3-button" aria-label="Create Backup">
|
||||||
|
<span class="large" style="margin: 0;">Create Backup</span>
|
||||||
|
<img class="small" role="img" style="height: 1lh; width: 1lh;" alt="Create Backup" src="images/actions/network/add.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="create-backup-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Create Backup
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
|
||||||
|
<label for="rate">Notes</label>
|
||||||
|
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
{{end}}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
{{/* <head> common across all pages*/}}
|
||||||
{{define "head"}}
|
{{define "head"}}
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{.global.Organization}} - dashboard</title>
|
<title>{{.global.Organization}} - dashboard</title>
|
||||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="modules/w3.css">
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
|
|
||||||
<script>
|
<script>
|
||||||
window.PVE = "{{.global.PVE}}";
|
window.PVE = "{{.global.PVE}}";
|
||||||
window.API = "{{.global.API}}";
|
window.API = "{{.global.API}}";
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
<link rel="stylesheet" href="css/form.css">
|
<link rel="stylesheet" href="css/form.css">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* <header> common across all pages*/}}
|
||||||
{{define "header"}}
|
{{define "header"}}
|
||||||
<h1>{{.global.Organization}}</h1>
|
<header>
|
||||||
<label for="navtoggle">☰</label>
|
<h1>{{.global.Organization}}</h1>
|
||||||
<input type="checkbox" id="navtoggle">
|
<label for="navtoggle">☰</label>
|
||||||
<nav id="navigation">
|
<input type="checkbox" id="navtoggle">
|
||||||
|
<nav id="navigation">
|
||||||
{{if eq .page "login"}}
|
{{if eq .page "login"}}
|
||||||
<a href="login" aria-current="page">Login</a>
|
<a href="login" aria-current="page">Login</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -28,5 +30,6 @@
|
|||||||
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
|
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
|
||||||
<a href="login">Logout</a>
|
<a href="login">Logout</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
|
</header>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1,50 +1,103 @@
|
|||||||
{{define "proctype-input"}}
|
{{define "proctype-input"}}
|
||||||
<svg aria-label="CPU Type"><use href="images/resources/cpu.svg#symb"></svg>
|
<img alt="CPU Type" src="images/resources/cpu.svg#symb">
|
||||||
<label for="proctype">CPU Type</label>
|
<label for="proctype">CPU Type</label>
|
||||||
{{template "select" .}}
|
{{template "select" .}}
|
||||||
<div></div>
|
<div></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "cores-input"}}
|
{{define "cores-input"}}
|
||||||
<svg aria-label="CPU Amount"><use href="images/resources/cpu.svg#symb"></svg>
|
<img alt="CPU Amount" src="images/resources/cpu.svg#symb">
|
||||||
<label for="cores">CPU Amount</label>
|
<label for="cores">CPU Amount</label>
|
||||||
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
|
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||||
<p>Cores</p>
|
<p>Cores</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "memory-input"}}
|
{{define "memory-input"}}
|
||||||
<svg aria-label="Memory Amount"><use href="images/resources/ram.svg#symb"></svg>
|
<img alt="Memory Amount" src="images/resources/ram.svg#symb">
|
||||||
<label for="ram">Memory</label>
|
<label for="ram">Memory</label>
|
||||||
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
|
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||||
<p>MiB</p>
|
<p>MiB</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "swap-input"}}
|
{{define "swap-input"}}
|
||||||
<svg aria-label="Swap Amount"><use href="images/resources/swap.svg#symb"></svg>
|
<img alt="Swap Amount" src="images/resources/swap.svg#symb">
|
||||||
<label for="swap">Swap</label>
|
<label for="swap">Swap</label>
|
||||||
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
|
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||||
<p>MiB</p>
|
<p>MiB</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "volumes"}}
|
{{define "volumes"}}
|
||||||
{{range $k,$v := .}}
|
{{range $k,$v := .Volumes}}
|
||||||
{{if eq $v.Type "rootfs"}}
|
{{if eq $v.Type "rootfs"}}
|
||||||
{{ template "volume-rootfs" Map "Name" $k "Volume" $v}}
|
{{ template "volume-rootfs" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||||
{{else if eq $v.Type "mp"}}
|
{{else if eq $v.Type "mp"}}
|
||||||
{{ template "volume-mp" Map "Name" $k "Volume" $v}}
|
{{ template "volume-mp" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||||
{{else if eq $v.Type "ide"}}
|
{{else if eq $v.Type "ide"}}
|
||||||
{{ template "volume-ide" Map "Name" $k "Volume" $v}}
|
{{ template "volume-ide" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||||
{{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}}
|
{{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}}
|
||||||
{{ template "volume-scsi" Map "Name" $k "Volume" $v}}
|
{{ template "volume-scsi" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||||
{{else if eq $v.Type "unused"}}
|
{{else if eq $v.Type "unused"}}
|
||||||
{{ template "volume-unused" Map "Name" $k "Volume" $v}}
|
{{ template "volume-unused" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "volumes-add-disk"}}
|
||||||
|
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
|
||||||
|
<span class="large" style="margin: 0;">Add Disk</span>
|
||||||
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New Disk" src="images/actions/disk/add-disk.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="add-disk-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Create New Disk
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
{{if eq .config.Type "VM"}}
|
||||||
|
<label for="device">SCSI</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="30" value="0" required>
|
||||||
|
{{else}}
|
||||||
|
<label for="device">MP</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="255" value="0" required>
|
||||||
|
{{end}}
|
||||||
|
<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required></select>
|
||||||
|
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "volumes-add-cd"}}
|
||||||
|
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD">
|
||||||
|
<span class="large" style="margin: 0;">Mount CD</span>
|
||||||
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New CDROM" src="images/actions/disk/add-cd.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="add-cd-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Mount a CDROM
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required>
|
||||||
|
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "volume-rootfs"}}
|
{{define "volume-rootfs"}}
|
||||||
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
|
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
|
||||||
<p>{{.Name}}</p>
|
<p>{{.Name}}</p>
|
||||||
<p>{{.Volume.File}}</p>
|
<p>{{.Volume.File}}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -56,7 +109,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "volume-mp"}}
|
{{define "volume-mp"}}
|
||||||
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
|
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
|
||||||
<p>{{.Name}}</p>
|
<p>{{.Name}}</p>
|
||||||
<p>{{.Volume.File}}</p>
|
<p>{{.Volume.File}}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -68,7 +121,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "volume-ide"}}
|
{{define "volume-ide"}}
|
||||||
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
|
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
|
||||||
<p>{{.Name}}</p>
|
<p>{{.Name}}</p>
|
||||||
<p>{{.Volume.File}}</p>
|
<p>{{.Volume.File}}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -80,7 +133,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "volume-scsi"}}
|
{{define "volume-scsi"}}
|
||||||
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
|
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
|
||||||
<p>{{.Name}}</p>
|
<p>{{.Name}}</p>
|
||||||
<p>{{.Volume.File}}</p>
|
<p>{{.Volume.File}}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -92,7 +145,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "volume-unused"}}
|
{{define "volume-unused"}}
|
||||||
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
|
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
|
||||||
<p>{{.Name}}</p>
|
<p>{{.Name}}</p>
|
||||||
<p>{{.Volume.File}}</p>
|
<p>{{.Volume.File}}</p>
|
||||||
<div>
|
<div>
|
||||||
@@ -107,7 +160,24 @@
|
|||||||
<volume-action data-type="move" data-volume="{{.Name}}">
|
<volume-action data-type="move" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg>
|
<img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Move {{.Name}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"></select>
|
||||||
|
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -116,7 +186,7 @@
|
|||||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg aria-label=""><use href="images/actions/disk/move-inactive.svg#symb"></svg>
|
<img alt="" src="images/actions/disk/move-inactive.svg#symb">
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -125,7 +195,24 @@
|
|||||||
<volume-action data-type="resize" data-volume="{{.Name}}">
|
<volume-action data-type="resize" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg>
|
<img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Resize {{.Name}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="size-increment">Size Increment (GiB)</label>
|
||||||
|
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -134,7 +221,7 @@
|
|||||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg aria-label=""><use href="images/actions/disk/resize-inactive.svg#symb"></svg>
|
<img alt="" src="images/actions/disk/resize-inactive.svg#symb">
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -143,7 +230,23 @@
|
|||||||
<volume-action data-type="delete" data-volume="{{.Name}}">
|
<volume-action data-type="delete" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg>
|
<img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Delete {{.Name}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>Are you sure you want to <strong>delete</strong> disk {{.Name}}?</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -152,7 +255,7 @@
|
|||||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg aria-label=""><use href="images/actions/disk/delete-inactive.svg#symb"></svg>
|
<img alt="" src="images/actions/disk/delete-inactive.svg#symb">
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -161,7 +264,31 @@
|
|||||||
<volume-action data-type="attach" data-volume="{{.Name}}">
|
<volume-action data-type="attach" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg>
|
<img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Attach {{.Name}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
{{if eq .InstanceType "VM"}}
|
||||||
|
<label for="device">SCSI</label>
|
||||||
|
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="30" required>
|
||||||
|
{{else}}
|
||||||
|
<label for="device">MP</label>
|
||||||
|
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="255" required>
|
||||||
|
<label for="device">Path</label>
|
||||||
|
<input class="w3-input w3-border" name="mp" id="mp" required>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -170,7 +297,23 @@
|
|||||||
<volume-action data-type="detach" data-volume="{{.Name}}">
|
<volume-action data-type="detach" data-volume="{{.Name}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg>
|
<img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Detach {{.Name}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>Are you sure you want to detach disk {{.Name}}?</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -179,7 +322,7 @@
|
|||||||
<volume-action data-type="none">
|
<volume-action data-type="none">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg aria-label=""></svg>
|
<img alt="" src="images/common/blank.svg">
|
||||||
</template>
|
</template>
|
||||||
</volume-action>
|
</volume-action>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -190,21 +333,80 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "nets-add-net"}}
|
||||||
|
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
|
||||||
|
<span class="large" style="margin: 0;">Add Network</span>
|
||||||
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New Network Interface" src="images/actions/network/add.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="add-net-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Create Network Interface
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
|
||||||
|
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
||||||
|
{{if eq .config.Type "CT"}}
|
||||||
|
<label for="name">Interface Name</label><input type="text" id="name" name="name" class="w3-input w3-border">
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "net"}}
|
{{define "net"}}
|
||||||
<svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg>
|
<img data-network="{{.Net_ID}}" alt="Net {{.Net_ID}}" src="images/resources/network.svg#symb">
|
||||||
<p>{{.Net_ID}}</p>
|
<p>{{.Net_ID}}</p>
|
||||||
<p>{{.Value}}</p>
|
<p>{{.Value}}</p>
|
||||||
<div>
|
<div>
|
||||||
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg>
|
<img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Edit {{.Net_ID}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</network-action>
|
</network-action>
|
||||||
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg>
|
<img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Delete {{.Net_ID}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>Are you sure you want to <strong>delete</strong> {{.Net_ID}}?</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</network-action>
|
</network-action>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,54 +418,96 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "devices-add-device"}}
|
||||||
|
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
|
||||||
|
<span class="large" style="margin: 0;">Add Device</span>
|
||||||
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New PCIe Device" src="images/actions/device/add.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="add-device-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Add Expansion Card
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="hostpci">Device Bus</label><input type="number" id="hostpci" name="hostpci" class="w3-input w3-border">
|
||||||
|
<label for="device">Device</label><select id="device" name="device" required></select>
|
||||||
|
<label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "device"}}
|
{{define "device"}}
|
||||||
<svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg>
|
<img data-device="{{.Device_ID}}" alt="Device {{.Device_ID}}" src="images/resources/device.svg#symb">
|
||||||
<p>{{.Device_ID}}</p>
|
<p>{{.Device_ID}}</p>
|
||||||
<p>{{.Device_Name}}</p>
|
<p>{{.Device_Name}}</p>
|
||||||
<div>
|
<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">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg>
|
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Edit Expansion Card {{.Device_ID}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</device-action>
|
</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">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg>
|
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
|
||||||
|
<template id="dialog-template">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
remove Expansion Card {{.Device_ID}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>Are you sure you want to <strong>remove</strong> {{.Device_ID}}?</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</device-action>
|
</device-action>
|
||||||
</div>
|
</div>
|
||||||
{{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">
|
||||||
|
<template shadowrootmode="open">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
div.draggable-item.ghost {
|
div.draggable-item.ghost {
|
||||||
border: 1px dashed var(--main-text-color);
|
border: 1px dashed var(--main-text-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -272,32 +516,45 @@
|
|||||||
div.draggable-item {
|
div.draggable-item {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
div.draggable-item svg {
|
div.draggable-item img {
|
||||||
height: 1em;
|
height: 1em;
|
||||||
width: 1em;
|
width: 1em;
|
||||||
}
|
}
|
||||||
* {
|
div.draggable-item p {
|
||||||
-webkit-box-sizing: border-box;
|
margin: 0;
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
</style>
|
div.draggable-item p.volume-file {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#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"}}
|
||||||
{{if .volume_id}}
|
{{if .volume_id}}
|
||||||
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
|
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
|
||||||
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
|
<img alt="Drag" src="images/actions/drag.svg#symb">
|
||||||
<svg aria-label="Volume"><use href="images/resources/drive.svg#symb"></use></svg>
|
<img alt="Volume" src="images/resources/drive.svg#symb">
|
||||||
<p style="margin: 0px;">{{.volume_id}}</p>
|
<p class="volume-id">{{.volume_id}}</p>
|
||||||
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
|
<p class="volume-file">{{.file}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{else if .net_id}}
|
{{else if .net_id}}
|
||||||
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
|
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
|
||||||
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
|
<img alt="Drag" src="images/actions/drag.svg#symb">
|
||||||
<svg aria-label="Net"><use href="images/resources/network.svg#symb"></use></svg>
|
<img alt="Net" src="images/resources/network.svg#symb">
|
||||||
<p style="margin: 0px;">{{.net_id}}</p>
|
<p class="volume-id">{{.net_id}}</p>
|
||||||
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
|
<p class="volume-file">{{.value}}</p>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,61 +2,162 @@
|
|||||||
<instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}">
|
<instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}">
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="modules/w3.css">
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
|
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
a, svg, img {
|
||||||
|
line-height: 1em;
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
a img {
|
||||||
|
vertical-align: unset;
|
||||||
|
}
|
||||||
|
#instance-name {
|
||||||
|
overflow-x: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.flex { /* needed for some reason to avoid a flickering issue on chrome ONLY */
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.row { /* needed for some reason to avoid a flickering issue on chrome ONLY */
|
||||||
|
flex-direction: row;
|
||||||
|
column-gap: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
@media screen and (width >=993px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
}
|
||||||
|
@media screen and (width <=993px) and (width >=601px){
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
}
|
||||||
|
@media screen and (width <=601px) and (width >=440px){
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 440px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
.hide-tiny { display: none !important;}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
|
<p>{{.VMID}}</p>
|
||||||
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;">
|
<p id="instance-name">{{.Name}}</p>
|
||||||
<p class="w3-col l1 m2 s6">{{.VMID}}</p>
|
<p class="hide-small">{{.Type}}</p>
|
||||||
<p class="w3-col l2 m3 s6" id="instance-name">{{.Name}}</p>
|
<div class="flex row nowrap hide-tiny">
|
||||||
<p class="w3-col l1 m2 w3-hide-small">{{.Type}}</p>
|
|
||||||
<div class="w3-col l2 m3 s6 flex row nowrap">
|
|
||||||
{{if eq .Status "running"}}
|
{{if eq .Status "running"}}
|
||||||
<svg id="status" aria-label="instance is running"><use href="images/status/active.svg#symb"></svg>
|
<img id="status" alt="instance is running" src="images/status/active.svg#symb">
|
||||||
{{else if eq .Status "stopped"}}
|
{{else if eq .Status "stopped"}}
|
||||||
<svg id="status" aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg>
|
<img id="status" alt="instance is stopped" src="images/status/inactive.svg#symb">
|
||||||
{{else if eq .Status "loading"}}
|
{{else if eq .Status "loading"}}
|
||||||
<svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
|
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
|
||||||
{{else}}
|
{{else}}
|
||||||
|
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
|
||||||
{{end}}
|
{{end}}
|
||||||
<p>{{.Status}}</p>
|
<p>{{.Status}}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.Node}}</p>
|
<p class="hide-medium">{{.Node}}</p>
|
||||||
<div class="w3-col l2 w3-hide-medium w3-hide-small flex row nowrap">
|
<div class="flex row nowrap hide-medium">
|
||||||
{{if eq .NodeStatus "online"}}
|
{{if eq .NodeStatus "online"}}
|
||||||
<svg aria-label="node is online"><use href="images/status/active.svg#symb"></svg>
|
<img alt="node is online" src="images/status/active.svg#symb">
|
||||||
{{else if eq .NodeStatus "offline"}}
|
{{else if eq .NodeStatus "offline"}}
|
||||||
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
|
<img alt="node is offline" src="images/status/inactive.svg#symb">
|
||||||
{{else if eq .NodeStatus "unknown"}}
|
{{else if eq .NodeStatus "unknown"}}
|
||||||
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
|
<img alt="node is offline" src="images/status/inactive.svg#symb">
|
||||||
{{else}}
|
{{else}}
|
||||||
{{end}}
|
{{end}}
|
||||||
<p>{{.NodeStatus}}</p>
|
<p>{{.NodeStatus}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
|
<div class="flex row nowrap">
|
||||||
{{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>
|
<img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb">
|
||||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
|
||||||
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
|
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
|
||||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
<a href="{{.ConsolePath}}" target="_blank">
|
||||||
|
<img id="console-btn" class="clickable" alt="open console" src="images/actions/instance/console-active.svg#symb">
|
||||||
|
</a>
|
||||||
|
<img id="delete-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/delete-inactive.svg#symb">
|
||||||
{{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>
|
<img id="power-btn" class="clickable" alt="start instance" role="button" tabindex=0 src="images/actions/instance/start.svg#symb">
|
||||||
<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>
|
<img id="configure-btn" class="clickable" alt="change configuration" src="images/actions/instance/config-active.svg#symb">
|
||||||
<svg id="delete-btn" class="clickable" aria-label="delete instance"><use href="images/actions/instance/delete-active.svg#symb"></svg>
|
</a>
|
||||||
|
<a href="{{.BackupsPath}}">
|
||||||
|
<img id="backup-btn" class="clickable" alt="manage backups" src="images/actions/instance/backup-active.svg#symb">
|
||||||
|
</a>
|
||||||
|
<img id="console-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/console-inactive.svg#symb">
|
||||||
|
<img id="delete-btn" class="clickable" alt="delete instance" role="button" tabindex=0 src="images/actions/instance/delete-active.svg#symb">
|
||||||
{{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>
|
<img id="power-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/loading.svg#symb">
|
||||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
|
||||||
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
|
||||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
<img id="console-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/console-inactive.svg#symb">
|
||||||
|
<img id="delete-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/delete-inactive.svg#symb">
|
||||||
{{else}}
|
{{else}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<template id="power-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
{{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">
|
||||||
|
{{if eq .Status "running"}}
|
||||||
|
<p>Are you sure you want to <strong>stop</strong> {{.VMID}}?</p>
|
||||||
|
{{else if eq .Status "stopped"}}
|
||||||
|
<p>Are you sure you want to <strong>start</strong> {{.VMID}}?</p>
|
||||||
|
{{else}}
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
<template id="delete-dialog">
|
||||||
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Delete {{.VMID}}
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<p>Are you sure you want to <strong>delete</strong> {{.VMID}}?</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</instance-card>
|
</instance-card>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -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}}
|
||||||
@@ -2,42 +2,43 @@
|
|||||||
<resource-chart>
|
<resource-chart>
|
||||||
<template shadowrootmode="open">
|
<template shadowrootmode="open">
|
||||||
<link rel="stylesheet" href="modules/w3.css">
|
<link rel="stylesheet" href="modules/w3.css">
|
||||||
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
|
|
||||||
<link rel="stylesheet" href="css/style.css">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<style>
|
<style>
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
#container{
|
#container{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
padding: 10px 10px 10px 10px;
|
padding: 0.5em;
|
||||||
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 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 10px;
|
margin-top: 0.5em;
|
||||||
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>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||||
</p>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</resource-chart>
|
</resource-chart>
|
||||||
{{end}}-
|
{{end}}
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
|
{{/*
|
||||||
|
Select: generic data driven <select> element template
|
||||||
|
.ID = (string) select element id & name attribute
|
||||||
|
.Required = (bool) select element required attribute
|
||||||
|
.Options = ([]Options) array of Options
|
||||||
|
*/}}
|
||||||
{{define "select"}}
|
{{define "select"}}
|
||||||
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
|
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
|
||||||
{{range .Options}}
|
{{range .Options}}
|
||||||
|
{{template "option" .}}
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Options: generic data driven <option> element template
|
||||||
|
.Selected = (bool) option element selected attribute
|
||||||
|
.Value = (string) option element value attribute
|
||||||
|
.Display = (string) option element innerText
|
||||||
|
*/}}
|
||||||
|
{{define "option"}}
|
||||||
{{if .Selected}}
|
{{if .Selected}}
|
||||||
<option value="{{.Value}}" selected>{{.Display}}</option>
|
<option value="{{.Value}}" selected>{{.Display}}</option>
|
||||||
{{else}}
|
{{else}}
|
||||||
<option value="{{.Value}}">{{.Display}}</option>
|
<option value="{{.Value}}">{{.Display}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
|
||||||
{{end}}
|
|
||||||