Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0752394d69 | |||
| 526b2a0ff0 | |||
| ff1f618ad7 | |||
| 47415fbdd9 | |||
| 3646bc1272 | |||
| 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 ======================="
|
||||
mkdir -p dist
|
||||
# resolve symbolic links in web by copying it into 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
|
||||
go run .
|
||||
|
||||
@@ -13,7 +13,7 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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-API | api.local | paas.domain.net/api/ |
|
||||
| ProxmoxAAS-Fabric | fabric.local | N/A |
|
||||
| ProxmoxAAS-LDAP | ldap.local | N/A|
|
||||
| access-manager-api | access.local | N/A|
|
||||
|
||||
## Prerequisites - Dashboard
|
||||
- Proxmox VE Cluster (v7.0+)
|
||||
@@ -36,11 +36,11 @@ We will assume different hosts for each component which are accessible by unique
|
||||
1. Initialize any host, which will be the `ProxmoxAAS-Dashboard` component host
|
||||
2. Download `proxmoxaas-dashboard` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
|
||||
Rename `template.config.json` to `config.json` and modify:
|
||||
- listenPort: port for PAAS-Dashboard to bind and listen on
|
||||
- organization: name of your org which is displayed on the top left corner
|
||||
- dashurl: url for the dashboard, ie. `https://paas.domain.net`
|
||||
- apiurl: url for PAAS-API, ie. `https://paas.domain.net/api`
|
||||
- pveurl: url for the Proxmox endpoint, ie. `https://pve.domain.net`
|
||||
- `listenPort`: port for PAAS-Dashboard to bind and listen on
|
||||
- `organization`: name of your org which is displayed on the top left corner
|
||||
- `dashurl`: url for the dashboard, ie. `https://paas.domain.net`
|
||||
- `apiurl`: url for PAAS-API, ie. `https://paas.domain.net/api`
|
||||
- `pveurl`: url for the Proxmox endpoint, ie. `https://pve.domain.net`
|
||||
3. Execute the binary or additionally download `proxmoxaas-dashboard.service` from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases) to run using systemd
|
||||
|
||||
After this step, the Dashboard should be available on the `ProxmoxAAS-Dashboard` host at the configured `listenPort`
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
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"
|
||||
"fmt"
|
||||
"log"
|
||||
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
|
||||
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"proxmoxaas-dashboard/app/routes"
|
||||
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
)
|
||||
|
||||
func Run() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||
flag.Parse()
|
||||
|
||||
common.Global = common.GetConfig(*configPath)
|
||||
|
||||
// setup static resources
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
router := gin.Default()
|
||||
m := common.InitMinify()
|
||||
ServeStatic(router, m)
|
||||
html := common.MinifyStatic(m, web.Templates)
|
||||
common.TMPL = common.LoadHTMLToGin(router, html)
|
||||
|
||||
router.GET("/account", routes.HandleGETAccount)
|
||||
// dynamic routes for pages and page fragments
|
||||
router.GET("/", routes.HandleGETIndex)
|
||||
router.GET("/index", routes.HandleGETIndex)
|
||||
router.GET("/index/instances", routes.HandleGETInstancesFragment)
|
||||
router.GET("/account", routes.HandleGETAccount)
|
||||
router.GET("/config", routes.HandleGETConfig)
|
||||
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
|
||||
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
|
||||
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
|
||||
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
|
||||
router.GET("/backups", routes.HandleGETBackups)
|
||||
router.GET("/backups/backups", routes.HandleGETBackupsFragment)
|
||||
router.GET("/login", routes.HandleGETLogin)
|
||||
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) {
|
||||
css := common.MinifyStatic(m, web.CSS_fs)
|
||||
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
|
||||
|
||||
import (
|
||||
@@ -7,6 +9,7 @@ import (
|
||||
"github.com/tdewolff/minify/v2/css"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
"github.com/tdewolff/minify/v2/js"
|
||||
"github.com/tdewolff/minify/v2/svg"
|
||||
)
|
||||
|
||||
// defines mime type and associated minifier
|
||||
@@ -35,6 +38,10 @@ var MimeTypes = map[string]MimeType{
|
||||
},
|
||||
"svg": {
|
||||
Type: "image/svg+xml",
|
||||
Minifier: svg.Minify,
|
||||
},
|
||||
"png": {
|
||||
Type: "image/png",
|
||||
Minifier: nil,
|
||||
},
|
||||
"js": {
|
||||
@@ -1,5 +1,9 @@
|
||||
package common
|
||||
|
||||
import "html/template"
|
||||
|
||||
var Global Config
|
||||
|
||||
type Config struct {
|
||||
Port int `json:"listenPort"`
|
||||
Organization string `json:"organization"`
|
||||
@@ -8,6 +12,11 @@ type Config struct {
|
||||
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 {
|
||||
Data string
|
||||
MimeType MimeType
|
||||
@@ -26,23 +35,3 @@ type Option struct {
|
||||
Value 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"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
|
||||
paas "proxmoxaas-common-lib"
|
||||
)
|
||||
|
||||
var TMPL *template.Template
|
||||
var Global Config
|
||||
|
||||
// get config file from configPath
|
||||
func GetConfig(configPath string) Config {
|
||||
content, err := os.ReadFile(configPath)
|
||||
root, err := os.OpenRoot(".")
|
||||
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
|
||||
err = json.Unmarshal(content, &config)
|
||||
if err != nil {
|
||||
log.Fatal("Error during parsing config file: ", err)
|
||||
log.Fatal("[ERR ] error parsing config file: ", err)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// initialize minifier using the meta types specified
|
||||
func InitMinify() *minify.M {
|
||||
m := minify.New()
|
||||
for _, v := range MimeTypes {
|
||||
@@ -47,14 +57,14 @@ func InitMinify() *minify.M {
|
||||
|
||||
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
||||
minified := make(map[string]StaticFile)
|
||||
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
v, err := files.ReadFile(path)
|
||||
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(), ".")
|
||||
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
|
||||
min, err := m.String(mimetype.Type, string(v)) // try to minify
|
||||
if err != nil {
|
||||
log.Fatalf("error minifying file %s: %s", path, err.Error())
|
||||
log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
|
||||
}
|
||||
minified[path] = StaticFile{
|
||||
Data: min,
|
||||
@@ -84,7 +94,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return minified
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[ERR ] error in MinifyStatic: %s", err)
|
||||
return nil
|
||||
} else {
|
||||
return minified
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
v := reflect.ValueOf(x)
|
||||
keys := v.MapKeys()
|
||||
s := ""
|
||||
for i := 0; i < len(keys); i++ {
|
||||
var s strings.Builder
|
||||
for i := range keys {
|
||||
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) {
|
||||
if len(values)%2 != 0 {
|
||||
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 {
|
||||
key, ok := values[i].(string)
|
||||
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))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
engine.SetHTMLTemplate(root)
|
||||
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) {
|
||||
log.Printf("[Error] encountered an error: %s", err.Error())
|
||||
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
|
||||
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)
|
||||
if err != nil {
|
||||
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{}
|
||||
@@ -185,23 +204,62 @@ func RequestGetAPI(path string, context RequestContext) (*http.Response, int, er
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &context.Body)
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
|
||||
case *map[string]any:
|
||||
err = json.Unmarshal(data, &body)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func GetAuth(c *gin.Context) (Auth, error) {
|
||||
func GetAuthFromRequest(c *gin.Context) (paas.Auth, error) {
|
||||
_, errAuth := c.Cookie("auth")
|
||||
username, errUsername := c.Cookie("username")
|
||||
token, errToken := c.Cookie("PVEAuthCookie")
|
||||
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
|
||||
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
|
||||
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
|
||||
access, errAccess := c.Cookie("PAASAccessManagerTicket")
|
||||
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 {
|
||||
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,102 +2,28 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
paas "proxmoxaas-common-lib"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
|
||||
"github.com/gerow/go-color"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
func HandleGETAccount(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
account, err := GetUserAccount(auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range account.Resources {
|
||||
switch t := v.(type) {
|
||||
case NumericResource:
|
||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[k] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
}
|
||||
case StorageResource:
|
||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[k] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
}
|
||||
case ListResource:
|
||||
l := struct {
|
||||
Type string
|
||||
Display bool
|
||||
Resources []ResourceChart
|
||||
}{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Resources: []ResourceChart{},
|
||||
}
|
||||
|
||||
for _, r := range t.Total {
|
||||
l.Resources = append(l.Resources, ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: r.Name,
|
||||
Used: r.Used,
|
||||
Max: r.Max,
|
||||
Avail: float64(r.Avail), // usually an int
|
||||
Unit: "",
|
||||
})
|
||||
}
|
||||
account.Resources[k] = l
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "account",
|
||||
"account": account,
|
||||
})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Pools map[string]bool
|
||||
Nodes map[string]bool
|
||||
VMID struct {
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
Resources map[string]any
|
||||
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
|
||||
@@ -110,20 +36,21 @@ type NumericResource struct {
|
||||
Type string
|
||||
Name string
|
||||
Multiplier int64
|
||||
Base 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 int64
|
||||
Base uint64
|
||||
Compact bool
|
||||
Unit string
|
||||
Display bool
|
||||
@@ -131,6 +58,7 @@ type StorageResource struct {
|
||||
Global Constraint
|
||||
Nodes map[string]Constraint
|
||||
Total Constraint
|
||||
Category string
|
||||
}
|
||||
|
||||
type ListResource struct {
|
||||
@@ -140,124 +68,228 @@ type ListResource struct {
|
||||
Global []Match
|
||||
Nodes map[string][]Match
|
||||
Total []Match
|
||||
Category string
|
||||
}
|
||||
|
||||
type ResourceChart struct {
|
||||
Type string
|
||||
Display bool
|
||||
Name string
|
||||
Used int64
|
||||
Max int64
|
||||
Avail float64
|
||||
Prefix string
|
||||
Unit string
|
||||
Type string
|
||||
Display bool
|
||||
Name string
|
||||
Used int64
|
||||
Max int64
|
||||
Avail string
|
||||
Prefix string
|
||||
Unit string
|
||||
ColorHex string
|
||||
}
|
||||
|
||||
func GetUserAccount(auth common.Auth) (Account, error) {
|
||||
account := Account{
|
||||
Resources: map[string]any{},
|
||||
}
|
||||
var Red = color.RGB{
|
||||
R: 1,
|
||||
G: 0,
|
||||
B: 0,
|
||||
}
|
||||
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
var Green = color.RGB{
|
||||
R: 0,
|
||||
G: 1,
|
||||
B: 0,
|
||||
}
|
||||
|
||||
// get user account basic data
|
||||
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx)
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
|
||||
}
|
||||
err = mapstructure.Decode(ctx.Body, &account)
|
||||
if err != nil {
|
||||
return account, err
|
||||
func HandleGETAccount(c *gin.Context) {
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
|
||||
account, err := GetUser(auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
case NumericResource:
|
||||
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||
}
|
||||
case StorageResource:
|
||||
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||
}
|
||||
case ListResource:
|
||||
l := struct {
|
||||
Type string
|
||||
Display bool
|
||||
Resources []ResourceChart
|
||||
}{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Resources: []ResourceChart{},
|
||||
}
|
||||
|
||||
for _, r := range t.Total {
|
||||
avail := fmt.Sprintf("%d", r.Avail)
|
||||
l.Resources = append(l.Resources, ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: r.Name,
|
||||
Used: r.Used,
|
||||
Max: r.Max,
|
||||
Avail: avail, // usually an int
|
||||
Unit: "",
|
||||
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
|
||||
})
|
||||
}
|
||||
pools[poolname].Resources[category].(map[string]any)[resource] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
account.Pools = pools
|
||||
|
||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "account",
|
||||
"account": account,
|
||||
})
|
||||
} else {
|
||||
account.Username = auth.Username
|
||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
// get user resources
|
||||
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx)
|
||||
func GetUser(auth paas.Auth) (Account, error) {
|
||||
account := Account{}
|
||||
body := map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
|
||||
return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
|
||||
}
|
||||
resources := ctx.Body
|
||||
err = mapstructure.Decode(body, &account)
|
||||
return account, err
|
||||
}
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
func GetUserPools(auth paas.Auth) (map[string]paas.Pool, error) {
|
||||
pools := map[string]paas.Pool{}
|
||||
|
||||
// get all pools
|
||||
body := map[string]any{}
|
||||
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
|
||||
if err != nil {
|
||||
return pools, err
|
||||
}
|
||||
if code != 200 {
|
||||
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
|
||||
}
|
||||
|
||||
// get global config for resource type metadata
|
||||
body = map[string]any{}
|
||||
// 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 {
|
||||
return account, err
|
||||
return pools, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
|
||||
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
|
||||
}
|
||||
meta := ctx.Body["resources"].(map[string]any)
|
||||
meta := body["resources"].(map[string]any)
|
||||
|
||||
// build each resource by its meta type
|
||||
for k, v := range meta {
|
||||
m := v.(map[string]any)
|
||||
t := m["type"].(string)
|
||||
r := resources[k].(map[string]any)
|
||||
if t == "numeric" {
|
||||
n := NumericResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
// for each pool
|
||||
for poolname, pool := range pools {
|
||||
// for each resource in pool data
|
||||
for k, v := range pool.Resources {
|
||||
m := meta[k].(map[string]any)
|
||||
t := m["type"].(string)
|
||||
r := v.(map[string]any)
|
||||
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{}
|
||||
}
|
||||
account.Resources[k] = n
|
||||
} else if t == "storage" {
|
||||
n := StorageResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
|
||||
// depending on type, decode the pool data into the corresponding resource type
|
||||
switch t {
|
||||
case "numeric":
|
||||
n := NumericResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||
case "storage":
|
||||
n := StorageResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||
case "list":
|
||||
n := ListResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
pools[poolname].Resources[category].(map[string]any)[k] = n
|
||||
}
|
||||
account.Resources[k] = n
|
||||
} else if t == "list" {
|
||||
n := ListResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[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) {
|
||||
valf := float64(val)
|
||||
basef := float64(base)
|
||||
steps := 0
|
||||
for math.Abs(valf) > basef && steps < 4 {
|
||||
valf /= basef
|
||||
steps++
|
||||
}
|
||||
|
||||
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, ""
|
||||
// interpolate between min and max by normalized (0 - 1) val
|
||||
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
|
||||
minhsl := min.ToHSL()
|
||||
maxhsl := max.ToHSL()
|
||||
interphsl := color.HSL{
|
||||
H: (1-val)*minhsl.H + (val)*maxhsl.H,
|
||||
S: (1-val)*minhsl.S + (val)*maxhsl.S,
|
||||
L: (1-val)*minhsl.L + (val)*maxhsl.L,
|
||||
}
|
||||
return interphsl.ToRGB()
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
paas "proxmoxaas-common-lib"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
fabric "proxmoxaas-fabric/app"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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) {
|
||||
auth, err := common.GetAuth(c)
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
|
||||
config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
||||
}
|
||||
@@ -49,11 +70,12 @@ func HandleGETConfig(c *gin.Context) {
|
||||
}
|
||||
|
||||
func HandleGETConfigVolumesFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
@@ -62,21 +84,26 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigNetsFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
@@ -85,21 +112,26 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigDevicesFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
@@ -108,21 +140,26 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigBootFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
auth, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
vm_path, err := common.GetInstancePathFromRequest(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
@@ -131,65 +168,24 @@ func HandleGETConfigBootFragment(c *gin.Context) {
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
if err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
} else {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractVMPath(c *gin.Context) (VMPath, 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) {
|
||||
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
|
||||
config := InstanceConfig{}
|
||||
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
|
||||
body := map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
err = mapstructure.Decode(ctx.Body, &config)
|
||||
err = mapstructure.Decode(body, &config)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
@@ -208,77 +204,53 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type GlobalConfig struct {
|
||||
CPU struct {
|
||||
Whitelist bool
|
||||
}
|
||||
}
|
||||
|
||||
type UserConfig struct {
|
||||
CPU struct {
|
||||
Global []CPUConfig
|
||||
Nodes map[string][]CPUConfig
|
||||
}
|
||||
}
|
||||
|
||||
type CPUConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
||||
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
|
||||
cputypes := common.Select{
|
||||
ID: "proctype",
|
||||
Required: true,
|
||||
}
|
||||
|
||||
// get global resource config
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
body := map[string]any{}
|
||||
path := "/global/config/resources"
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
global := GlobalConfig{}
|
||||
err = mapstructure.Decode(ctx.Body["resources"], &global)
|
||||
globalConfig := GlobalConfig{}
|
||||
err = mapstructure.Decode(body["resources"], &globalConfig)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// get user resource config
|
||||
ctx.Body = map[string]any{}
|
||||
path = "/user/config/resources"
|
||||
res, code, err = common.RequestGetAPI(path, ctx)
|
||||
// get pool resource config
|
||||
body = map[string]any{}
|
||||
path = fmt.Sprintf("/access/pools/%s", pool)
|
||||
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
user := UserConfig{}
|
||||
err = mapstructure.Decode(ctx.Body, &user)
|
||||
poolCPUConfig := PoolConfig{}
|
||||
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// use node specific rules if present, otherwise use global rules
|
||||
var userCPU []CPUConfig
|
||||
if _, ok := user.CPU.Nodes[vm.Node]; ok {
|
||||
userCPU = user.CPU.Nodes[vm.Node]
|
||||
var userCPU []paas.MatchLimit
|
||||
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
|
||||
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
|
||||
} else {
|
||||
userCPU = user.CPU.Global
|
||||
userCPU = poolCPUConfig.CPU.Global
|
||||
}
|
||||
|
||||
if global.CPU.Whitelist { // cpu is a whitelist
|
||||
if globalConfig.CPU.Whitelist { // cpu is a whitelist
|
||||
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
|
||||
cputypes.Options = append(cputypes.Options, common.Option{
|
||||
Display: cpu.Name,
|
||||
@@ -287,9 +259,9 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
||||
}
|
||||
} else { // cpu is a blacklist
|
||||
// get the supported cpu types from the node
|
||||
ctx.Body = map[string]any{}
|
||||
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
|
||||
res, code, err = common.RequestGetAPI(path, ctx)
|
||||
body = map[string]any{}
|
||||
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
|
||||
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
supported := struct {
|
||||
data []CPUConfig
|
||||
data []paas.MatchLimit
|
||||
}{}
|
||||
err = mapstructure.Decode(ctx.Body, supported)
|
||||
err = mapstructure.Decode(body, supported)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
|
||||
for _, cpu := range supported.data {
|
||||
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
|
||||
contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
|
||||
return c.Name == cpu.Name
|
||||
})
|
||||
if !contains {
|
||||
|
||||
@@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
paas "proxmoxaas-common-lib"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"strconv"
|
||||
|
||||
@@ -10,39 +11,17 @@ import (
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
func HandleGETIndex(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.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)
|
||||
}
|
||||
|
||||
// primary type used in constructing instance cards in index
|
||||
type InstanceCard struct {
|
||||
VMID uint
|
||||
Name string
|
||||
Type string
|
||||
Status string
|
||||
Node string
|
||||
NodeStatus string
|
||||
ConfigPath string
|
||||
ConsolePath string
|
||||
BackupsPath string
|
||||
}
|
||||
|
||||
// used in constructing instance cards in index
|
||||
@@ -51,60 +30,88 @@ type Node struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// used in constructing instance cards in index
|
||||
type InstanceCard struct {
|
||||
VMID uint
|
||||
Name string
|
||||
Type string
|
||||
Status string
|
||||
Node string
|
||||
NodeStatus string
|
||||
}
|
||||
|
||||
// used in retriving cluster tasks
|
||||
type Task struct {
|
||||
Type string
|
||||
Node string
|
||||
User string
|
||||
ID string
|
||||
VMID uint
|
||||
Status string
|
||||
Type string
|
||||
Node string
|
||||
User string
|
||||
ID string
|
||||
VMID uint
|
||||
Status string
|
||||
EndTime uint
|
||||
}
|
||||
|
||||
type InstanceStatus struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
func HandleGETIndex(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
|
||||
}
|
||||
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
|
||||
}
|
||||
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
instances := map[uint]InstanceCard{}
|
||||
nodes := map[string]Node{}
|
||||
|
||||
// if we successfully retrieved the resources, then process it and return index
|
||||
for _, v := range ctx.Body["data"].([]any) {
|
||||
// parse /proxmox/cluster/resources to separate instances and nodes
|
||||
for _, v := range body {
|
||||
m := v.(map[string]any)
|
||||
if m["type"] == "node" {
|
||||
switch m["type"] {
|
||||
case "node": // if type is node -> parse as Node object
|
||||
node := Node{}
|
||||
err := mapstructure.Decode(v, &node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nodes[node.Node] = node
|
||||
} else if m["type"] == "lxc" || m["type"] == "qemu" {
|
||||
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
|
||||
instance := InstanceCard{}
|
||||
err := mapstructure.Decode(v, &instance)
|
||||
if err != nil {
|
||||
@@ -113,14 +120,27 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
|
||||
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 {
|
||||
nodestatus := nodes[instance.Node].Status
|
||||
instance.NodeStatus = nodestatus
|
||||
// set instance's node status
|
||||
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
|
||||
}
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx)
|
||||
body = []any{}
|
||||
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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{}
|
||||
err := mapstructure.Decode(v, &task)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// try to get task vmid but continue if it would be an invalid vmid
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
continue
|
||||
} 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
|
||||
instance := instances[task.VMID]
|
||||
instance := instances[vmid]
|
||||
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
|
||||
ctx.Body = map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
body := map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// attempt to decode task status as instance status
|
||||
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
|
||||
instances[task.VMID] = instance
|
||||
instances[vmid] = instance
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,34 +9,6 @@ import (
|
||||
"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
|
||||
type GetRealmsBody struct {
|
||||
Data []Realm `json:"data"`
|
||||
@@ -49,6 +21,31 @@ type Realm struct {
|
||||
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) {
|
||||
realms, err := GetLoginRealms()
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func HandleGETSettings(c *gin.Context) {
|
||||
_, err := common.GetAuth(c)
|
||||
_, err := common.GetAuthFromRequest(c)
|
||||
if err == nil {
|
||||
c.HTML(http.StatusOK, "html/settings.html", gin.H{
|
||||
"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
|
||||
|
||||
go 1.24
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||
github.com/tdewolff/minify v2.3.6+incompatible
|
||||
proxmoxaas-fabric v0.0.0
|
||||
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-viper/mapstructure/v2 v2.5.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 (
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/diskfs/go-diskfs v1.5.2 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.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/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // 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/luthermonson/go-proxmox v0.2.2 // indirect
|
||||
github.com/magefile/mage v1.15.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/tdewolff/minify/v2 v2.23.1 // indirect
|
||||
github.com/tdewolff/parse v2.3.4+incompatible // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.23 // indirect
|
||||
github.com/tdewolff/test v1.0.11 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.4.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/tdewolff/parse/v2 v2.8.13 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.1 // indirect
|
||||
golang.org/x/arch v0.28.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
{
|
||||
"name": "proxmoxaas-dashboard",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"description": "Front-end for ProxmoxAAS",
|
||||
"type": "module",
|
||||
"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/",
|
||||
"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"
|
||||
"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/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.43.0",
|
||||
"eslint-config-standard": "^17.1.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eslint-plugin-n": "^16.0.1",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"globals": "^17.5.0",
|
||||
"html-validate": "^9.4.0",
|
||||
"stylelint": "^15.9.0",
|
||||
"stylelint-config-standard": "^33.0.0",
|
||||
"html-validate": "^9.4.0"
|
||||
"stylelint-config-standard": "^33.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
input, select, textarea {
|
||||
background-color: var(--main-input-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.input-grid {
|
||||
display: grid;
|
||||
gap: 5px 10px;
|
||||
gap: 0.5em 1em;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -8,15 +13,15 @@
|
||||
.input-grid * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-top: 0.5em;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.input-grid input {
|
||||
padding: 8px;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.input-grid svg {
|
||||
.input-grid img {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -29,17 +34,17 @@ legend {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1.5em;
|
||||
margin-top: 0.25lh;
|
||||
margin-bottom: 0.25lh;
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset > *:last-child {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
fieldset > .input-grid {
|
||||
@@ -61,7 +66,7 @@ input[type="radio"] {
|
||||
}
|
||||
|
||||
.w3-select, select {
|
||||
padding: 8px;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.w3-check {
|
||||
@@ -72,11 +77,25 @@ input[type="radio"] {
|
||||
:not(.input-grid) .input-grid + * {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
|
||||
dialog {
|
||||
max-width: calc(min(50%, 80ch));
|
||||
background-color: var(--main-bg-color);
|
||||
margin: auto;
|
||||
max-width: calc(min(100% - 16px, 80ch));
|
||||
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 {
|
||||
font-size: 18px;
|
||||
font-size: var(--small-font-size);
|
||||
margin: 0;
|
||||
background-color: var(--nav-header-bg-color);
|
||||
color: var(--nav-header-text-color);
|
||||
@@ -61,15 +61,15 @@ header h1 {
|
||||
}
|
||||
|
||||
nav {
|
||||
font-size: var(--small-font-size);
|
||||
overflow: hidden;
|
||||
font-size: larger;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
nav a, header h1, label[for="navtoggle"] {
|
||||
text-align: left;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
height: 2em;
|
||||
@@ -80,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (width >= 600px){
|
||||
@media screen and (width >= 601px){
|
||||
header {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 600px){
|
||||
@media screen and (width <= 601px){
|
||||
header {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
--positive-color: #0f0;
|
||||
--highlight-color: yellow;
|
||||
--lightbg-text-color: black;
|
||||
--large-font-size: 32px;
|
||||
--medium-font-size: 24px;
|
||||
--small-font-size: 16px;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
@@ -11,7 +14,6 @@
|
||||
--main-text-color: white;
|
||||
--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-table-header-bg-color: black;
|
||||
--main-input-bg-color: #404040;
|
||||
}
|
||||
|
||||
@@ -20,7 +22,6 @@
|
||||
--main-text-color: black;
|
||||
--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-table-header-bg-color: #808080;
|
||||
--main-input-bg-color: white;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +32,6 @@
|
||||
--main-text-color: black;
|
||||
--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-table-header-bg-color: #808080;
|
||||
--main-input-bg-color: white;
|
||||
}
|
||||
|
||||
@@ -40,18 +40,43 @@
|
||||
--main-text-color: white;
|
||||
--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-table-header-bg-color: black;
|
||||
--main-input-bg-color: #404040;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
*, h1, h2, h3, h4, h5, h6, p {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--main-bg-color);
|
||||
font-family: monospace;
|
||||
line-height: normal;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: monospace;
|
||||
h2 {
|
||||
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 {
|
||||
@@ -77,33 +102,24 @@ main {
|
||||
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 {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
color: var(--main-text-color)
|
||||
}
|
||||
|
||||
hr, * {
|
||||
a img {
|
||||
vertical-align: unset;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
@@ -114,6 +130,12 @@ hr, * {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.column-reverse {
|
||||
flex-direction: column-reverse;
|
||||
row-gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 10px;
|
||||
@@ -156,18 +178,26 @@ hr, * {
|
||||
}
|
||||
|
||||
/* add hide large class similar to w3-hide-medium and w3-hide-small */
|
||||
@media (width >=993px) {
|
||||
.w3-hide-large {
|
||||
display: none !important;
|
||||
}
|
||||
@media screen and (width >=993px) {
|
||||
.hide-large {display: none !important;}
|
||||
}
|
||||
|
||||
/* fix edge case in w3-hide-medium where width between 992 and 993 */
|
||||
@media (width <=993px) and (width >=601px){
|
||||
.w3-hide-medium{display:none!important}
|
||||
/* fixes edge case in w3-hide-medium where width between 992 and 993 */
|
||||
@media screen and (width <=993px) and (width >=601px){
|
||||
.hide-large {display: none !important;}
|
||||
.hide-medium {display:none !important}
|
||||
}
|
||||
|
||||
/* fix edge case in w3-hide-small when width between 600 and 601 */
|
||||
@media (width <=601px) {
|
||||
.w3-hide-small{display:none!important}
|
||||
/* fixes edge case in w3-hide-small when width between 600 and 601 */
|
||||
@media screen and (width <=601px) {
|
||||
.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">
|
||||
<style>
|
||||
@media screen and (width >= 1264px){
|
||||
#resource-container {
|
||||
.resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, calc(100% / 6));
|
||||
grid-gap: 0;
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 1264px) and (width >= 680px) {
|
||||
#resource-container {
|
||||
.resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 200px);
|
||||
grid-gap: 0;
|
||||
@@ -23,7 +23,7 @@
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 680px) {
|
||||
#resource-container {
|
||||
.resource-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
@@ -34,44 +34,40 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
{{template "header" .}}
|
||||
<main>
|
||||
<h2>Account</h2>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Account Details</h3>
|
||||
<p id="username">Username: {{.account.Username}}</p>
|
||||
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
|
||||
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
|
||||
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
|
||||
</section>
|
||||
<section class="w3-card w3-padding">
|
||||
<div class="flex row nowrap">
|
||||
<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}}
|
||||
{{if eq .Type "storage"}}
|
||||
{{template "resource-chart" .}}
|
||||
{{end}}
|
||||
{{if eq .Type "list"}}
|
||||
{{range .Resources}}
|
||||
{{template "resource-chart" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
|
||||
<p id="email">Email: {{.account.Mail}}</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>
|
||||
</section>
|
||||
{{range $poolname, $pool := .account.Pools}}
|
||||
{{template "pool-resources" $pool}}
|
||||
{{end}}
|
||||
</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>
|
||||
</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/draggable.js" type="module"></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/dialog.js">
|
||||
<style>
|
||||
@@ -20,13 +19,11 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
{{template "header" .}}
|
||||
<main>
|
||||
<section>
|
||||
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
|
||||
<form>
|
||||
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
|
||||
<form id="config-form">
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Resources</legend>
|
||||
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
|
||||
@@ -43,18 +40,14 @@
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Volumes</legend>
|
||||
<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 class="w3-container w3-center">
|
||||
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
|
||||
<span class="large" style="margin: 0;">Add Disk</span>
|
||||
<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>
|
||||
</button>
|
||||
<!--Add Disk Button & Dialog Template-->
|
||||
{{template "volumes-add-disk" .}}
|
||||
<!--Add CD Button & Dialog Template-->
|
||||
{{if eq .config.Type "VM"}}
|
||||
<button type="button" id="cd-add" class="w3-button" aria-label="Add New 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>
|
||||
{{template "volumes-add-cd"}}
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -64,10 +57,8 @@
|
||||
{{template "nets" .config.Nets}}
|
||||
</div>
|
||||
<div class="w3-container w3-center">
|
||||
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
|
||||
<span class="large" style="margin: 0;">Add Network</span>
|
||||
<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>
|
||||
<!--Add Net Button & Dialog Template-->
|
||||
{{template "nets-add-net"}}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{if eq .config.Type "VM"}}
|
||||
@@ -77,10 +68,8 @@
|
||||
{{template "devices" .config.Devices}}
|
||||
</div>
|
||||
<div class="w3-container w3-center">
|
||||
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
|
||||
<span class="large" style="margin: 0;">Add Device</span>
|
||||
<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>
|
||||
<!--Add Device Button & Dialog Template-->
|
||||
{{template "devices-add-device"}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
@@ -91,7 +80,7 @@
|
||||
</fieldset>
|
||||
{{end}}
|
||||
<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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -8,12 +8,6 @@
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
<link rel="modulepreload" href="scripts/clientsync.js">
|
||||
<style>
|
||||
#instance-container > div {
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
#instance-container > div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@media screen and (width >= 440px) {
|
||||
#vm-search {
|
||||
max-width: calc(100% - 10px - 152px);
|
||||
@@ -24,36 +18,125 @@
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
{{template "header" .}}
|
||||
<main>
|
||||
<section>
|
||||
<h2>Instances</h2>
|
||||
<div class="w3-card w3-padding">
|
||||
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
||||
<form id="vm-search" role="search" class="flex row nowrap">
|
||||
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
|
||||
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
|
||||
<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">
|
||||
</form>
|
||||
<!--Add Instance Button & Dialog Template-->
|
||||
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
||||
<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>
|
||||
<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 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>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;">
|
||||
<p class="w3-col l1 m2 w3-hide-small">ID</p>
|
||||
<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 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">
|
||||
{{range .instances}}
|
||||
{{template "instance-card" .}}
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
{{template "header" .}}
|
||||
<main class="flex" style="justify-content: center; align-items: center;">
|
||||
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
||||
|
||||
@@ -5,30 +5,31 @@
|
||||
<script src="scripts/settings.js" type="module"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<style>
|
||||
legend {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
column-gap: 10px;
|
||||
column-gap: 0.5em;
|
||||
}
|
||||
label + p {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 25px;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
#save.disabled {
|
||||
display: none;
|
||||
}
|
||||
#save.enabled {
|
||||
display: unset;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
{{template "header" .}}
|
||||
<main>
|
||||
<h2>Settings</h2>
|
||||
<form id="settings">
|
||||
@@ -42,6 +43,8 @@
|
||||
<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>
|
||||
<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>
|
||||
<legend>App Sync Frequency</legend>
|
||||
@@ -73,7 +76,7 @@
|
||||
</fieldset>
|
||||
</section>
|
||||
<div class="w3-container w3-center" id="form-actions">
|
||||
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button>
|
||||
<button class="w3-button w3-margin disabled" id="save" type="submit">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -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}
|
||||
/* 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}
|
||||
@@ -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-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-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{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%}
|
||||
@@ -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-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
||||
.w3-hover-none:hover{box-shadow:none!important}
|
||||
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
|
||||
/* Colors */
|
||||
.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}
|
||||
@@ -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-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-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-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}
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
import { dialog } from "./dialog.js";
|
||||
import { requestAPI, setAppearance } from "./utils.js";
|
||||
import { dialog } from "./dialog.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
async function init () {
|
||||
setAppearance();
|
||||
|
||||
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm);
|
||||
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
|
||||
}
|
||||
|
||||
function handlePasswordChangeForm () {
|
||||
const 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>
|
||||
`;
|
||||
const d = dialog("Change Password", body, async (result, form) => {
|
||||
function handlePasswordChangeButton () {
|
||||
const template = document.querySelector("#change-password-dialog");
|
||||
const d = dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
|
||||
if (result.status !== 200) {
|
||||
@@ -29,11 +22,9 @@ function handlePasswordChangeForm () {
|
||||
|
||||
const password = d.querySelector("#new-password");
|
||||
const confirmPassword = d.querySelector("#confirm-password");
|
||||
|
||||
function validatePassword () {
|
||||
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||
}
|
||||
|
||||
password.addEventListener("change", 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getSyncSettings, requestAPI } from "./utils.js";
|
||||
import { getSetting, requestAPI } from "./utils.js";
|
||||
|
||||
export async function setupClientSync (callback) {
|
||||
const { scheme, rate } = getSyncSettings();
|
||||
|
||||
if (scheme === "always") {
|
||||
const scheme = getSetting("sync-scheme");
|
||||
const rate = getSetting("sync-rate");
|
||||
if (scheme === "never") {
|
||||
return;
|
||||
}
|
||||
else if (scheme === "always") {
|
||||
window.setInterval(callback, rate * 1000);
|
||||
}
|
||||
else if (scheme === "hash") {
|
||||
@@ -19,7 +22,7 @@ export async function setupClientSync (callback) {
|
||||
}
|
||||
else if (scheme === "interrupt") {
|
||||
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
|
||||
socket.addEventListener("open", (event) => {
|
||||
socket.addEventListener("open", (_event) => {
|
||||
socket.send(`rate ${rate}`);
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
@@ -19,7 +19,7 @@ async function init () {
|
||||
initNetworks();
|
||||
initDevices();
|
||||
|
||||
document.querySelector("#exit").addEventListener("click", handleFormExit);
|
||||
document.querySelector("#config-form").addEventListener("submit", handleFormExit);
|
||||
}
|
||||
|
||||
class VolumeAction extends HTMLElement {
|
||||
@@ -29,6 +29,7 @@ class VolumeAction extends HTMLElement {
|
||||
super();
|
||||
const internals = this.attachInternals();
|
||||
this.shadowRoot = internals.shadowRoot;
|
||||
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||
if (this.dataset.type === "move") {
|
||||
this.addEventListener("click", this.handleDiskMove);
|
||||
}
|
||||
@@ -47,15 +48,13 @@ class VolumeAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async setStatusLoading () {
|
||||
const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`);
|
||||
setSVGSrc(svg, "images/status/loading.svg");
|
||||
const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
|
||||
setIconSrc(icon, "images/status/loading.svg");
|
||||
}
|
||||
|
||||
async handleDiskDetach () {
|
||||
const disk = this.dataset.volume;
|
||||
const header = `Detach ${disk}`;
|
||||
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
|
||||
dialog(header, body, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
||||
@@ -69,20 +68,13 @@ class VolumeAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async handleDiskAttach () {
|
||||
const header = `Attach ${this.dataset.volume}`;
|
||||
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) => {
|
||||
dialog(this.template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const device = form.get("device");
|
||||
this.setStatusLoading();
|
||||
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 disk = `${prefix}${device}`;
|
||||
@@ -97,15 +89,7 @@ class VolumeAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async handleDiskResize () {
|
||||
const header = `Resize ${this.dataset.volume}`;
|
||||
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) => {
|
||||
dialog(this.template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const disk = this.dataset.volume;
|
||||
this.setStatusLoading();
|
||||
@@ -123,25 +107,7 @@ class VolumeAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async handleDiskMove () {
|
||||
const content = type === "qemu" ? "images" : "rootdir";
|
||||
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) => {
|
||||
const d = dialog(this.template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const disk = this.dataset.volume;
|
||||
this.setStatusLoading();
|
||||
@@ -157,13 +123,20 @@ class VolumeAction extends HTMLElement {
|
||||
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 () {
|
||||
const disk = this.dataset.volume;
|
||||
const header = `Delete ${disk}`;
|
||||
const body = `<p>Are you sure you want to <strong>delete</strong> disk ${disk}</p>`;
|
||||
dialog(header, body, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
||||
@@ -201,26 +174,8 @@ async function refreshVolumes () {
|
||||
}
|
||||
|
||||
async function handleDiskAdd () {
|
||||
const content = type === "qemu" ? "images" : "rootdir";
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
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) => {
|
||||
const template = document.querySelector("#add-disk-dialog");
|
||||
const d = dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
storage: form.get("storage-select"),
|
||||
@@ -237,19 +192,21 @@ async function handleDiskAdd () {
|
||||
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 () {
|
||||
const isos = await requestAPI("/user/vm-isos", "GET");
|
||||
const header = "Mount a CDROM";
|
||||
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) => {
|
||||
const template = document.querySelector("#add-cd-dialog");
|
||||
const d = dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
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) {
|
||||
isoSelect.append(new Option(iso.name, iso.volid));
|
||||
for (const iso of isos.data) {
|
||||
select.add(new Option(iso.name, iso.volid));
|
||||
}
|
||||
isoSelect.selectedIndex = -1;
|
||||
select.selectedIndex = -1;
|
||||
}
|
||||
|
||||
class NetworkAction extends HTMLElement {
|
||||
@@ -279,6 +237,7 @@ class NetworkAction extends HTMLElement {
|
||||
super();
|
||||
const internals = this.attachInternals();
|
||||
this.shadowRoot = internals.shadowRoot;
|
||||
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||
if (this.dataset.type === "config") {
|
||||
this.addEventListener("click", this.handleNetworkConfig);
|
||||
}
|
||||
@@ -288,21 +247,14 @@ class NetworkAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async setStatusLoading () {
|
||||
const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`);
|
||||
setSVGSrc(svg, "images/status/loading.svg");
|
||||
const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
|
||||
setIconSrc(icon, "images/status/loading.svg");
|
||||
}
|
||||
|
||||
async handleNetworkConfig () {
|
||||
const netID = this.dataset.network;
|
||||
const netDetails = this.dataset.value;
|
||||
const header = `Edit ${netID}`;
|
||||
const 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>
|
||||
`;
|
||||
|
||||
const d = dialog(header, body, async (result, form) => {
|
||||
const netID = this.dataset.network;
|
||||
const d = dialog(this.template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const body = {
|
||||
@@ -323,11 +275,9 @@ class NetworkAction extends HTMLElement {
|
||||
|
||||
async handleNetworkDelete () {
|
||||
const netID = this.dataset.network;
|
||||
const header = `Delete ${netID}`;
|
||||
const body = "";
|
||||
dialog(header, body, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
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 result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
|
||||
if (result.status !== 200) {
|
||||
@@ -361,17 +311,8 @@ async function refreshNetworks () {
|
||||
}
|
||||
|
||||
async function handleNetworkAdd () {
|
||||
const header = "Create Network Interface";
|
||||
let 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 (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) => {
|
||||
const template = document.querySelector("#add-net-dialog");
|
||||
dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
rate: form.get("rate")
|
||||
@@ -398,6 +339,7 @@ class DeviceAction extends HTMLElement {
|
||||
super();
|
||||
const internals = this.attachInternals();
|
||||
this.shadowRoot = internals.shadowRoot;
|
||||
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||
if (this.dataset.type === "config") {
|
||||
this.addEventListener("click", this.handleDeviceConfig);
|
||||
}
|
||||
@@ -407,22 +349,15 @@ class DeviceAction extends HTMLElement {
|
||||
}
|
||||
|
||||
async setStatusLoading () {
|
||||
const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`);
|
||||
setSVGSrc(svg, "images/status/loading.svg");
|
||||
const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
|
||||
setIconSrc(icon, "images/status/loading.svg");
|
||||
}
|
||||
|
||||
async handleDeviceConfig () {
|
||||
const deviceID = this.dataset.device;
|
||||
const deviceDetails = this.dataset.value;
|
||||
const deviceName = this.dataset.name;
|
||||
const header = `Edit Expansion Card ${deviceID}`;
|
||||
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) => {
|
||||
const d = dialog(this.template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const body = {
|
||||
@@ -440,7 +375,7 @@ class DeviceAction extends HTMLElement {
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
|
||||
for (const availDevice of availDevices) {
|
||||
for (const availDevice of availDevices.data) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||
}
|
||||
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
||||
@@ -448,9 +383,7 @@ class DeviceAction extends HTMLElement {
|
||||
|
||||
async handleDeviceDelete () {
|
||||
const deviceID = this.dataset.device;
|
||||
const header = `Remove Expansion Card ${deviceID}`;
|
||||
const body = "";
|
||||
dialog(header, body, async (result, form) => {
|
||||
dialog(this.template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.setStatusLoading();
|
||||
const device = `${deviceID}`;
|
||||
@@ -487,15 +420,8 @@ async function refreshDevices () {
|
||||
}
|
||||
|
||||
async function handleDeviceAdd () {
|
||||
const header = "Add Expansion Card";
|
||||
const 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>
|
||||
`;
|
||||
const d = dialog(header, body, async (result, form) => {
|
||||
const template = document.querySelector("#add-device-dialog");
|
||||
const d = dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const hostpci = form.get("hostpci");
|
||||
const body = {
|
||||
@@ -511,8 +437,8 @@ async function handleDeviceAdd () {
|
||||
}
|
||||
});
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
for (const availDevice of availDevices) {
|
||||
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
|
||||
for (const availDevice of availDevices.data) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||
}
|
||||
d.querySelector("#pcie").checked = true;
|
||||
@@ -523,14 +449,15 @@ async function refreshBoot () {
|
||||
if (boot.status !== 200) {
|
||||
alert("Error fetching instance boot order.");
|
||||
}
|
||||
else {
|
||||
else if (type === "qemu") {
|
||||
boot = boot.data;
|
||||
const order = document.querySelector("#boot-order");
|
||||
order.setHTMLUnsafe(boot);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormExit () {
|
||||
async function handleFormExit (event) {
|
||||
event.preventDefault();
|
||||
const body = {
|
||||
cores: document.querySelector("#cores").value,
|
||||
memory: document.querySelector("#ram").value
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
export function dialog (header, body, onclose = async (result, form) => { }) {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.innerHTML = `
|
||||
<p class="w3-large" id="prompt" style="text-align: center;"></p>
|
||||
<div id="body"></div>
|
||||
<div class="w3-center w3-container">
|
||||
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
|
||||
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
|
||||
</div>
|
||||
`;
|
||||
dialog.className = "w3-container w3-card w3-border-0";
|
||||
dialog.querySelector("#prompt").innerText = header;
|
||||
dialog.querySelector("#body").innerHTML = body;
|
||||
/**
|
||||
* Spawn modal dialog from template node. Assumes the following structure:
|
||||
* <template>
|
||||
* <dialog>
|
||||
* <p id="prompt"></p>
|
||||
* <div id="body">
|
||||
* <form id="form"> ... </form>
|
||||
* </div>
|
||||
* <div id="controls">
|
||||
* <button value="..." form="form"
|
||||
* <button value="..." form="form"
|
||||
* ...
|
||||
* </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 () => {
|
||||
const formElem = dialog.querySelector("form");
|
||||
const formData = formElem ? new FormData(formElem) : null;
|
||||
await onclose(dialog.returnValue, formData);
|
||||
formElem.reset();
|
||||
dialog.close();
|
||||
dialog.parentElement.removeChild(dialog);
|
||||
});
|
||||
if (!dialog.querySelector("form")) {
|
||||
dialog.querySelector("#confirm").addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
dialog.close(e.target.value);
|
||||
});
|
||||
dialog.querySelector("#cancel").addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
dialog.close(e.target.value);
|
||||
});
|
||||
for (const control of dialog.querySelector("#controls").childNodes) {
|
||||
control.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
dialog.close(e.target.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
@@ -33,23 +42,114 @@ export function dialog (header, body, onclose = async (result, form) => { }) {
|
||||
}
|
||||
|
||||
export function alert (message) {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog">
|
||||
<p class="w3-center" style="margin-bottom: 0px;">${message}</p>
|
||||
<div class="w3-center">
|
||||
<button class="w3-button w3-margin" id="submit">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
dialog.className = "w3-container w3-card w3-border-0";
|
||||
const dialog = document.querySelector("#alert-dialog");
|
||||
if (dialog == null) {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.id = "alert-dialog";
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog">
|
||||
<p class="w3-large" id="prompt">Alert</p>
|
||||
<p class="w3-center" style="margin-bottom: 0;">${message}</p>
|
||||
<div class="w3-center">
|
||||
<button class="w3-button w3-margin" id="submit">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
dialog.className = "w3-container w3-card w3-border-0";
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
dialog.addEventListener("close", () => {
|
||||
dialog.parentElement.removeChild(dialog);
|
||||
});
|
||||
return dialog;
|
||||
}
|
||||
else {
|
||||
console.error("Attempted to create a new alert while one already exists!");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
document.body.append(dialog);
|
||||
dialog.showModal();
|
||||
class ErrorDialog extends HTMLElement {
|
||||
shadowRoot = null;
|
||||
dialog = null;
|
||||
errors = null;
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
dialog.parentElement.removeChild(dialog);
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const blank = document.createElement("img");
|
||||
blank.src = "images/common/blank.png"; // for whatever reason an svg does NOT work here
|
||||
|
||||
class DraggableContainer extends HTMLElement {
|
||||
shadowRoot = null;
|
||||
@@ -12,7 +13,7 @@ class DraggableContainer extends HTMLElement {
|
||||
window.Sortable.create(this.content, {
|
||||
group: this.dataset.group,
|
||||
ghostClass: "ghost",
|
||||
setData: function (dataTransfer, dragEl) {
|
||||
setData: function (dataTransfer, _dragEl) {
|
||||
dataTransfer.setDragImage(blank, 0, 0);
|
||||
}
|
||||
});
|
||||
@@ -48,7 +49,7 @@ class DraggableContainer extends HTMLElement {
|
||||
|
||||
get value () {
|
||||
const value = [];
|
||||
this.content.childNodes.forEach((element) => {
|
||||
this.content.querySelectorAll(".draggable-item").forEach((element) => {
|
||||
if (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 { alert, dialog } from "./dialog.js";
|
||||
import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt } from "./utils.js";
|
||||
import { alert, dialog, error } from "./dialog.js";
|
||||
import { setupClientSync } from "./clientsync.js";
|
||||
import wfaInit from "../modules/wfa.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ async function init () {
|
||||
wfaInit("modules/wfa.wasm");
|
||||
initInstances();
|
||||
|
||||
document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd);
|
||||
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
|
||||
document.querySelector("#vm-search").addEventListener("input", sortInstances);
|
||||
|
||||
setupClientSync(refreshInstances);
|
||||
@@ -120,48 +120,52 @@ class InstanceCard extends HTMLElement {
|
||||
}
|
||||
|
||||
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
||||
if (powerButton.classList.contains("clickable")) {
|
||||
powerButton.onclick = this.handlePowerButton.bind(this);
|
||||
}
|
||||
|
||||
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);
|
||||
if (powerButton !== null) {
|
||||
if (powerButton.classList.contains("clickable")) {
|
||||
powerButton.onclick = this.handlePowerButton.bind(this);
|
||||
powerButton.onkeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.handlePowerButton();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||
if (deleteButton.classList.contains("clickable")) {
|
||||
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||
if (deleteButton !== null) {
|
||||
if (deleteButton.classList.contains("clickable")) {
|
||||
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||
deleteButton.onkeydown = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
this.handleDeleteButton();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStatusLoading() {
|
||||
this.status = "loading"
|
||||
let statusicon = this.shadowRoot.querySelector("#status")
|
||||
let powerbtn = this.shadowRoot.querySelector("#power-btn")
|
||||
setSVGSrc(statusicon, "images/status/loading.svg")
|
||||
setSVGAlt(statusicon, "instance is loading")
|
||||
setSVGSrc(powerbtn, "images/status/loading.svg")
|
||||
setSVGAlt(powerbtn, "")
|
||||
setStatusLoading () {
|
||||
this.status = "loading";
|
||||
const statusicon = this.shadowRoot.querySelector("#status");
|
||||
const powerbtn = this.shadowRoot.querySelector("#power-btn");
|
||||
setIconSrc(statusicon, "images/status/loading.svg");
|
||||
setIconAlt(statusicon, "instance is loading");
|
||||
setIconSrc(powerbtn, "images/status/loading.svg");
|
||||
setIconAlt(powerbtn, "");
|
||||
}
|
||||
|
||||
async handlePowerButton () {
|
||||
if (!this.actionLock) {
|
||||
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`;
|
||||
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`;
|
||||
dialog(header, body, async (result, form) => {
|
||||
const template = this.shadowRoot.querySelector("#power-dialog");
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
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 });
|
||||
this.setStatusLoading()
|
||||
this.setStatusLoading();
|
||||
|
||||
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 () {
|
||||
if (!this.actionLock && this.status === "stopped") {
|
||||
const header = `Delete VM ${this.vmid}`;
|
||||
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||
dialog(template, async (result, _form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
|
||||
@@ -235,7 +223,7 @@ async function getInstancesFragment () {
|
||||
async function refreshInstances () {
|
||||
let instances = await getInstancesFragment();
|
||||
if (instances.status !== 200) {
|
||||
alert("Error fetching instances.");
|
||||
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
|
||||
}
|
||||
else {
|
||||
instances = instances.data;
|
||||
@@ -255,11 +243,11 @@ function initInstances () {
|
||||
}
|
||||
|
||||
function sortInstances () {
|
||||
const searchCriteria = getSearchSettings();
|
||||
const searchCriteria = getSetting("search-criteria");
|
||||
const searchQuery = document.querySelector("#search").value || null;
|
||||
let criteria;
|
||||
if (!searchQuery) {
|
||||
criteria = (item, query = null) => {
|
||||
criteria = (item, _query = null) => {
|
||||
return { score: item.vmid, alignment: null };
|
||||
};
|
||||
}
|
||||
@@ -269,9 +257,9 @@ function sortInstances () {
|
||||
if (substrInc) {
|
||||
const substrStartIndex = item.indexOf(query);
|
||||
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)}`;
|
||||
return { score: 1, alignment };
|
||||
return { score: -1, alignment };
|
||||
}
|
||||
else {
|
||||
const alignment = `${"X".repeat(item.length)}`;
|
||||
@@ -288,8 +276,8 @@ function sortInstances () {
|
||||
};
|
||||
criteria = (item, query) => {
|
||||
// lower is better
|
||||
const { score, CIGAR } = global.wfAlign(query, item, penalties, true);
|
||||
const alignment = global.DecodeCIGAR(CIGAR);
|
||||
const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
|
||||
const alignment = global.wfa.DecodeCIGAR(CIGAR);
|
||||
return { score: score / item.length, alignment };
|
||||
};
|
||||
}
|
||||
@@ -324,47 +312,9 @@ function sortInstances () {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstanceAdd () {
|
||||
const header = "Create New Instance";
|
||||
|
||||
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) => {
|
||||
async function handleInstanceAddButton () {
|
||||
const template = document.querySelector("#create-instance-dialog");
|
||||
const d = dialog(template, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
name: form.get("name"),
|
||||
@@ -393,8 +343,10 @@ async function handleInstanceAdd () {
|
||||
}
|
||||
});
|
||||
|
||||
// setup type select
|
||||
const typeSelect = d.querySelector("#type");
|
||||
typeSelect.selectedIndex = -1;
|
||||
// on type change, reveal or hide the container specific section
|
||||
typeSelect.addEventListener("change", () => {
|
||||
if (typeSelect.value === "qemu") {
|
||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||
@@ -409,75 +361,79 @@ async function handleInstanceAdd () {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const rootfsContent = "rootdir";
|
||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
const userResources = await requestAPI("/user/dynamic/resources", "GET");
|
||||
const userCluster = await requestAPI("/user/config/cluster", "GET");
|
||||
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
const clusterNodes = await requestPVE("/nodes", "GET");
|
||||
const allowedNodes = Object.keys(userCluster.nodes);
|
||||
clusterNodes.data.forEach((element) => {
|
||||
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||
nodeSelect.add(new Option(element.node));
|
||||
}
|
||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||
element.classList.add("none");
|
||||
element.disabled = true;
|
||||
});
|
||||
|
||||
// setup pool select
|
||||
const poolSelect = d.querySelector("#pool");
|
||||
poolSelect.innerHTML = "";
|
||||
// add user pools to selector
|
||||
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
|
||||
userPools.forEach((element) => {
|
||||
poolSelect.add(new Option(element));
|
||||
});
|
||||
poolSelect.selectedIndex = -1;
|
||||
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
|
||||
poolSelect.addEventListener("change", async () => {
|
||||
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
|
||||
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
nodeSelect.innerHTML = "";
|
||||
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
|
||||
const allowedNodes = Object.keys(pool["nodes-allowed"]);
|
||||
clusterNodes.forEach((element) => {
|
||||
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||
nodeSelect.add(new Option(element.node));
|
||||
}
|
||||
});
|
||||
nodeSelect.selectedIndex = -1;
|
||||
|
||||
// set vmid min/max
|
||||
d.querySelector("#vmid").min = pool["vmid-allowed"].min;
|
||||
d.querySelector("#vmid").max = pool["vmid-allowed"].max;
|
||||
});
|
||||
|
||||
// setup node select
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
nodeSelect.selectedIndex = -1;
|
||||
// on node change, get the available storages and repopulate the storage selector
|
||||
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
|
||||
const node = nodeSelect.value;
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
storage.data.forEach((element) => {
|
||||
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
|
||||
rootfsStorage.innerHTML = "";
|
||||
storage.forEach((element) => {
|
||||
if (element.content.includes(rootfsContent)) {
|
||||
rootfsStorage.add(new Option(element.storage));
|
||||
}
|
||||
});
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
// set core and memory min/max depending on node selected
|
||||
if (node in userResources.cores.nodes) {
|
||||
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#cores").max = userResources.cores.global.avail;
|
||||
}
|
||||
|
||||
if (node in userResources.memory.nodes) {
|
||||
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#memory").max = userResources.memory.global.avail;
|
||||
}
|
||||
});
|
||||
|
||||
// set vmid min/max
|
||||
d.querySelector("#vmid").min = userCluster.vmid.min;
|
||||
d.querySelector("#vmid").max = userCluster.vmid.max;
|
||||
|
||||
// add user pools to selector
|
||||
const poolSelect = d.querySelector("#pool");
|
||||
const userPools = Object.keys(userCluster.pools);
|
||||
userPools.forEach((element) => {
|
||||
poolSelect.add(new Option(element));
|
||||
});
|
||||
poolSelect.selectedIndex = -1;
|
||||
// setup root dir select
|
||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
// set rootfs content type (rootdir)
|
||||
const rootfsContent = "rootdir";
|
||||
|
||||
// setup templateImage depending on selected image storage
|
||||
const templateImage = d.querySelector("#template-image");
|
||||
// add template images to selector
|
||||
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
|
||||
for (const template of templates) {
|
||||
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||
for (const template of templates.data) {
|
||||
templateImage.append(new Option(template.name, template.volid));
|
||||
}
|
||||
templateImage.selectedIndex = -1;
|
||||
|
||||
// setup custom password checker for containers
|
||||
const password = d.querySelector("#password");
|
||||
const confirmPassword = d.querySelector("#confirm-password");
|
||||
|
||||
function validatePassword () {
|
||||
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||
}
|
||||
|
||||
password.addEventListener("change", validatePassword);
|
||||
confirmPassword.addEventListener("keyup", validatePassword);
|
||||
|
||||
d.showModal();
|
||||
}
|
||||
|
||||
@@ -1,35 +1,67 @@
|
||||
import { setAppearance, getSyncSettings, getSearchSettings, getThemeSettings, setSyncSettings, setSearchSettings, setThemeSettings } from "./utils.js";
|
||||
import { setAppearance, getSetting, setSetting } from "./utils.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
function init () {
|
||||
setAppearance();
|
||||
const { scheme, rate } = getSyncSettings();
|
||||
if (scheme) {
|
||||
document.querySelector(`#sync-${scheme}`).checked = true;
|
||||
}
|
||||
if (rate) {
|
||||
document.querySelector("#sync-rate").value = rate;
|
||||
}
|
||||
|
||||
const search = getSearchSettings();
|
||||
if (search) {
|
||||
document.querySelector(`#search-${search}`).checked = true;
|
||||
}
|
||||
document.querySelectorAll("[id^=sync-]").forEach((v) => {
|
||||
v.addEventListener("change", handleSettingsChange);
|
||||
});
|
||||
document.querySelector(`#sync-${getSetting("sync-scheme")}`).checked = true;
|
||||
document.querySelector("#sync-rate").value = getSetting("sync-rate");
|
||||
|
||||
const theme = getThemeSettings();
|
||||
if (theme) {
|
||||
document.querySelector("#appearance-theme").value = theme;
|
||||
}
|
||||
document.querySelectorAll("[id^=search-]").forEach((v) => {
|
||||
v.addEventListener("change", handleSettingsChange);
|
||||
});
|
||||
document.querySelector(`#search-${getSetting("search-criteria")}`).checked = true;
|
||||
|
||||
document.querySelector("#appearance-theme").addEventListener("change", handleSettingsChange);
|
||||
document.querySelector("#appearance-theme").value = getSetting("appearance-theme");
|
||||
|
||||
document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false);
|
||||
}
|
||||
|
||||
function handleSettingsChange (event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(document.querySelector("#settings"));
|
||||
const saveBtn = document.querySelector("#save");
|
||||
if (getSetting("sync-scheme") !== form.get("sync-scheme")) {
|
||||
saveBtn.classList.remove("disabled");
|
||||
saveBtn.classList.add("enabled");
|
||||
console.log("here1")
|
||||
}
|
||||
else if (getSetting("sync-rate") !== Number(form.get("sync-rate"))) {
|
||||
saveBtn.classList.remove("disabled");
|
||||
saveBtn.classList.add("enabled");
|
||||
console.log("here2")
|
||||
}
|
||||
else if (getSetting("search-criteria") !== form.get("search-criteria")) {
|
||||
saveBtn.classList.remove("disabled");
|
||||
saveBtn.classList.add("enabled");
|
||||
console.log("here3")
|
||||
}
|
||||
else if (getSetting("appearance-theme") !== form.get("appearance-theme")) {
|
||||
saveBtn.classList.remove("disabled");
|
||||
saveBtn.classList.add("enabled");
|
||||
console.log("here4")
|
||||
}
|
||||
else {
|
||||
saveBtn.classList.remove("enabled");
|
||||
saveBtn.classList.add("disabled");
|
||||
console.log("here5")
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveSettings (event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(document.querySelector("#settings"));
|
||||
setSyncSettings(form.get("sync-scheme"), form.get("sync-rate"));
|
||||
setSearchSettings(form.get("search-criteria"));
|
||||
setThemeSettings(form.get("appearance-theme"));
|
||||
const saveBtn = document.querySelector("#save");
|
||||
setSetting("sync-scheme", form.get("sync-scheme"));
|
||||
setSetting("sync-rate", Number(form.get("sync-rate")));
|
||||
setSetting("search-criteria", form.get("search-criteria"));
|
||||
setSetting("appearance-theme", form.get("appearance-theme"));
|
||||
saveBtn.classList.remove("enabled");
|
||||
saveBtn.classList.add("disabled");
|
||||
init();
|
||||
}
|
||||
|
||||
@@ -80,28 +80,34 @@ async function request (url, content) {
|
||||
try {
|
||||
const response = await fetch(url, content);
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
let data = null;
|
||||
if (contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
data.status = response.status;
|
||||
const res = {};
|
||||
|
||||
if (contentType === null) {
|
||||
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")) {
|
||||
data = { data: await response.text() };
|
||||
data.status = response.status;
|
||||
res.data = await response.text();
|
||||
res.status = response.status;
|
||||
}
|
||||
else if (contentType.includes("text/plain")) {
|
||||
data = { data: await response.text() };
|
||||
data.status = response.status;
|
||||
res.data = await response.text();
|
||||
res.status = response.status;
|
||||
}
|
||||
else {
|
||||
data = response;
|
||||
res.data = null;
|
||||
res.status = response.status;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { status: response.status, error: data ? data.error : response.status };
|
||||
return { status: response.status, error: res.data ? res.data.error : response.status };
|
||||
}
|
||||
else {
|
||||
data.status = response.status;
|
||||
return data || response;
|
||||
return res;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
@@ -114,79 +120,35 @@ export function goToPage (page, data = null) {
|
||||
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 () {
|
||||
const url = new URL(window.location.href);
|
||||
return Object.fromEntries(url.searchParams);
|
||||
}
|
||||
|
||||
const settingsDefault = {
|
||||
"sync-scheme": "always",
|
||||
"sync-rate": 5,
|
||||
"search-criteria": "fuzzy",
|
||||
"appearance-theme": "auto"
|
||||
const settings = {
|
||||
"sync-scheme": {"type": String, "default": "always"},
|
||||
"sync-rate": {"type": Number, "default": 5},
|
||||
"search-criteria": {"type": String, "default": "fuzzy"},
|
||||
"appearance-theme": {"type": String, "default": "auto"}
|
||||
};
|
||||
|
||||
export function getSyncSettings () {
|
||||
let scheme = localStorage.getItem("sync-scheme");
|
||||
let rate = Number(localStorage.getItem("sync-rate"));
|
||||
if (!scheme) {
|
||||
scheme = settingsDefault["sync-scheme"];
|
||||
localStorage.setItem("sync-scheme", scheme);
|
||||
export function getSetting (key) {
|
||||
const meta = settings[key];
|
||||
let value = localStorage.getItem(key);
|
||||
if (value === null || meta === null) {
|
||||
value = meta.default;
|
||||
localStorage.setItem(key, meta.default);
|
||||
}
|
||||
if (!rate) {
|
||||
rate = settingsDefault["sync-rate"];
|
||||
localStorage.setItem("sync-rate", rate);
|
||||
}
|
||||
return { scheme, rate };
|
||||
|
||||
return meta.type(value);
|
||||
}
|
||||
|
||||
export function getSearchSettings () {
|
||||
let searchCriteria = localStorage.getItem("search-criteria");
|
||||
if (!searchCriteria) {
|
||||
searchCriteria = settingsDefault["search-criteria"];
|
||||
localStorage.setItem("search-criteria", searchCriteria);
|
||||
}
|
||||
return searchCriteria;
|
||||
}
|
||||
|
||||
export function getThemeSettings () {
|
||||
let theme = localStorage.getItem("appearance-theme");
|
||||
if (!theme) {
|
||||
theme = settingsDefault["appearance-theme"];
|
||||
localStorage.setItem("appearance-theme", theme);
|
||||
}
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function setSyncSettings (scheme, rate) {
|
||||
localStorage.setItem("sync-scheme", scheme);
|
||||
localStorage.setItem("sync-rate", rate);
|
||||
}
|
||||
|
||||
export function setSearchSettings (criteria) {
|
||||
localStorage.setItem("search-criteria", criteria);
|
||||
}
|
||||
|
||||
export function setThemeSettings (theme) {
|
||||
localStorage.setItem("appearance-theme", theme);
|
||||
export function setSetting (key, value) {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function setAppearance () {
|
||||
const theme = getThemeSettings();
|
||||
const theme = getSetting("appearance-theme");
|
||||
if (theme === "auto") {
|
||||
document.querySelector(":root").classList.remove("dark-theme", "light-theme");
|
||||
}
|
||||
@@ -200,16 +162,10 @@ export function setAppearance () {
|
||||
}
|
||||
}
|
||||
|
||||
// assumes href is path to svg, and id to grab is #symb
|
||||
export function setSVGSrc (svgElem, href) {
|
||||
let useElem = svgElem.querySelector("use");
|
||||
if (!useElem) {
|
||||
useElem = document.createElementNS("http://www.w3.org/2000/svg", "use");
|
||||
}
|
||||
useElem.setAttribute("href", `${href}#symb`);
|
||||
svgElem.append(useElem);
|
||||
export function setIconSrc (icon, path) {
|
||||
icon.setAttribute("src", path);
|
||||
}
|
||||
|
||||
export function setSVGAlt (svgElem, alt) {
|
||||
svgElem.setAttribute("aria-label", alt);
|
||||
export function setIconAlt (icon, 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"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.global.Organization}} - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="modules/w3.css">
|
||||
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
|
||||
<script>
|
||||
window.PVE = "{{.global.PVE}}";
|
||||
window.API = "{{.global.API}}";
|
||||
@@ -15,18 +15,21 @@
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
{{end}}
|
||||
|
||||
{{/* <header> common across all pages*/}}
|
||||
{{define "header"}}
|
||||
<h1>{{.global.Organization}}</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav id="navigation">
|
||||
{{if eq .page "login"}}
|
||||
<a href="login" aria-current="page">Login</a>
|
||||
{{else}}
|
||||
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
|
||||
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
|
||||
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
|
||||
<a href="login">Logout</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<header>
|
||||
<h1>{{.global.Organization}}</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav id="navigation">
|
||||
{{if eq .page "login"}}
|
||||
<a href="login" aria-current="page">Login</a>
|
||||
{{else}}
|
||||
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
|
||||
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
|
||||
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
|
||||
<a href="login">Logout</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
{{end}}
|
||||
@@ -1,50 +1,103 @@
|
||||
{{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>
|
||||
{{template "select" .}}
|
||||
<div></div>
|
||||
{{end}}
|
||||
|
||||
{{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>
|
||||
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||
<p>Cores</p>
|
||||
{{end}}
|
||||
|
||||
{{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>
|
||||
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||
<p>MiB</p>
|
||||
{{end}}
|
||||
|
||||
{{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>
|
||||
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
|
||||
<p>MiB</p>
|
||||
{{end}}
|
||||
|
||||
{{define "volumes"}}
|
||||
{{range $k,$v := .}}
|
||||
{{range $k,$v := .Volumes}}
|
||||
{{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"}}
|
||||
{{ template "volume-mp" Map "Name" $k "Volume" $v}}
|
||||
{{ template "volume-mp" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||
{{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")}}
|
||||
{{ template "volume-scsi" Map "Name" $k "Volume" $v}}
|
||||
{{ template "volume-scsi" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
|
||||
{{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}}
|
||||
{{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"}}
|
||||
<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>{{.Volume.File}}</p>
|
||||
<div>
|
||||
@@ -56,7 +109,7 @@
|
||||
{{end}}
|
||||
|
||||
{{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>{{.Volume.File}}</p>
|
||||
<div>
|
||||
@@ -68,7 +121,7 @@
|
||||
{{end}}
|
||||
|
||||
{{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>{{.Volume.File}}</p>
|
||||
<div>
|
||||
@@ -80,7 +133,7 @@
|
||||
{{end}}
|
||||
|
||||
{{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>{{.Volume.File}}</p>
|
||||
<div>
|
||||
@@ -92,7 +145,7 @@
|
||||
{{end}}
|
||||
|
||||
{{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>{{.Volume.File}}</p>
|
||||
<div>
|
||||
@@ -107,7 +160,24 @@
|
||||
<volume-action data-type="move" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -116,7 +186,7 @@
|
||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -125,7 +195,24 @@
|
||||
<volume-action data-type="resize" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -134,7 +221,7 @@
|
||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -143,7 +230,23 @@
|
||||
<volume-action data-type="delete" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -152,7 +255,7 @@
|
||||
<volume-action data-type="none" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -161,7 +264,31 @@
|
||||
<volume-action data-type="attach" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -170,7 +297,23 @@
|
||||
<volume-action data-type="detach" data-volume="{{.Name}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -179,7 +322,7 @@
|
||||
<volume-action data-type="none">
|
||||
<template shadowrootmode="open">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<svg aria-label=""></svg>
|
||||
<img alt="" src="images/common/blank.svg">
|
||||
</template>
|
||||
</volume-action>
|
||||
{{end}}
|
||||
@@ -190,21 +333,80 @@
|
||||
{{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"}}
|
||||
<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>{{.Value}}</p>
|
||||
<div>
|
||||
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</network-action>
|
||||
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
|
||||
<template shadowrootmode="open">
|
||||
<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>
|
||||
</network-action>
|
||||
</div>
|
||||
@@ -216,88 +418,143 @@
|
||||
{{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"}}
|
||||
<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_Name}}</p>
|
||||
<div>
|
||||
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}">
|
||||
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
|
||||
<template shadowrootmode="open">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<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>
|
||||
</device-action>
|
||||
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
|
||||
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
|
||||
<template shadowrootmode="open">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<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>
|
||||
</device-action>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "boot"}}
|
||||
<draggable-container id="enabled" data-group="boot">
|
||||
<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>
|
||||
{{template "boot-container" Map "ID" "enabled" "Name" "Enabled" "Targets" .Enabled}}
|
||||
<hr style="padding: 0; margin: 0;">
|
||||
<draggable-container id="disabled" data-group="boot">
|
||||
<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>
|
||||
{{template "boot-container" Map "ID" "disabled" "Name" "Disabled" "Targets" .Disabled}}
|
||||
{{end}}
|
||||
|
||||
{{define "boot-style"}}
|
||||
<style>
|
||||
div.draggable-item.ghost {
|
||||
border: 1px dashed var(--main-text-color);
|
||||
border-radius: 5px;
|
||||
margin: -1px;
|
||||
}
|
||||
div.draggable-item {
|
||||
cursor: grab;
|
||||
}
|
||||
div.draggable-item svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
* {
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
{{define "boot-container"}}
|
||||
<draggable-container id="{{.ID}}" data-group="boot">
|
||||
<template shadowrootmode="open">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
div.draggable-item.ghost {
|
||||
border: 1px dashed var(--main-text-color);
|
||||
border-radius: 5px;
|
||||
margin: -1px;
|
||||
}
|
||||
div.draggable-item {
|
||||
cursor: grab;
|
||||
}
|
||||
div.draggable-item img {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
div.draggable-item p {
|
||||
margin: 0;
|
||||
}
|
||||
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}}
|
||||
|
||||
{{define "boot-target"}}
|
||||
{{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;">
|
||||
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
|
||||
<svg aria-label="Volume"><use href="images/resources/drive.svg#symb"></use></svg>
|
||||
<p style="margin: 0px;">{{.volume_id}}</p>
|
||||
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
|
||||
<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;">
|
||||
<img alt="Drag" src="images/actions/drag.svg#symb">
|
||||
<img alt="Volume" src="images/resources/drive.svg#symb">
|
||||
<p class="volume-id">{{.volume_id}}</p>
|
||||
<p class="volume-file">{{.file}}</p>
|
||||
</div>
|
||||
{{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;">
|
||||
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
|
||||
<svg aria-label="Net"><use href="images/resources/network.svg#symb"></use></svg>
|
||||
<p style="margin: 0px;">{{.net_id}}</p>
|
||||
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
|
||||
<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;">
|
||||
<img alt="Drag" src="images/actions/drag.svg#symb">
|
||||
<img alt="Net" src="images/resources/network.svg#symb">
|
||||
<p class="volume-id">{{.net_id}}</p>
|
||||
<p class="volume-file">{{.value}}</p>
|
||||
</div>
|
||||
{{else}}
|
||||
{{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}}">
|
||||
<template shadowrootmode="open">
|
||||
<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">
|
||||
<style>
|
||||
* {
|
||||
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>
|
||||
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
|
||||
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;">
|
||||
<p class="w3-col l1 m2 s6">{{.VMID}}</p>
|
||||
<p class="w3-col l2 m3 s6" id="instance-name">{{.Name}}</p>
|
||||
<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"}}
|
||||
<svg id="status" aria-label="instance is running"><use href="images/status/active.svg#symb"></svg>
|
||||
{{else if eq .Status "stopped"}}
|
||||
<svg id="status" aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg>
|
||||
{{else if eq .Status "loading"}}
|
||||
<svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
|
||||
{{else}}
|
||||
{{end}}
|
||||
<p>{{.Status}}</p>
|
||||
</div>
|
||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.Node}}</p>
|
||||
<div class="w3-col l2 w3-hide-medium w3-hide-small flex row nowrap">
|
||||
{{if eq .NodeStatus "online"}}
|
||||
<svg aria-label="node is online"><use href="images/status/active.svg#symb"></svg>
|
||||
{{else if eq .NodeStatus "offline"}}
|
||||
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
|
||||
{{else if eq .NodeStatus "unknown"}}
|
||||
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
|
||||
{{else}}
|
||||
{{end}}
|
||||
<p>{{.NodeStatus}}</p>
|
||||
</div>
|
||||
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
|
||||
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
|
||||
<svg id="power-btn" class="clickable" aria-label="shutdown instance"><use href="images/actions/instance/stop.svg#symb"></svg>
|
||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
||||
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
|
||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
||||
{{else if and (eq .NodeStatus "online") (eq .Status "stopped")}}
|
||||
<svg id="power-btn" class="clickable" aria-label="start instance"><use href="images/actions/instance/start.svg#symb"></svg>
|
||||
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
|
||||
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
||||
<svg id="delete-btn" class="clickable" aria-label="delete instance"><use href="images/actions/instance/delete-active.svg#symb"></svg>
|
||||
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
|
||||
<svg id="power-btn" aria-label=""><use href="images/actions/instance/loading.svg#symb"></svg>
|
||||
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
|
||||
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
|
||||
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
|
||||
{{else}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p>{{.VMID}}</p>
|
||||
<p id="instance-name">{{.Name}}</p>
|
||||
<p class="hide-small">{{.Type}}</p>
|
||||
<div class="flex row nowrap hide-tiny">
|
||||
{{if eq .Status "running"}}
|
||||
<img id="status" alt="instance is running" src="images/status/active.svg#symb">
|
||||
{{else if eq .Status "stopped"}}
|
||||
<img id="status" alt="instance is stopped" src="images/status/inactive.svg#symb">
|
||||
{{else if eq .Status "loading"}}
|
||||
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
|
||||
{{else}}
|
||||
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
|
||||
{{end}}
|
||||
<p>{{.Status}}</p>
|
||||
</div>
|
||||
<p class="hide-medium">{{.Node}}</p>
|
||||
<div class="flex row nowrap hide-medium">
|
||||
{{if eq .NodeStatus "online"}}
|
||||
<img alt="node is online" src="images/status/active.svg#symb">
|
||||
{{else if eq .NodeStatus "offline"}}
|
||||
<img alt="node is offline" src="images/status/inactive.svg#symb">
|
||||
{{else if eq .NodeStatus "unknown"}}
|
||||
<img alt="node is offline" src="images/status/inactive.svg#symb">
|
||||
{{else}}
|
||||
{{end}}
|
||||
<p>{{.NodeStatus}}</p>
|
||||
</div>
|
||||
<div class="flex row nowrap">
|
||||
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
|
||||
<img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb">
|
||||
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
|
||||
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
|
||||
<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")}}
|
||||
<img id="power-btn" class="clickable" alt="start instance" role="button" tabindex=0 src="images/actions/instance/start.svg#symb">
|
||||
<a href="{{.ConfigPath}}">
|
||||
<img id="configure-btn" class="clickable" alt="change configuration" src="images/actions/instance/config-active.svg#symb">
|
||||
</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")}}
|
||||
<img id="power-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/loading.svg#symb">
|
||||
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
|
||||
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
|
||||
<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}}
|
||||
{{end}}
|
||||
</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 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>
|
||||
</instance-card>
|
||||
{{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>
|
||||
<template shadowrootmode="open">
|
||||
<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">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
#container{
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 10px 10px 10px 10px;
|
||||
border-radius: 5px;
|
||||
padding: 0.5em;
|
||||
}
|
||||
progress {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
height: 1em;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
#caption {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
margin-top: 0.5em;
|
||||
display: flex;
|
||||
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>
|
||||
<div id="container">
|
||||
<progress value="{{.Used}}" max="{{.Max}}"></progress>
|
||||
<p id="caption">
|
||||
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
|
||||
<label id="caption" for="resource">
|
||||
<span>{{.Name}}</span>
|
||||
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||
</p>
|
||||
<span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
</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"}}
|
||||
<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}}
|
||||
<option value="{{.Value}}" selected>{{.Display}}</option>
|
||||
{{else}}
|
||||
<option value="{{.Value}}">{{.Display}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||