91 Commits

Author SHA1 Message Date
alu d88a208da5 font consistency fixes 2026-06-05 21:38:51 +00:00
alu 3b1b20b506 template cleanup 2026-06-04 17:58:42 +00:00
alu 66747fa657 update go mod 2026-06-03 17:05:23 +00:00
alu f40638598d move flag parsing to app.go 2026-06-02 18:00:24 +00:00
alu 0082f2f3e5 update go mod 2026-05-31 02:18:56 +00:00
alu 549316ab73 remove temp message notifying of login delay regression 2026-05-30 06:22:27 +00:00
alu fc58861046 add release compile flag for mapping mime types 2026-05-29 16:35:12 +00:00
alu 9b7404c8d6 update go mod, fix get config to use os.Root 2026-05-28 20:05:20 +00:00
alu 08cd4dfaaa add various missing error handling 2026-05-27 18:43:27 +00:00
alu 26a21e6cc7 move config-inactive to common 2026-05-26 22:43:07 +00:00
alu c3fe936e05 initial changes for API v2.0.0:
- added access manager api token to auth object
- update account page to show pool based resource quotas
- update config logic to use pool based resource quotas
- minor improvements and cleanup
2026-05-26 20:28:21 +00:00
alu eb201de26b add prefer-const to eslint rules,
remove empty block in dialog
2026-05-21 22:00:52 +00:00
alu 545061b2eb update sortable js module 2026-05-01 18:57:42 +00:00
alu 0692c1b96c update configs 2026-04-29 21:34:09 +00:00
alu 4f9d19eb39 update proxmoxaas-common-lib 2026-04-27 21:57:02 +00:00
alu c92c4c4e6d update proxmoxaas-common-lib 2026-04-22 16:28:24 +00:00
alu f44df0446e update go to 1.26.2, update go mod 2026-04-22 16:28:04 +00:00
alu 7475ae30b7 update go mod 2026-04-19 18:13:03 +00:00
alu b2820a3051 update eslint to v10 2026-04-19 17:46:12 +00:00
alu 7aadc03fc9 migrate to using proxmoxaas-common-lib types 2026-04-19 00:55:07 +00:00
alu 9606d62e78 removed fabric as submodule 2026-04-19 00:32:43 +00:00
alu 3f754c65b9 update go mod 2026-04-04 22:28:00 +00:00
alu 5788e1b493 properly return error values if a server error occurs 2026-03-28 07:25:59 +00:00
alu 3636878bb7 fix issue where users with larger pve task audit permission could cause a parse error when extracting vmid value from task without a vmid value 2026-03-28 07:25:25 +00:00
alu 6b094690f9 update paas-fabric 2026-03-25 17:42:39 +00:00
alu 3d8e4b416d update go mod 2026-03-25 17:40:56 +00:00
alu 7a5888be82 update go mod 2026-02-27 21:53:19 +00:00
alu d06f62f5e5 update go mod 2026-02-12 06:01:09 +00:00
alu 72b1179fc9 update paas fabric submodule 2025-12-09 00:51:34 +00:00
alu 32c23daf9d update go mod 2025-12-09 00:50:45 +00:00
alu b86be4c749 add a bunch of comments to important code 2025-11-18 19:36:05 +00:00
alu 338ea3342e remove external Sortable js dependency 2025-11-07 22:56:10 +00:00
alu 3b7d3ba01f fix regression in draggable boot order list drag image,
update Sortable.js,
fix mising icons in boot order list
2025-11-07 21:03:34 +00:00
alu 9e1fca8597 fix missing img alt in templates 2025-10-30 05:30:05 +00:00
alu c8ca49b85c cleanup css 2025-10-28 21:33:55 +00:00
alu 2877f7709a fix issues with icon coloring in chrome by switching to img tags 2025-10-28 18:34:42 +00:00
alu 7db0bea35c temporary fix for error dialog 2025-10-16 23:16:14 +00:00
alu ff98eb318e fix linting 2025-10-14 04:50:04 +00:00
alu 05ced39598 fix exact match bug, 2025-10-13 20:56:05 +00:00
alu 4e2b6278d8 fix chrome flicker for smaller screen sizes 2025-10-10 22:14:57 +00:00
alu 75e098b7b4 fix wrong css class definition in instance card to fix chrome flicker issue 2025-10-10 22:12:37 +00:00
alu 8c378a3b49 update go mod 2025-10-10 21:53:35 +00:00
alu 3d5989a946 fix flickering issue with instance-card refresh and instance search 2025-10-10 21:41:10 +00:00
alu 06afdcec37 reimplement instance card using display;contents to have better alignment and fitting to grid for instance card container,
migrate away from using w3-hide to custom hide for breakpoint fixes
2025-10-10 06:35:28 +00:00
alu 2f21b23535 fix instance card action area slightly too small in medium layouts 2025-10-03 03:58:08 +00:00
alu e8dd28b519 update index with wfa update 2025-10-02 22:04:12 +00:00
alu e0c7a53d85 update wfa,
minor style fix
2025-10-02 22:03:23 +00:00
alu 118b7dac53 minor style fix 2025-10-02 17:52:08 +00:00
alu db32f318b9 update go mod 2025-10-02 17:45:40 +00:00
alu c13a4c8539 update wfa 2025-09-30 17:50:36 +00:00
alu 8d490cd336 remove remaining external css links 2025-09-29 22:05:08 +00:00
alu 343c149330 update w3 css,
remove external css link to speedup render times
2025-09-29 21:59:26 +00:00
alu d95a82f248 move flag parsing to main, add debug mime types 2025-09-25 00:27:24 +00:00
alu fc42de2c49 fix bugs with boot order interface 2025-09-25 00:27:00 +00:00
alu 3f723394c4 update go mod 2025-09-23 03:56:07 +00:00
alu e7627b5787 update go mod, update fabric submodule 2025-09-23 03:53:11 +00:00
alu 7732da0642 fix bug in CPU type selector 2025-09-13 21:42:18 +00:00
alu 87c42495ad bump version to 1.0.0 2025-09-07 05:54:39 +00:00
alu 89065254d2 update fabric 2025-09-07 05:44:32 +00:00
alu 08e5f8b392 fix linting 2025-09-07 05:44:22 +00:00
alu 8be935a421 update go mod, update fabric 2025-09-02 20:12:20 +00:00
alu f94dca7e0c update go mod 2025-08-12 22:14:58 +00:00
alu 8905886065 fix favicon viewbox issue 2025-08-11 22:41:43 +00:00
alu df6772c72b add categorization to account resources 2025-08-11 22:40:39 +00:00
alu f3b6c0abf4 update go mod,
update submodules
2025-08-11 22:37:13 +00:00
alu 69fae92313 add mp selector to config 2025-08-06 19:42:33 +00:00
alu a79dd96d2a disable backup when instance is running 2025-08-04 21:22:52 +00:00
alu ee397c48e1 add restore function to backups 2025-07-25 21:49:52 +00:00
alu 33b0a4b5ff add edit, delete, and restore options to backups
update fabric submodule
2025-07-14 20:58:10 +00:00
alu 65c8fbdca8 implement user triggered backups 2025-07-07 20:59:12 +00:00
alu e932165a98 various minor fixes 2025-07-04 05:33:20 +00:00
alu 8c339794b3 move getAPI request interface body to parameter,
move VMPath and FormatNumber methods to common utils
2025-06-30 23:44:28 +00:00
alu a62fc83386 fix viewbox on delete icons 2025-06-25 22:21:17 +00:00
alu 756aef587d fix viewbox on instance action icons 2025-06-25 21:35:32 +00:00
alu ca555a7116 add unknown status to instance,
add svg minimize
2025-06-20 21:37:19 +00:00
alu 85c3ab49fc format index 2025-06-19 20:28:35 +00:00
alu 9ec277ce65 implement ssr dialog for config 2025-06-19 20:23:10 +00:00
alu e41c8d2a07 revert ssr dialog interface 2025-06-18 21:21:00 +00:00
alu 308d133e6e implement ssr modal dialog for index and account 2025-06-13 00:30:54 +00:00
alu 99d58eb250 add fabric as submodule 2025-06-02 19:25:27 +00:00
alu acd6eba520 fix bug in ssr dialog with multiple event listeners, add ssr dialog for instance delete 2025-05-29 18:33:01 +00:00
alu 478ca20451 implement ssr modal dialog form construction for create instances 2025-05-28 20:08:52 +00:00
alu 28c60aecc9 rename html fragments to go.tmpl extension 2025-05-28 20:08:25 +00:00
alu 3d677a46ee fix some accessibility issues with non focusable elements 2025-05-22 18:34:45 +00:00
alu e170d7f93d update go mod 2025-05-20 17:58:36 +00:00
alu 85bd81ef30 readd coloring to resource chart bars 2025-05-20 17:55:15 +00:00
alu 53832b67a2 minor optimizations to instance card config and console actions 2025-05-18 01:12:58 +00:00
alu e6cd1fbb3d fix linting 2025-05-13 18:37:06 +00:00
alu 3f21f3c4a4 Merge pull request 'Fix Sync Issue with Instance Power Action' (#4) from instance-power-sync-fix into main
Reviewed-on: #4
2025-05-13 17:41:28 +00:00
alu 1bcbed6828 minor code formatting to login.go 2025-05-13 17:35:54 +00:00
alu 31bfa79e66 update wfajs module 2025-05-13 17:35:35 +00:00
73 changed files with 2263 additions and 1167 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "proxmoxaas-common-lib"]
path = proxmoxaas-common-lib
url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib
+1 -1
View File
@@ -4,7 +4,7 @@ build: clean
@echo "======================== Building Binary =======================" @echo "======================== Building Binary ======================="
# resolve symbolic links in web by copying it into dist/web/ # resolve symbolic links in web by copying it into dist/web/
cp -rL web/ dist/web/ cp -rL web/ dist/web/
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ . CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
test: clean test: clean
go run . go run .
+11 -6
View File
@@ -4,43 +4,48 @@ import (
"flag" "flag"
"fmt" "fmt"
"log" "log"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes" "proxmoxaas-dashboard/app/routes"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
) )
func Run() { func Run() {
gin.SetMode(gin.ReleaseMode)
configPath := flag.String("config", "config.json", "path to config.json file") configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse() flag.Parse()
common.Global = common.GetConfig(*configPath) common.Global = common.GetConfig(*configPath)
// setup static resources
gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
m := common.InitMinify() m := common.InitMinify()
ServeStatic(router, m) ServeStatic(router, m)
html := common.MinifyStatic(m, web.Templates) html := common.MinifyStatic(m, web.Templates)
common.TMPL = common.LoadHTMLToGin(router, html) common.TMPL = common.LoadHTMLToGin(router, html)
router.GET("/account", routes.HandleGETAccount) // dynamic routes for pages and page fragments
router.GET("/", routes.HandleGETIndex) router.GET("/", routes.HandleGETIndex)
router.GET("/index", routes.HandleGETIndex) router.GET("/index", routes.HandleGETIndex)
router.GET("/index/instances", routes.HandleGETInstancesFragment) router.GET("/index/instances", routes.HandleGETInstancesFragment)
router.GET("/account", routes.HandleGETAccount)
router.GET("/config", routes.HandleGETConfig) router.GET("/config", routes.HandleGETConfig)
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment) router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
router.GET("/config/nets", routes.HandleGETConfigNetsFragment) router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment) router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
router.GET("/config/boot", routes.HandleGETConfigBootFragment) router.GET("/config/boot", routes.HandleGETConfigBootFragment)
router.GET("/backups", routes.HandleGETBackups)
router.GET("/backups/backups", routes.HandleGETBackupsFragment)
router.GET("/login", routes.HandleGETLogin) router.GET("/login", routes.HandleGETLogin)
router.GET("/settings", routes.HandleGETSettings) router.GET("/settings", routes.HandleGETSettings)
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port))) // run on all interfaces with port
log.Fatal("[Error] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
} }
// setup static resources under web (css, images, modules, scripts)
func ServeStatic(router *gin.Engine, m *minify.M) { func ServeStatic(router *gin.Engine, m *minify.M) {
css := common.MinifyStatic(m, web.CSS_fs) css := common.MinifyStatic(m, web.CSS_fs)
router.GET("/css/*css", func(c *gin.Context) { router.GET("/css/*css", func(c *gin.Context) {
+56
View File
@@ -0,0 +1,56 @@
//go:build !release
// +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,6 @@
//go:build release
// +build release
package common package common
import ( import (
@@ -7,6 +10,7 @@ import (
"github.com/tdewolff/minify/v2/css" "github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html" "github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js" "github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/svg"
) )
// defines mime type and associated minifier // defines mime type and associated minifier
@@ -35,6 +39,10 @@ var MimeTypes = map[string]MimeType{
}, },
"svg": { "svg": {
Type: "image/svg+xml", Type: "image/svg+xml",
Minifier: svg.Minify,
},
"png": {
Type: "image/png",
Minifier: nil, Minifier: nil,
}, },
"js": { "js": {
+20 -6
View File
@@ -1,5 +1,9 @@
package common package common
import "html/template"
var Global Config
type Config struct { type Config struct {
Port int `json:"listenPort"` Port int `json:"listenPort"`
Organization string `json:"organization"` Organization string `json:"organization"`
@@ -8,11 +12,23 @@ type Config struct {
API string `json:"apiurl"` API string `json:"apiurl"`
} }
// variable for html template root
// generated from LoadHTMLToGin
var TMPL *template.Template
// static served file type containing data and mimetype
type StaticFile struct { type StaticFile struct {
Data string Data string
MimeType MimeType MimeType MimeType
} }
// parsed vmpath data (ie node/type/vmid)
type VMPath struct {
Node string
Type string
VMID string
}
// type used for templated <select> // type used for templated <select>
type Select struct { type Select struct {
ID string ID string
@@ -27,17 +43,15 @@ type Option struct {
Display string Display string
} }
type RequestType int
type RequestContext struct { type RequestContext struct {
Cookies map[string]string Cookies map[string]string
Body map[string]any
} }
type Auth struct { type Auth struct {
Username string Username string
Token string Token string
CSRF string CSRF string
AccessManagerTicket string
} }
type Icon struct { type Icon struct {
+93 -17
View File
@@ -10,6 +10,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"math"
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
@@ -19,22 +20,29 @@ import (
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
) )
var TMPL *template.Template // get config file from configPath
var Global Config
func GetConfig(configPath string) Config { func GetConfig(configPath string) Config {
content, err := os.ReadFile(configPath) root, err := os.OpenRoot(".")
if err != nil {
log.Fatal("Error when opening root dir: ", err)
}
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil { if err != nil {
log.Fatal("Error when opening config file: ", err) log.Fatal("Error when opening config file: ", err)
} }
var config Config var config Config
err = json.Unmarshal(content, &config) err = json.Unmarshal(content, &config)
if err != nil { if err != nil {
log.Fatal("Error during parsing config file: ", err) log.Fatal("Error during parsing config file: ", err)
} }
return config return config
} }
// initialize minifier using the meta types specified
func InitMinify() *minify.M { func InitMinify() *minify.M {
m := minify.New() m := minify.New()
for _, v := range MimeTypes { for _, v := range MimeTypes {
@@ -47,14 +55,14 @@ func InitMinify() *minify.M {
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile { func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
minified := make(map[string]StaticFile) minified := make(map[string]StaticFile)
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error { err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if !entry.IsDir() { if !entry.IsDir() {
v, err := files.ReadFile(path) v, err := files.ReadFile(path)
if err != nil { if err != nil {
log.Fatalf("error parsing template file %s: %s", path, err.Error()) log.Fatalf("[Error] parsing template file %s: %s", path, err.Error())
} }
x := strings.Split(entry.Name(), ".") x := strings.Split(entry.Name(), ".")
if len(x) >= 2 { // file has extension if len(x) >= 2 { // file has extension
@@ -62,7 +70,7 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier
min, err := m.String(mimetype.Type, string(v)) // try to minify min, err := m.String(mimetype.Type, string(v)) // try to minify
if err != nil { if err != nil {
log.Fatalf("error minifying file %s: %s", path, err.Error()) log.Fatalf("[Error] minifying file %s: %s", path, err.Error())
} }
minified[path] = StaticFile{ minified[path] = StaticFile{
Data: min, Data: min,
@@ -84,7 +92,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
} }
return nil return nil
}) })
return minified
if err != nil {
log.Printf("[Error] MinifyStatic: %s", err)
return nil
} else {
return minified
}
} }
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template { func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
@@ -118,7 +132,7 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
}, },
} }
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html)) tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
engine.SetHTMLTemplate(tmpl) engine.SetHTMLTemplate(root)
return tmpl return tmpl
} }
@@ -159,13 +173,13 @@ func HandleNonFatalError(c *gin.Context, err error) {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
func RequestGetAPI(path string, context RequestContext) (*http.Response, int, error) { func RequestGetAPI(path string, context RequestContext, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil) req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
for k, v := range context.Cookies { for k, v := range context.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v}) req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
} }
client := &http.Client{} client := &http.Client{}
@@ -185,10 +199,18 @@ func RequestGetAPI(path string, context RequestContext) (*http.Response, int, er
if err != nil { if err != nil {
return nil, response.StatusCode, err return nil, response.StatusCode, err
} }
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
err = json.Unmarshal(data, &context.Body) case *map[string]any:
if err != nil { err = json.Unmarshal(data, &body)
return nil, response.StatusCode, err 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 return response, response.StatusCode, nil
@@ -199,9 +221,63 @@ func GetAuth(c *gin.Context) (Auth, error) {
username, errUsername := c.Cookie("username") username, errUsername := c.Cookie("username")
token, errToken := c.Cookie("PVEAuthCookie") token, errToken := c.Cookie("PVEAuthCookie")
csrf, errCSRF := c.Cookie("CSRFPreventionToken") csrf, errCSRF := c.Cookie("CSRFPreventionToken")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil { access, errAccess := c.Cookie("PAASAccessManagerTicket")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF) return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
} else { } else {
return Auth{username, token, csrf}, nil return Auth{username, token, csrf, access}, nil
}
}
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
}
func FormatNumber(val int64, base int64) (string, string) {
valf := float64(val)
basef := float64(base)
steps := 0
for math.Abs(valf) > basef && steps < 4 {
valf /= basef
steps++
}
switch base {
case 1000:
s := fmt.Sprintf("%.4f", valf)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
prefixes := []string{"", "K", "M", "G", "T"}
return s, prefixes[steps]
case 1024:
s := fmt.Sprintf("%.4f", valf)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return s, prefixes[steps]
default:
return "0", ""
}
}
func GetRequestContextFromCookies(auth Auth) RequestContext {
return RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
},
} }
} }
+204 -170
View File
@@ -2,102 +2,28 @@ package routes
import ( import (
"fmt" "fmt"
"math"
"net/http" "net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
account, err := GetUserAccount(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
for k, v := range account.Resources {
switch t := v.(type) {
case NumericResource:
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[k] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
}
case StorageResource:
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[k] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
}
case ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: float64(r.Avail), // usually an int
Unit: "",
})
}
account.Resources[k] = l
}
}
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"account": account,
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
type Account struct { type Account struct {
Username string paas.User
Pools map[string]bool Pools map[string]paas.Pool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]any
} }
// numerical constraint
type Constraint struct { type Constraint struct {
Max int64 Max int64
Used int64 Used int64
Avail int64 Avail int64
} }
// match constraint
type Match struct { type Match struct {
Name string Name string
Match string Match string
@@ -117,6 +43,7 @@ type NumericResource struct {
Global Constraint Global Constraint
Nodes map[string]Constraint Nodes map[string]Constraint
Total Constraint Total Constraint
Category string
} }
type StorageResource struct { type StorageResource struct {
@@ -131,6 +58,7 @@ type StorageResource struct {
Global Constraint Global Constraint
Nodes map[string]Constraint Nodes map[string]Constraint
Total Constraint Total Constraint
Category string
} }
type ListResource struct { type ListResource struct {
@@ -140,124 +68,230 @@ type ListResource struct {
Global []Match Global []Match
Nodes map[string][]Match Nodes map[string][]Match
Total []Match Total []Match
Category string
} }
type ResourceChart struct { type ResourceChart struct {
Type string Type string
Display bool Display bool
Name string Name string
Used int64 Used int64
Max int64 Max int64
Avail float64 Avail string
Prefix string Prefix string
Unit string Unit string
ColorHex string
} }
func GetUserAccount(auth common.Auth) (Account, error) { var Red = color.RGB{
account := Account{ R: 1,
Resources: map[string]any{}, G: 0,
} B: 0,
}
ctx := common.RequestContext{ var Green = color.RGB{
Cookies: map[string]string{ R: 0,
"username": auth.Username, G: 1,
"PVEAuthCookie": auth.Token, B: 0,
"CSRFPreventionToken": auth.CSRF, }
},
Body: map[string]any{},
}
// get user account basic data func HandleGETAccount(c *gin.Context) {
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx) auth, err := common.GetAuth(c)
if err != nil { if err == nil {
return account, err
} account, err := GetUser(auth)
if code != 200 { if err != nil {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res) common.HandleNonFatalError(c, err)
} return
err = mapstructure.Decode(ctx.Body, &account) }
if err != nil {
return account, err 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 := common.FormatNumber(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 := common.FormatNumber(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 { } 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{} func GetUser(auth common.Auth) (Account, error) {
// get user resources account := Account{}
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx) ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), ctx, &body)
if err != nil { if err != nil {
return account, err return account, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /user/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 common.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
// get all pools
ctx := common.GetRequestContextFromCookies(auth)
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", ctx, &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 // get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx) res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil { if err != nil {
return account, err return pools, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res) return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
} }
meta := ctx.Body["resources"].(map[string]any) meta := body["resources"].(map[string]any)
// build each resource by its meta type // for each pool
for k, v := range meta { for poolname, pool := range pools {
m := v.(map[string]any) // for each resource in pool data
t := m["type"].(string) for k, v := range pool.Resources {
r := resources[k].(map[string]any) m := meta[k].(map[string]any)
if t == "numeric" { t := m["type"].(string)
n := NumericResource{} r := v.(map[string]any)
n.Type = t category := m["category"].(string)
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n) // create a category if it does not already exist
if err_m != nil || err_r != nil { if _, ok := pool.Resources[category]; !ok {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) pool.Resources[category] = map[string]any{}
} }
account.Resources[k] = n
} else if t == "storage" { // depending on type, decode the pool data into the corresponding resource type
n := StorageResource{} switch t {
n.Type = t case "numeric":
err_m := mapstructure.Decode(m, &n) n := NumericResource{}
err_r := mapstructure.Decode(r, &n) n.Type = t
if err_m != nil || err_r != nil { err_m := mapstructure.Decode(m, &n)
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) 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" { // delete the old entry, only categories should be left at the end of the loop
n := ListResource{} delete(pools[poolname].Resources, k)
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
} }
} }
return account, nil return pools, nil
} }
func FormatNumber(val int64, base int64) (float64, string) { // interpolate between min and max by normalized (0 - 1) val
valf := float64(val) func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
basef := float64(base) minhsl := min.ToHSL()
steps := 0 maxhsl := max.ToHSL()
for math.Abs(valf) > basef && steps < 4 { interphsl := color.HSL{
valf /= basef H: (1-val)*minhsl.H + (val)*maxhsl.H,
steps++ S: (1-val)*minhsl.S + (val)*maxhsl.S,
} L: (1-val)*minhsl.L + (val)*maxhsl.L,
if base == 1000 {
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
return 0, ""
} }
return interphsl.ToRGB()
} }
+107
View File
@@ -0,0 +1,107 @@
package routes
import (
"fmt"
"net/http"
"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.GetAuth(c)
if err == nil {
vm_path, err := common.ExtractVMPath(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.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.ExtractVMPath(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 common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.GetRequestContextFromCookies(auth)
body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &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 := common.FormatNumber(backups[i].Size, 1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
}
return backups, nil
}
+84 -110
View File
@@ -3,22 +3,43 @@ package routes
import ( import (
"fmt" "fmt"
"net/http" "net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"slices" "slices"
"sort" "sort"
fabric "proxmoxaas-fabric/app"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
// imported types from fabric
type InstanceConfig struct {
paas.Instance `mapstructure:",squash"`
// overrides
ProctypeSelect common.Select
}
type GlobalConfig struct {
CPU struct {
Whitelist bool
}
}
type PoolConfig struct {
CPU struct {
Global []paas.MatchLimit
Nodes map[string][]paas.MatchLimit
}
}
func HandleGETConfig(c *gin.Context) { func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return
} }
config, err := GetInstanceConfig(vm_path, auth) config, err := GetInstanceConfig(vm_path, auth)
@@ -27,7 +48,7 @@ func HandleGETConfig(c *gin.Context) {
} }
if config.Type == "VM" { // if VM, fetch CPU types from node if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth) config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
if err != nil { if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error())) common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
} }
@@ -51,9 +72,10 @@ func HandleGETConfig(c *gin.Context) {
func HandleGETConfigVolumesFragment(c *gin.Context) { func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return
} }
config, err := GetInstanceConfig(vm_path, auth) config, err := GetInstanceConfig(vm_path, auth)
@@ -62,10 +84,14 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -74,9 +100,10 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
func HandleGETConfigNetsFragment(c *gin.Context) { func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return
} }
config, err := GetInstanceConfig(vm_path, auth) config, err := GetInstanceConfig(vm_path, auth)
@@ -85,10 +112,14 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -97,9 +128,10 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
func HandleGETConfigDevicesFragment(c *gin.Context) { func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return
} }
config, err := GetInstanceConfig(vm_path, auth) config, err := GetInstanceConfig(vm_path, auth)
@@ -108,10 +140,14 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
@@ -120,9 +156,10 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
func HandleGETConfigBootFragment(c *gin.Context) { func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
return
} }
config, err := GetInstanceConfig(vm_path, auth) config, err := GetInstanceConfig(vm_path, auth)
@@ -131,65 +168,25 @@ func HandleGETConfigBootFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{ err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else { } else {
c.Status(http.StatusUnauthorized) c.Status(http.StatusUnauthorized)
} }
} }
func ExtractVMPath(c *gin.Context) (VMPath, error) { func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
return vm_path, nil
}
type VMPath struct {
Node string
Type string
VMID string
}
// imported types from fabric
type InstanceConfig struct {
Type fabric.InstanceType `json:"type"`
Name string `json:"name"`
Proctype string `json:"cpu"`
Cores uint64 `json:"cores"`
Memory uint64 `json:"memory"`
Swap uint64 `json:"swap"`
Volumes map[string]*fabric.Volume `json:"volumes"`
Nets map[string]*fabric.Net `json:"nets"`
Devices map[string]*fabric.Device `json:"devices"`
Boot fabric.BootOrder `json:"boot"`
// overrides
ProctypeSelect common.Select
}
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
config := InstanceConfig{} config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID) path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{ ctx := common.GetRequestContextFromCookies(auth)
Cookies: map[string]string{ body := map[string]any{}
"username": auth.Username, res, code, err := common.RequestGetAPI(path, ctx, &body)
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
Body: map[string]any{},
}
res, code, err := common.RequestGetAPI(path, ctx)
if err != nil { if err != nil {
return config, err return config, err
} }
@@ -197,7 +194,7 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
return config, fmt.Errorf("request to %s resulted in %+v", path, res) return config, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
err = mapstructure.Decode(ctx.Body, &config) err = mapstructure.Decode(body, &config)
if err != nil { if err != nil {
return config, err return config, err
} }
@@ -208,77 +205,54 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
return config, nil return config, nil
} }
type GlobalConfig struct { func GetCPUTypes(vm common.VMPath, pool string, auth common.Auth) (common.Select, error) {
CPU struct {
Whitelist bool
}
}
type UserConfig struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
}
}
type CPUConfig struct {
Name string
}
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
cputypes := common.Select{ cputypes := common.Select{
ID: "proctype", ID: "proctype",
Required: true, Required: true,
} }
// get global resource config // get global resource config
ctx := common.RequestContext{ ctx := common.GetRequestContextFromCookies(auth)
Cookies: map[string]string{ body := map[string]any{}
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
Body: map[string]any{},
}
path := "/global/config/resources" path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx) res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
if code != 200 { if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
global := GlobalConfig{} globalConfig := GlobalConfig{}
err = mapstructure.Decode(ctx.Body["resources"], &global) err = mapstructure.Decode(body["resources"], &globalConfig)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// get user resource config // get pool resource config
ctx.Body = map[string]any{} body = map[string]any{}
path = "/user/config/resources" path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, ctx) res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
if code != 200 { if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
user := UserConfig{} poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(ctx.Body, &user) err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// use node specific rules if present, otherwise use global rules // use node specific rules if present, otherwise use global rules
var userCPU []CPUConfig var userCPU []paas.MatchLimit
if _, ok := user.CPU.Nodes[vm.Node]; ok { if _, ok := poolCPUConfig.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node] userCPU = poolCPUConfig.CPU.Nodes[vm.Node]
} else { } else {
userCPU = user.CPU.Global userCPU = poolCPUConfig.CPU.Global
} }
if global.CPU.Whitelist { // cpu is a whitelist if globalConfig.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{ cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name, Display: cpu.Name,
@@ -287,9 +261,9 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
} }
} else { // cpu is a blacklist } else { // cpu is a blacklist
// get the supported cpu types from the node // get the supported cpu types from the node
ctx.Body = map[string]any{} body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node) path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx) res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
@@ -297,16 +271,16 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
supported := struct { supported := struct {
data []CPUConfig data []paas.MatchLimit
}{} }{}
err = mapstructure.Decode(ctx.Body, supported) err = mapstructure.Decode(body, supported)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options // for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data { for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool { contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
return c.Name == cpu.Name return c.Name == cpu.Name
}) })
if !contains { if !contains {
+121 -74
View File
@@ -10,39 +10,17 @@ import (
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
func HandleGETIndex(c *gin.Context) { // primary type used in constructing instance cards in index
auth, err := common.GetAuth(c) type InstanceCard struct {
if err == nil { // user should be authed, try to return index with population VMID uint
instances, _, err := GetClusterResources(auth) Name string
if err != nil { Type string
common.HandleNonFatalError(c, err) Status string
} Node string
c.HTML(http.StatusOK, "html/index.html", gin.H{ NodeStatus string
"global": common.Global, ConfigPath string
"page": "index", ConsolePath string
"instances": instances, BackupsPath string
})
} else { // return index without populating
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func HandleGETInstancesFragment(c *gin.Context) {
Auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(Auth)
if err != nil {
common.HandleNonFatalError(c, err)
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.frag", gin.H{
"instances": instances,
})
c.Status(http.StatusOK)
} else { // return 401
c.Status(http.StatusUnauthorized)
}
} }
// used in constructing instance cards in index // used in constructing instance cards in index
@@ -51,60 +29,89 @@ type Node struct {
Status string `json:"status"` 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 // used in retriving cluster tasks
type Task struct { type Task struct {
Type string Type string
Node string Node string
User string User string
ID string ID string
VMID uint VMID uint
Status string Status string
EndTime uint
} }
type InstanceStatus struct { type InstanceStatus struct {
Status string Status string
} }
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) { func HandleGETIndex(c *gin.Context) {
ctx := common.RequestContext{ auth, err := common.GetAuth(c)
Cookies: map[string]string{ if err == nil { // user should be authed, try to return index with population
"PVEAuthCookie": auth.Token, instances, _, err := GetClusterResources(auth)
"CSRFPreventionToken": auth.CSRF, if err != nil {
}, common.HandleNonFatalError(c, err)
Body: map[string]any{}, return
}
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.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)
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 common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
ctx := common.GetRequestContextFromCookies(auth)
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow if code != 200 { // if we did not successfully retrieve resources, then return the error because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res) return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
} }
instances := map[uint]InstanceCard{} instances := map[uint]InstanceCard{}
nodes := map[string]Node{} nodes := map[string]Node{}
// if we successfully retrieved the resources, then process it and return index // parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range ctx.Body["data"].([]any) { for _, v := range body {
m := v.(map[string]any) m := v.(map[string]any)
if m["type"] == "node" { switch m["type"] {
case "node": // if type is node -> parse as Node object
node := Node{} node := Node{}
err := mapstructure.Decode(v, &node) err := mapstructure.Decode(v, &node)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
nodes[node.Node] = node nodes[node.Node] = node
} else if m["type"] == "lxc" || m["type"] == "qemu" { case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
instance := InstanceCard{} instance := InstanceCard{}
err := mapstructure.Decode(v, &instance) err := mapstructure.Decode(v, &instance)
if err != nil { if err != nil {
@@ -113,14 +120,27 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
instances[instance.VMID] = instance instances[instance.VMID] = instance
} }
} }
// once all basic instance and node stuff is parsed, go back and fill in cross referenced data
for vmid, instance := range instances { for vmid, instance := range instances {
nodestatus := nodes[instance.Node].Status // set instance's node status
instance.NodeStatus = nodestatus instance.NodeStatus = nodes[instance.Node].Status
// set instance's config link path
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// set the instance's console link path
switch instance.Type {
case "qemu":
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
case "lxc":
instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
}
// set the instance's backups link path
instance.BackupsPath = fmt.Sprintf("backups?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
// save back to instances map
instances[vmid] = instance instances[vmid] = instance
} }
ctx.Body = map[string]any{} body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx) res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -128,17 +148,25 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res) return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
} }
for _, v := range ctx.Body["data"].([]any) { most_recent_task := map[uint]uint{}
expected_states := map[uint]string{}
// iterate through recent user accessible tasks to find the task most recently made on an instance
for _, v := range body {
// parse task as Task object
task := Task{} task := Task{}
err := mapstructure.Decode(v, &task) err := mapstructure.Decode(v, &task)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// try to get task vmid but continue if it would be an invalid vmid
x, err := strconv.Atoi(task.ID) x, err := strconv.Atoi(task.ID)
task.VMID = uint(x) // if there was an error converting the task's vmid, skip it
if err != nil { if err != nil {
return nil, nil, err continue
} }
task.VMID = uint(x)
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task) if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
continue continue
@@ -151,11 +179,26 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK } else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
continue continue
} else { // recent task is a start or stop task for user instance which is running or "OK" } else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task
switch task.Type {
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
expected_states[task.VMID] = "running"
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
expected_states[task.VMID] = "stopped"
}
}
}
}
// iterate through the instances with recent tasks, refetch their state from a more reliable source
for vmid, expected_state := range expected_states { // for the expected states from recent tasks
if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state
// get /status/current which is updated faster than /cluster/resources // get /status/current which is updated faster than /cluster/resources
instance := instances[task.VMID] instance := instances[vmid]
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID) path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
ctx.Body = map[string]any{} body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx) res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -163,11 +206,15 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res) return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
// attempt to decode task status as instance status
status := InstanceStatus{} status := InstanceStatus{}
mapstructure.Decode(ctx.Body["data"], &status) err = mapstructure.Decode(body, &status)
if err != nil { // did not successfully decode task status, just skip
continue
}
instance.Status = status.Status instance.Status = status.Status
instances[task.VMID] = instance instances[vmid] = instance
} }
} }
+28 -28
View File
@@ -9,34 +9,6 @@ import (
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
func GetLoginRealms() ([]Realm, error) {
realms := []Realm{}
ctx := common.RequestContext{
Cookies: nil,
Body: map[string]any{},
}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx)
if err != nil {
return realms, err
}
if code != 200 { // we expect /access/domains to always be avaliable
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
}
for _, v := range ctx.Body["data"].([]any) {
v = v.(map[string]any)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
if err != nil {
return realms, err
}
realms = append(realms, realm)
}
return realms, nil
}
// used when requesting GET /access/domains // used when requesting GET /access/domains
type GetRealmsBody struct { type GetRealmsBody struct {
Data []Realm `json:"data"` Data []Realm `json:"data"`
@@ -49,6 +21,34 @@ type Realm struct {
Comment string `json:"comment"` Comment string `json:"comment"`
} }
func GetLoginRealms() ([]Realm, error) {
realms := []Realm{}
ctx := common.RequestContext{
Cookies: nil,
}
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil {
return realms, err
}
if code != 200 { // we expect /access/domains to always be avaliable
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
}
for _, v := range body {
v = v.(map[string]any)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
if err != nil {
return realms, err
}
realms = append(realms, realm)
}
return realms, nil
}
func HandleGETLogin(c *gin.Context) { func HandleGETLogin(c *gin.Context) {
realms, err := GetLoginRealms() realms, err := GetLoginRealms()
if err != nil { if err != nil {
-42
View File
@@ -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
}
]
}
}
+29
View File
@@ -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"]
},
}]);
+30 -35
View File
@@ -1,51 +1,46 @@
module proxmoxaas-dashboard module proxmoxaas-dashboard
go 1.24 go 1.26.4
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
github.com/go-viper/mapstructure/v2 v2.2.1 github.com/gin-gonic/gin v1.12.0
github.com/tdewolff/minify v2.3.6+incompatible github.com/go-viper/mapstructure/v2 v2.5.0
proxmoxaas-fabric v0.0.0 github.com/tdewolff/minify/v2 v2.24.13
proxmoxaas-common-lib v0.0.0
) )
replace proxmoxaas-fabric => ../ProxmoxAAS-Fabric replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
require ( require (
github.com/buger/goterm v1.0.4 // indirect github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.7 // indirect
github.com/diskfs/go-diskfs v1.5.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/djherbis/times v1.6.0 // indirect github.com/gin-contrib/sse v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.6 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/luthermonson/go-proxmox v0.2.2 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/tdewolff/minify/v2 v2.23.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/quic-go/quic-go v0.59.1 // indirect
github.com/tdewolff/parse/v2 v2.7.23 // indirect github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/tdewolff/test v1.0.11 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
golang.org/x/arch v0.16.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
golang.org/x/crypto v0.37.0 // indirect golang.org/x/arch v0.27.0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/sys v0.45.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect golang.org/x/text v0.37.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+8 -9
View File
@@ -1,20 +1,19 @@
{ {
"name": "proxmoxaas-dashboard", "name": "proxmoxaas-dashboard",
"version": "0.0.1", "version": "1.0.0",
"description": "Front-end for ProxmoxAAS", "description": "Front-end for ProxmoxAAS",
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "html-validate --config configs/.htmlvalidate.json web/html/*; stylelint --config configs/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config configs/.eslintrc.json --fix web/scripts/", "lint": "html-validate --config dev_config/.htmlvalidate.json web/html/*; stylelint --config dev_config/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config dev_config/eslint.config.mjs --fix web/scripts/",
"update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm" "update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.43.0", "@eslint/eslintrc": "^3.3.5",
"eslint-config-standard": "^17.1.0", "@eslint/js": "^10.0.1",
"eslint-plugin-import": "^2.27.5", "eslint": "^10.2.1",
"eslint-plugin-n": "^16.0.1", "globals": "^17.5.0",
"eslint-plugin-promise": "^6.1.1", "html-validate": "^9.4.0",
"stylelint": "^15.9.0", "stylelint": "^15.9.0",
"stylelint-config-standard": "^33.0.0", "stylelint-config-standard": "^33.0.0"
"html-validate": "^9.4.0"
} }
} }
+8 -3
View File
@@ -1,3 +1,8 @@
input, select, textarea {
background-color: var(--main-input-bg-color);
color: var(--main-text-color);
}
.input-grid { .input-grid {
display: grid; display: grid;
gap: 5px 10px; gap: 5px 10px;
@@ -16,7 +21,7 @@
padding: 8px; padding: 8px;
} }
.input-grid svg { .input-grid img {
padding: 0; padding: 0;
} }
@@ -36,6 +41,7 @@ legend {
fieldset { fieldset {
border: 0; border: 0;
padding: 0;
} }
fieldset > *:last-child { fieldset > *:last-child {
@@ -76,7 +82,6 @@ input[type="radio"] {
} }
dialog { dialog {
max-width: calc(min(50%, 80ch)); max-width: calc(min(100% - 16px, 80ch));
background-color: var(--main-bg-color);
color: var(--main-text-color); color: var(--main-text-color);
} }
+3 -4
View File
@@ -53,7 +53,6 @@ header {
} }
header h1 { header h1 {
font-size: 18px;
margin: 0; margin: 0;
background-color: var(--nav-header-bg-color); background-color: var(--nav-header-bg-color);
color: var(--nav-header-text-color); color: var(--nav-header-text-color);
@@ -61,8 +60,8 @@ header h1 {
} }
nav { nav {
font-size: var(--small-font-size);
overflow: hidden; overflow: hidden;
font-size: larger;
width: fit-content; width: fit-content;
} }
@@ -80,7 +79,7 @@ label[for="navtoggle"], #navtoggle {
display: none; display: none;
} }
@media screen and (width >= 600px){ @media screen and (width >= 601px){
header { header {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }
@@ -106,7 +105,7 @@ label[for="navtoggle"], #navtoggle {
} }
} }
@media screen and (width <= 600px){ @media screen and (width <= 601px){
header { header {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
+53 -36
View File
@@ -3,6 +3,9 @@
--positive-color: #0f0; --positive-color: #0f0;
--highlight-color: yellow; --highlight-color: yellow;
--lightbg-text-color: black; --lightbg-text-color: black;
--large-font-size: 32px;
--medium-font-size: 24px;
--small-font-size: 16px;
} }
@media screen and (prefers-color-scheme: dark) { @media screen and (prefers-color-scheme: dark) {
@@ -11,7 +14,6 @@
--main-text-color: white; --main-text-color: white;
--main-card-bg-color: #202020; --main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%); --main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-table-header-bg-color: black;
--main-input-bg-color: #404040; --main-input-bg-color: #404040;
} }
@@ -20,7 +22,6 @@
--main-text-color: black; --main-text-color: black;
--main-card-bg-color: white; --main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%); --main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-table-header-bg-color: #808080;
--main-input-bg-color: white; --main-input-bg-color: white;
} }
} }
@@ -31,7 +32,6 @@
--main-text-color: black; --main-text-color: black;
--main-card-bg-color: white; --main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%); --main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-table-header-bg-color: #808080;
--main-input-bg-color: white; --main-input-bg-color: white;
} }
@@ -40,18 +40,30 @@
--main-text-color: white; --main-text-color: white;
--main-card-bg-color: #202020; --main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%); --main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-table-header-bg-color: black;
--main-input-bg-color: #404040; --main-input-bg-color: #404040;
} }
} }
html { *, h1, h2, h3, p {
box-sizing: border-box; box-sizing: border-box;
background-color: var(--main-bg-color); font-family: monospace;
} }
* { h1, p {
font-family: monospace; font-size: var(--small-font-size);
}
h2 {
font-size: var(--large-font-size);
}
h3 {
font-size: var(--medium-font-size);
}
html {
background-color: var(--main-bg-color);
} }
body { body {
@@ -77,33 +89,24 @@ main {
margin-top: 16px; margin-top: 16px;
} }
th {
background-color: var(--main-table-header-bg-color);
}
td {
background-color: var(--main-card-bg-color);
}
input, select, textarea {
background-color: var(--main-input-bg-color);
color: var(--main-text-color);
}
img.clickable, svg.clickable {
cursor: pointer;
}
img, svg { img, svg {
height: 1em; height: 1em;
width: 1em; width: 1em;
color: var(--main-text-color) color: var(--main-text-color)
} }
hr, * { a img {
vertical-align: unset;
}
hr {
border-color: var(--main-text-color); border-color: var(--main-text-color);
} }
.clickable {
cursor: pointer;
}
.flex { .flex {
display: flex; display: flex;
} }
@@ -114,6 +117,12 @@ hr, * {
align-items: center; align-items: center;
} }
.column-reverse {
flex-direction: column-reverse;
row-gap: 10px;
align-items: center;
}
.wrap { .wrap {
flex-wrap: wrap; flex-wrap: wrap;
row-gap: 10px; row-gap: 10px;
@@ -156,18 +165,26 @@ hr, * {
} }
/* add hide large class similar to w3-hide-medium and w3-hide-small */ /* add hide large class similar to w3-hide-medium and w3-hide-small */
@media (width >=993px) { @media screen and (width >=993px) {
.w3-hide-large { .hide-large {display: none !important;}
display: none !important;
}
} }
/* fix edge case in w3-hide-medium where width between 992 and 993 */ /* fixes edge case in w3-hide-medium where width between 992 and 993 */
@media (width <=993px) and (width >=601px){ @media screen and (width <=993px) and (width >=601px){
.w3-hide-medium{display:none!important} .hide-large {display: none !important;}
.hide-medium {display:none !important}
} }
/* fix edge case in w3-hide-small when width between 600 and 601 */ /* fixes edge case in w3-hide-small when width between 600 and 601 */
@media (width <=601px) { @media screen and (width <=601px) {
.w3-hide-small{display:none!important} .hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
} }
+32 -36
View File
@@ -7,7 +7,7 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
<style> <style>
@media screen and (width >= 1264px){ @media screen and (width >= 1264px){
#resource-container { .resource-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, calc(100% / 6)); grid-template-columns: repeat(auto-fill, calc(100% / 6));
grid-gap: 0; grid-gap: 0;
@@ -15,7 +15,7 @@
} }
} }
@media screen and (width <= 1264px) and (width >= 680px) { @media screen and (width <= 1264px) and (width >= 680px) {
#resource-container { .resource-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, 200px); grid-template-columns: repeat(auto-fill, 200px);
grid-gap: 0; grid-gap: 0;
@@ -23,7 +23,7 @@
} }
} }
@media screen and (width <= 680px) { @media screen and (width <= 680px) {
#resource-container { .resource-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
@@ -34,44 +34,40 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<h2>Account</h2> <h2>Account</h2>
<section class="w3-card w3-padding"> <section class="w3-card w3-padding">
<h3>Account Details</h3> <h3>Account Details</h3>
<p id="username">Username: {{.account.Username}}</p> <p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p> <p id="email">Email: {{.account.Mail}}</p>
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p> <p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
</section>
<section 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>
</section> </section>
{{range $poolname, $pool := .account.Pools}}
{{template "pool-resources" $pool}}
{{end}}
</main> </main>
<template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</body> </body>
</html> </html>
+3
View File
@@ -0,0 +1,3 @@
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}
+36
View File
@@ -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
View File
@@ -1 +0,0 @@
{{template "volumes" .config.Volumes}}
+1
View File
@@ -0,0 +1 @@
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
+13 -24
View File
@@ -5,7 +5,6 @@
<script src="scripts/config.js" type="module"></script> <script src="scripts/config.js" type="module"></script>
<script src="scripts/draggable.js" type="module"></script> <script src="scripts/draggable.js" type="module"></script>
<script src="modules/Sortable.min.js"></script> <script src="modules/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<link rel="modulepreload" href="scripts/utils.js"> <link rel="modulepreload" href="scripts/utils.js">
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
<style> <style>
@@ -20,13 +19,11 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<section> <section>
<h2><a href="index">Instances</a> / {{.config.Name}}</h2> <h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
<form> <form id="config-form">
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
<legend>Resources</legend> <legend>Resources</legend>
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;"> <div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
@@ -43,18 +40,14 @@
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
<legend>Volumes</legend> <legend>Volumes</legend>
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;"> <div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
{{template "volumes" .config.Volumes}} {{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk"> <!--Add Disk Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Disk</span> {{template "volumes-add-disk" .}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg> <!--Add CD Button & Dialog Template-->
</button>
{{if eq .config.Type "VM"}} {{if eq .config.Type "VM"}}
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD"> {{template "volumes-add-cd"}}
<span class="large" style="margin: 0;">Mount CD</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
</button>
{{end}} {{end}}
</div> </div>
</fieldset> </fieldset>
@@ -64,10 +57,8 @@
{{template "nets" .config.Nets}} {{template "nets" .config.Nets}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface"> <!--Add Net Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Network</span> {{template "nets-add-net"}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Network Interface"><use href="images/actions/network/add.svg#symb"></use></svg>
</button>
</div> </div>
</fieldset> </fieldset>
{{if eq .config.Type "VM"}} {{if eq .config.Type "VM"}}
@@ -77,10 +68,8 @@
{{template "devices" .config.Devices}} {{template "devices" .config.Devices}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device"> <!--Add Device Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Device</span> {{template "devices-add-device"}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New PCIe Device"><use href="images/actions/device/add.svg#symb"></use></svg>
</button>
</div> </div>
</fieldset> </fieldset>
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
@@ -91,7 +80,7 @@
</fieldset> </fieldset>
{{end}} {{end}}
<div class="w3-container w3-center" id="form-actions"> <div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button> <button class="w3-button w3-margin" id="exit" type="submit">EXIT</button>
</div> </div>
</form> </form>
</section> </section>
+104 -21
View File
@@ -8,12 +8,6 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
<link rel="modulepreload" href="scripts/clientsync.js"> <link rel="modulepreload" href="scripts/clientsync.js">
<style> <style>
#instance-container > div {
border-bottom: 1px solid white;
}
#instance-container > div:last-child {
border-bottom: none;
}
@media screen and (width >= 440px) { @media screen and (width >= 440px) {
#vm-search { #vm-search {
max-width: calc(100% - 10px - 152px); max-width: calc(100% - 10px - 152px);
@@ -24,36 +18,125 @@
max-width: calc(100% - 10px - 47px); max-width: calc(100% - 10px - 47px);
} }
} }
@media screen and (width >= 993px) {
#instance-table {
display: grid;
grid-template-columns: repeat(7, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 993px) and (width >= 601px){
#instance-table {
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 601px) and (width >= 440px){
#instance-table {
display: grid;
grid-template-columns: repeat(4, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 440px) {
#instance-table {
display: grid;
grid-template-columns: repeat(3, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<section> <section>
<h2>Instances</h2> <h2>Instances</h2>
<div class="w3-card w3-padding"> <div class="w3-card w3-padding">
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;"> <div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
<form id="vm-search" role="search" class="flex row nowrap"> <form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg> <button type="submit"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button>
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name"> <input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
</form> </form>
<!--Add Instance Button & Dialog Template-->
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance"> <button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
<span class="large" style="margin: 0;">Create Instance</span> <span class="large" style="margin: 0;">Create Instance</span>
<svg class="small" style="height: 1lh; width: 1lh;" role="img" aria-label="Create New Instance"><use href="images/actions/instance/add.svg#symb"></use></svg> <img class="small" style="height: 1lh; width: 1lh;" alt="Create Instance" aria-label="Create Instance" src="images/actions/instance/add.svg#symb">
</button> </button>
<template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create New Instance
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" selected-index="-1" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</div> </div>
<div> <div id="instance-table">
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;"> <div id="instance-table-header">
<p class="w3-col l1 m2 w3-hide-small">ID</p> <p>ID</p>
<p class="w3-col l2 m3 w3-hide-small">Name</p> <p>Name</p>
<p class="w3-col l1 m2 w3-hide-small">Type</p> <p class="hide-tiny">Type</p>
<p class="w3-col l2 m3 w3-hide-small">Status</p> <p class="hide-small">Status</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Name</p> <p class="hide-medium">Host Name</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Status</p> <p class="hide-medium">Host Status</p>
<p class="w3-col l2 m2 w3-hide-small">Actions</p> <p>Actions</p>
</div> </div>
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
<div id="instance-container"> <div id="instance-container">
{{range .instances}} {{range .instances}}
{{template "instance-card" .}} {{template "instance-card" .}}
+1 -3
View File
@@ -7,9 +7,7 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main class="flex" style="justify-content: center; align-items: center;"> <main class="flex" style="justify-content: center; align-items: center;">
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;"> <div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
<h2 class="w3-center">{{.global.Organization}} Login</h2> <h2 class="w3-center">{{.global.Organization}} Login</h2>
+3 -3
View File
@@ -26,9 +26,7 @@
</style> </style>
</head> </head>
<body> <body>
<header> {{template "header" .}}
{{template "header" .}}
</header>
<main> <main>
<h2>Settings</h2> <h2>Settings</h2>
<form id="settings"> <form id="settings">
@@ -42,6 +40,8 @@
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p> <p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label> <label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p> <p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>App Sync Frequency</legend> <legend>App Sync Frequency</legend>
+1
View File
@@ -0,0 +1 @@
../../common/config.svg
+1
View File
@@ -0,0 +1 @@
../../common/delete-active.svg
+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 B

+1
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

+2 -2
View File
File diff suppressed because one or more lines are too long
+17 -1
View File
@@ -1,4 +1,4 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-button:hover{color:#000!important;background-color:#ccc!important} .w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important} .w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important} .w3-hover-none:hover{box-shadow:none!important}
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
/* Colors */ /* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
.w3-danger{color:#fff!important;background-color:#dd0000!important}
.w3-note{color:#000!important;background-color:#fff599!important}
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
.w3-warning{color:#000!important;background-color:#ffb305!important}
.w3-success{color:#fff!important;background-color:#008a00!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
+2 -1
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+5 -14
View File
@@ -1,24 +1,17 @@
import { dialog } from "./dialog.js";
import { requestAPI, setAppearance } from "./utils.js"; import { requestAPI, setAppearance } from "./utils.js";
import { dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
async function init () { async function init () {
setAppearance(); setAppearance();
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm); document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
} }
function handlePasswordChangeForm () { function handlePasswordChangeButton () {
const body = ` const template = document.querySelector("#change-password-dialog");
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form"> const d = dialog(template, async (result, form) => {
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password"required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
</form>
`;
const d = dialog("Change Password", body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") }); const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
if (result.status !== 200) { if (result.status !== 200) {
@@ -29,11 +22,9 @@ function handlePasswordChangeForm () {
const password = d.querySelector("#new-password"); const password = d.querySelector("#new-password");
const confirmPassword = d.querySelector("#confirm-password"); const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () { function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : ""); confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
} }
password.addEventListener("change", validatePassword); password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword); confirmPassword.addEventListener("keyup", validatePassword);
} }
+149
View File
@@ -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();
}
});
}
+5 -3
View File
@@ -2,8 +2,10 @@ import { getSyncSettings, requestAPI } from "./utils.js";
export async function setupClientSync (callback) { export async function setupClientSync (callback) {
const { scheme, rate } = getSyncSettings(); const { scheme, rate } = getSyncSettings();
if (scheme === "never") {
if (scheme === "always") { return;
}
else if (scheme === "always") {
window.setInterval(callback, rate * 1000); window.setInterval(callback, rate * 1000);
} }
else if (scheme === "hash") { else if (scheme === "hash") {
@@ -19,7 +21,7 @@ export async function setupClientSync (callback) {
} }
else if (scheme === "interrupt") { else if (scheme === "interrupt") {
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`); const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
socket.addEventListener("open", (event) => { socket.addEventListener("open", (_event) => {
socket.send(`rate ${rate}`); socket.send(`rate ${rate}`);
}); });
socket.addEventListener("message", (event) => { socket.addEventListener("message", (event) => {
+62 -135
View File
@@ -1,4 +1,4 @@
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setSVGSrc, requestDash } from "./utils.js"; import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js"; import { alert, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
@@ -19,7 +19,7 @@ async function init () {
initNetworks(); initNetworks();
initDevices(); initDevices();
document.querySelector("#exit").addEventListener("click", handleFormExit); document.querySelector("#config-form").addEventListener("submit", handleFormExit);
} }
class VolumeAction extends HTMLElement { class VolumeAction extends HTMLElement {
@@ -29,6 +29,7 @@ class VolumeAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "move") { if (this.dataset.type === "move") {
this.addEventListener("click", this.handleDiskMove); this.addEventListener("click", this.handleDiskMove);
} }
@@ -47,15 +48,13 @@ class VolumeAction extends HTMLElement {
} }
async setStatusLoading () { async setStatusLoading () {
const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`); const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
setSVGSrc(svg, "images/status/loading.svg"); setIconSrc(icon, "images/status/loading.svg");
} }
async handleDiskDetach () { async handleDiskDetach () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
const header = `Detach ${disk}`; dialog(this.template, async (result, _form) => {
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
@@ -69,20 +68,13 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskAttach () { async handleDiskAttach () {
const header = `Attach ${this.dataset.volume}`; dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label>
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const device = form.get("device"); const device = form.get("device");
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
source: this.dataset.volume.replace("unused", "") source: this.dataset.volume.replace("unused", ""),
mp: form.get("mp")
}; };
const prefix = type === "qemu" ? "scsi" : "mp"; const prefix = type === "qemu" ? "scsi" : "mp";
const disk = `${prefix}${device}`; const disk = `${prefix}${device}`;
@@ -97,15 +89,7 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskResize () { async handleDiskResize () {
const header = `Resize ${this.dataset.volume}`; dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="size-increment">Size Increment (GiB)</label>
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072">
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const disk = this.dataset.volume; const disk = this.dataset.volume;
this.setStatusLoading(); this.setStatusLoading();
@@ -123,25 +107,7 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskMove () { async handleDiskMove () {
const content = type === "qemu" ? "images" : "rootdir"; const d = dialog(this.template, async (result, form) => {
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const header = `Move ${this.dataset.volume}`;
let options = "";
storage.data.forEach((element) => {
if (element.content.includes(content)) {
options += `<option value="${element.storage}">${element.storage}</option>"`;
}
});
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"><option hidden disabled selected value></option>${options}</select>`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
${select}
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const disk = this.dataset.volume; const disk = this.dataset.volume;
this.setStatusLoading(); this.setStatusLoading();
@@ -157,13 +123,20 @@ class VolumeAction extends HTMLElement {
refreshBoot(); refreshBoot();
} }
}); });
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
} }
async handleDiskDelete () { async handleDiskDelete () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
const header = `Delete ${disk}`; dialog(this.template, async (result, _form) => {
const body = `<p>Are you sure you want to <strong>delete</strong> disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
@@ -201,26 +174,8 @@ async function refreshVolumes () {
} }
async function handleDiskAdd () { async function handleDiskAdd () {
const content = type === "qemu" ? "images" : "rootdir"; const template = document.querySelector("#add-disk-dialog");
const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); const d = dialog(template, async (result, form) => {
const header = "Create New Disk";
let options = "";
storage.data.forEach((element) => {
if (element.content.includes(content)) {
options += `<option value="${element.storage}">${element.storage}</option>"`;
}
});
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${options}</select>`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" value="0" required>
${select}
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
storage: form.get("storage-select"), storage: form.get("storage-select"),
@@ -237,19 +192,21 @@ async function handleDiskAdd () {
refreshBoot(); refreshBoot();
} }
}); });
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
} }
async function handleCDAdd () { async function handleCDAdd () {
const isos = await requestAPI("/user/vm-isos", "GET"); const template = document.querySelector("#add-cd-dialog");
const header = "Mount a CDROM"; const d = dialog(template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required>
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
iso: form.get("iso-select") iso: form.get("iso-select")
@@ -264,12 +221,13 @@ async function handleCDAdd () {
} }
}); });
const isoSelect = d.querySelector("#iso-select"); const isos = await requestAPI("/user/vm-isos", "GET");
const select = d.querySelector("#iso-select");
for (const iso of isos) { for (const iso of isos.data) {
isoSelect.append(new Option(iso.name, iso.volid)); select.add(new Option(iso.name, iso.volid));
} }
isoSelect.selectedIndex = -1; select.selectedIndex = -1;
} }
class NetworkAction extends HTMLElement { class NetworkAction extends HTMLElement {
@@ -279,6 +237,7 @@ class NetworkAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") { if (this.dataset.type === "config") {
this.addEventListener("click", this.handleNetworkConfig); this.addEventListener("click", this.handleNetworkConfig);
} }
@@ -288,21 +247,14 @@ class NetworkAction extends HTMLElement {
} }
async setStatusLoading () { async setStatusLoading () {
const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`); const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
setSVGSrc(svg, "images/status/loading.svg"); setIconSrc(icon, "images/status/loading.svg");
} }
async handleNetworkConfig () { async handleNetworkConfig () {
const netID = this.dataset.network;
const netDetails = this.dataset.value; const netDetails = this.dataset.value;
const header = `Edit ${netID}`; const netID = this.dataset.network;
const body = ` const d = dialog(this.template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
@@ -323,11 +275,9 @@ class NetworkAction extends HTMLElement {
async handleNetworkDelete () { async handleNetworkDelete () {
const netID = this.dataset.network; const netID = this.dataset.network;
const header = `Delete ${netID}`; dialog(this.template, async (result, _form) => {
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg"); setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `${netID}`; const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) { if (result.status !== 200) {
@@ -361,17 +311,8 @@ async function refreshNetworks () {
} }
async function handleNetworkAdd () { async function handleNetworkAdd () {
const header = "Create Network Interface"; const template = document.querySelector("#add-net-dialog");
let body = ` dialog(template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
`;
if (type === "lxc") {
body += "<label for=\"name\">Interface Name</label><input type=\"text\" id=\"name\" name=\"name\" class=\"w3-input w3-border\">";
}
body += "</form>";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
rate: form.get("rate") rate: form.get("rate")
@@ -398,6 +339,7 @@ class DeviceAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") { if (this.dataset.type === "config") {
this.addEventListener("click", this.handleDeviceConfig); this.addEventListener("click", this.handleDeviceConfig);
} }
@@ -407,22 +349,15 @@ class DeviceAction extends HTMLElement {
} }
async setStatusLoading () { async setStatusLoading () {
const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`); const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
setSVGSrc(svg, "images/status/loading.svg"); setIconSrc(icon, "images/status/loading.svg");
} }
async handleDeviceConfig () { async handleDeviceConfig () {
const deviceID = this.dataset.device; const deviceID = this.dataset.device;
const deviceDetails = this.dataset.value; const deviceDetails = this.dataset.value;
const deviceName = this.dataset.name; const deviceName = this.dataset.name;
const header = `Edit Expansion Card ${deviceID}`; const d = dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
@@ -440,7 +375,7 @@ class DeviceAction extends HTMLElement {
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0])); d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
for (const availDevice of availDevices) { for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus)); d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
} }
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1"); d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
@@ -448,9 +383,7 @@ class DeviceAction extends HTMLElement {
async handleDeviceDelete () { async handleDeviceDelete () {
const deviceID = this.dataset.device; const deviceID = this.dataset.device;
const header = `Remove Expansion Card ${deviceID}`; dialog(this.template, async (result, _form) => {
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const device = `${deviceID}`; const device = `${deviceID}`;
@@ -487,15 +420,8 @@ async function refreshDevices () {
} }
async function handleDeviceAdd () { async function handleDeviceAdd () {
const header = "Add Expansion Card"; const template = document.querySelector("#add-device-dialog");
const body = ` const d = dialog(template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="hostpci">Device Bus</label><input type="number" id="hostpci" name="hostpci" class="w3-input w3-border">
<label for="device">Device</label><select id="device" name="device" required></select>
<label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const hostpci = form.get("hostpci"); const hostpci = form.get("hostpci");
const body = { const body = {
@@ -511,8 +437,8 @@ async function handleDeviceAdd () {
} }
}); });
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET"); const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
for (const availDevice of availDevices) { for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus)); d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
} }
d.querySelector("#pcie").checked = true; d.querySelector("#pcie").checked = true;
@@ -523,14 +449,15 @@ async function refreshBoot () {
if (boot.status !== 200) { if (boot.status !== 200) {
alert("Error fetching instance boot order."); alert("Error fetching instance boot order.");
} }
else { else if (type === "qemu") {
boot = boot.data; boot = boot.data;
const order = document.querySelector("#boot-order"); const order = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot); order.setHTMLUnsafe(boot);
} }
} }
async function handleFormExit () { async function handleFormExit (event) {
event.preventDefault();
const body = { const body = {
cores: document.querySelector("#cores").value, cores: document.querySelector("#cores").value,
memory: document.querySelector("#ram").value memory: document.querySelector("#ram").value
+135 -36
View File
@@ -1,31 +1,40 @@
export function dialog (header, body, onclose = async (result, form) => { }) { /**
const dialog = document.createElement("dialog"); * Spawn modal dialog from template node. Assumes the following structure:
dialog.innerHTML = ` * <template>
<p class="w3-large" id="prompt" style="text-align: center;"></p> * <dialog>
<div id="body"></div> * <p id="prompt"></p>
<div class="w3-center w3-container"> * <div id="body">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> * <form id="form"> ... </form>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> * </div>
</div> * <div id="controls">
`; * <button value="..." form="form"
dialog.className = "w3-container w3-card w3-border-0"; * <button value="..." form="form"
dialog.querySelector("#prompt").innerText = header; * ...
dialog.querySelector("#body").innerHTML = body; * </div>
* </dialog>
* </template>
* Where prompt is the modal dialog's prompt or header,
* body contains an optional form or other information,
* and controls contains a series of buttons which controls the form
*/
export function dialog (template, onclose = async (_result, _form) => { }) {
const dialog = template.content.querySelector("dialog").cloneNode(true);
document.body.append(dialog);
dialog.addEventListener("close", async () => { dialog.addEventListener("close", async () => {
const formElem = dialog.querySelector("form"); const formElem = dialog.querySelector("form");
const formData = formElem ? new FormData(formElem) : null; const formData = formElem ? new FormData(formElem) : null;
await onclose(dialog.returnValue, formData); await onclose(dialog.returnValue, formData);
formElem.reset();
dialog.close();
dialog.parentElement.removeChild(dialog); dialog.parentElement.removeChild(dialog);
}); });
if (!dialog.querySelector("form")) { if (!dialog.querySelector("form")) {
dialog.querySelector("#confirm").addEventListener("click", async (e) => { for (const control of dialog.querySelector("#controls").childNodes) {
e.preventDefault(); control.addEventListener("click", async (e) => {
dialog.close(e.target.value); e.preventDefault();
}); dialog.close(e.target.value);
dialog.querySelector("#cancel").addEventListener("click", async (e) => { });
e.preventDefault(); }
dialog.close(e.target.value);
});
} }
document.body.append(dialog); document.body.append(dialog);
dialog.showModal(); dialog.showModal();
@@ -33,23 +42,113 @@ export function dialog (header, body, onclose = async (result, form) => { }) {
} }
export function alert (message) { export function alert (message) {
const dialog = document.createElement("dialog"); const dialog = document.querySelector("#alert-dialog");
dialog.innerHTML = ` if (dialog == null) {
<form method="dialog"> const dialog = document.createElement("dialog");
<p class="w3-center" style="margin-bottom: 0px;">${message}</p> dialog.id = "alert-dialog";
<div class="w3-center"> dialog.innerHTML = `
<button class="w3-button w3-margin" id="submit">OK</button> <form method="dialog">
</div> <p class="w3-center" style="margin-bottom: 0px;">${message}</p>
</form> <div class="w3-center">
`; <button class="w3-button w3-margin" id="submit">OK</button>
dialog.className = "w3-container w3-card w3-border-0"; </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); class ErrorDialog extends HTMLElement {
dialog.showModal(); shadowRoot = null;
dialog = null;
errors = null;
dialog.addEventListener("close", () => { constructor () {
dialog.parentElement.removeChild(dialog); 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: 0px;
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
}
#errors * {
margin: 0px;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt" style="text-align: center;">Error</p>
<div id="errors" class="flex column-reverse"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
</div>
</form>
</dialog>
`;
this.dialog = this.shadowRoot.querySelector("dialog");
this.errors = this.shadowRoot.querySelector("#errors");
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
control.addEventListener("click", async (e) => {
e.preventDefault();
this.dialog.close(e.target.value);
});
}
this.dialog.addEventListener("close", () => {
if (this.dialog.returnValue === "copy") {
let errors = "";
for (const error of this.errors.childNodes) {
errors += `${error.innerText}\n`;
}
navigator.clipboard.writeText(errors);
}
this.parentElement.removeChild(this);
});
}
appendError (error) {
error = `${(new Date()).toUTCString()}: ${error}`;
const p = document.createElement("p");
p.innerText = error;
this.errors.appendChild(p);
}
showModal () {
this.dialog.showModal();
}
}
customElements.define("error-dialog", ErrorDialog);
export function error (message) {
let dialog = document.querySelector("error-dialog");
if (dialog == null) {
dialog = document.createElement("error-dialog");
document.body.append(dialog);
dialog.appendError(message);
dialog.showModal();
}
else {
dialog.appendError(message);
dialog.showModal();
}
return dialog; return dialog;
} }
+3 -2
View File
@@ -1,4 +1,5 @@
const blank = document.createElement("img"); const blank = document.createElement("img");
blank.src = "images/common/blank.png"; // for whatever reason an svg does NOT work here
class DraggableContainer extends HTMLElement { class DraggableContainer extends HTMLElement {
shadowRoot = null; shadowRoot = null;
@@ -12,7 +13,7 @@ class DraggableContainer extends HTMLElement {
window.Sortable.create(this.content, { window.Sortable.create(this.content, {
group: this.dataset.group, group: this.dataset.group,
ghostClass: "ghost", ghostClass: "ghost",
setData: function (dataTransfer, dragEl) { setData: function (dataTransfer, _dragEl) {
dataTransfer.setDragImage(blank, 0, 0); dataTransfer.setDragImage(blank, 0, 0);
} }
}); });
@@ -48,7 +49,7 @@ class DraggableContainer extends HTMLElement {
get value () { get value () {
const value = []; const value = [];
this.content.childNodes.forEach((element) => { this.content.querySelectorAll(".draggable-item").forEach((element) => {
if (element.dataset.value) { if (element.dataset.value) {
value.push(element.dataset.value); value.push(element.dataset.value);
} }
+98 -142
View File
@@ -1,5 +1,5 @@
import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, requestDash, setSVGSrc, setSVGAlt } from "./utils.js"; import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js";
import { alert, dialog } from "./dialog.js"; import { alert, dialog, error } from "./dialog.js";
import { setupClientSync } from "./clientsync.js"; import { setupClientSync } from "./clientsync.js";
import wfaInit from "../modules/wfa.js"; import wfaInit from "../modules/wfa.js";
@@ -11,7 +11,7 @@ async function init () {
wfaInit("modules/wfa.wasm"); wfaInit("modules/wfa.wasm");
initInstances(); initInstances();
document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd); document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
document.querySelector("#vm-search").addEventListener("input", sortInstances); document.querySelector("#vm-search").addEventListener("input", sortInstances);
setupClientSync(refreshInstances); setupClientSync(refreshInstances);
@@ -120,48 +120,52 @@ class InstanceCard extends HTMLElement {
} }
const powerButton = this.shadowRoot.querySelector("#power-btn"); const powerButton = this.shadowRoot.querySelector("#power-btn");
if (powerButton.classList.contains("clickable")) { if (powerButton !== null) {
powerButton.onclick = this.handlePowerButton.bind(this); if (powerButton.classList.contains("clickable")) {
} powerButton.onclick = this.handlePowerButton.bind(this);
powerButton.onkeydown = (event) => {
const configButton = this.shadowRoot.querySelector("#configure-btn"); if (event.key === "Enter") {
if (configButton.classList.contains("clickable")) { event.preventDefault();
configButton.onclick = this.handleConfigButton.bind(this); this.handlePowerButton();
} }
};
const consoleButton = this.shadowRoot.querySelector("#console-btn"); }
if (consoleButton.classList.contains("clickable")) {
consoleButton.classList.add("clickable");
consoleButton.onclick = this.handleConsoleButton.bind(this);
} }
const deleteButton = this.shadowRoot.querySelector("#delete-btn"); const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) { if (deleteButton !== null) {
deleteButton.onclick = this.handleDeleteButton.bind(this); if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
} }
} }
setStatusLoading() { setStatusLoading () {
this.status = "loading" this.status = "loading";
let statusicon = this.shadowRoot.querySelector("#status") const statusicon = this.shadowRoot.querySelector("#status");
let powerbtn = this.shadowRoot.querySelector("#power-btn") const powerbtn = this.shadowRoot.querySelector("#power-btn");
setSVGSrc(statusicon, "images/status/loading.svg") setIconSrc(statusicon, "images/status/loading.svg");
setSVGAlt(statusicon, "instance is loading") setIconAlt(statusicon, "instance is loading");
setSVGSrc(powerbtn, "images/status/loading.svg") setIconSrc(powerbtn, "images/status/loading.svg");
setSVGAlt(powerbtn, "") setIconAlt(powerbtn, "");
} }
async handlePowerButton () { async handlePowerButton () {
if (!this.actionLock) { if (!this.actionLock) {
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`; const template = this.shadowRoot.querySelector("#power-dialog");
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`; dialog(template, async (result, _form) => {
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
const targetAction = this.status === "running" ? "stop" : "start"; const targetAction = this.status === "running" ? "stop" : "start";
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid }); const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
this.setStatusLoading() this.setStatusLoading();
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay)); const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
@@ -186,26 +190,10 @@ class InstanceCard extends HTMLElement {
} }
} }
handleConfigButton () {
if (!this.actionLock && this.status === "stopped") { // if the action lock is false, and the node is stopped, then navigate to the config page with the node info in the search query
goToPage("config", { node: this.node.name, type: this.type, vmid: this.vmid });
}
}
handleConsoleButton () {
if (!this.actionLock && this.status === "running") {
const data = { console: `${this.type === "qemu" ? "kvm" : "lxc"}`, vmid: this.vmid, vmname: this.name, node: this.node.name, resize: "off", cmd: "" };
data[`${this.type === "qemu" ? "novnc" : "xtermjs"}`] = 1;
goToURL(window.PVE, data, true);
}
}
handleDeleteButton () { handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") { if (!this.actionLock && this.status === "stopped") {
const header = `Delete VM ${this.vmid}`; const template = this.shadowRoot.querySelector("#delete-dialog");
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`; dialog(template, async (result, _form) => {
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
@@ -235,7 +223,7 @@ async function getInstancesFragment () {
async function refreshInstances () { async function refreshInstances () {
let instances = await getInstancesFragment(); let instances = await getInstancesFragment();
if (instances.status !== 200) { if (instances.status !== 200) {
alert("Error fetching instances."); error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
} }
else { else {
instances = instances.data; instances = instances.data;
@@ -259,7 +247,7 @@ function sortInstances () {
const searchQuery = document.querySelector("#search").value || null; const searchQuery = document.querySelector("#search").value || null;
let criteria; let criteria;
if (!searchQuery) { if (!searchQuery) {
criteria = (item, query = null) => { criteria = (item, _query = null) => {
return { score: item.vmid, alignment: null }; return { score: item.vmid, alignment: null };
}; };
} }
@@ -269,9 +257,9 @@ function sortInstances () {
if (substrInc) { if (substrInc) {
const substrStartIndex = item.indexOf(query); const substrStartIndex = item.indexOf(query);
const queryLength = query.length; const queryLength = query.length;
const remaining = item.length - substrInc - queryLength; const remaining = item.length - substrInc - queryLength + 1;
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`; const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
return { score: 1, alignment }; return { score: -1, alignment };
} }
else { else {
const alignment = `${"X".repeat(item.length)}`; const alignment = `${"X".repeat(item.length)}`;
@@ -288,8 +276,8 @@ function sortInstances () {
}; };
criteria = (item, query) => { criteria = (item, query) => {
// lower is better // lower is better
const { score, CIGAR } = global.wfAlign(query, item, penalties, true); const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
const alignment = global.DecodeCIGAR(CIGAR); const alignment = global.wfa.DecodeCIGAR(CIGAR);
return { score: score / item.length, alignment }; return { score: score / item.length, alignment };
}; };
} }
@@ -324,47 +312,9 @@ function sortInstances () {
} }
} }
async function handleInstanceAdd () { async function handleInstanceAddButton () {
const header = "Create New Instance"; const template = document.querySelector("#create-instance-dialog");
const d = dialog(template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" required>
<label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
<label for="memory">Memory (MiB)</label>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
</form>
`;
const templates = await requestAPI("/user/ct-templates", "GET");
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
name: form.get("name"), name: form.get("name"),
@@ -393,8 +343,10 @@ async function handleInstanceAdd () {
} }
}); });
// setup type select
const typeSelect = d.querySelector("#type"); const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1; typeSelect.selectedIndex = -1;
// on type change, reveal or hide the container specific section
typeSelect.addEventListener("change", () => { typeSelect.addEventListener("change", () => {
if (typeSelect.value === "qemu") { if (typeSelect.value === "qemu") {
d.querySelectorAll(".container-specific").forEach((element) => { d.querySelectorAll(".container-specific").forEach((element) => {
@@ -409,75 +361,79 @@ async function handleInstanceAdd () {
}); });
} }
}); });
d.querySelectorAll(".container-specific").forEach((element) => {
const rootfsContent = "rootdir"; element.classList.add("none");
const rootfsStorage = d.querySelector("#rootfs-storage"); element.disabled = true;
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));
}
}); });
// 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; nodeSelect.selectedIndex = -1;
// on node change, get the available storages and repopulate the storage selector
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value; const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
storage.data.forEach((element) => { rootfsStorage.innerHTML = "";
storage.forEach((element) => {
if (element.content.includes(rootfsContent)) { if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage)); rootfsStorage.add(new Option(element.storage));
} }
}); });
rootfsStorage.selectedIndex = -1; rootfsStorage.selectedIndex = -1;
// set core and memory min/max depending on node selected
if (node in userResources.cores.nodes) {
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
}
else {
d.querySelector("#cores").max = userResources.cores.global.avail;
}
if (node in userResources.memory.nodes) {
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
}
else {
d.querySelector("#memory").max = userResources.memory.global.avail;
}
}); });
// set vmid min/max // setup root dir select
d.querySelector("#vmid").min = userCluster.vmid.min; const rootfsStorage = d.querySelector("#rootfs-storage");
d.querySelector("#vmid").max = userCluster.vmid.max; rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
// add user pools to selector const rootfsContent = "rootdir";
const poolSelect = d.querySelector("#pool");
const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// setup templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector // add template images to selector
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage const templates = await requestAPI("/user/ct-templates", "GET");
for (const template of templates) { for (const template of templates.data) {
templateImage.append(new Option(template.name, template.volid)); templateImage.append(new Option(template.name, template.volid));
} }
templateImage.selectedIndex = -1; templateImage.selectedIndex = -1;
// setup custom password checker for containers
const password = d.querySelector("#password"); const password = d.querySelector("#password");
const confirmPassword = d.querySelector("#confirm-password"); const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () { function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : ""); confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
} }
password.addEventListener("change", validatePassword); password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword); confirmPassword.addEventListener("keyup", validatePassword);
d.showModal();
} }
+1
View File
@@ -4,6 +4,7 @@ window.addEventListener("DOMContentLoaded", init);
function init () { function init () {
setAppearance(); setAppearance();
const { scheme, rate } = getSyncSettings(); const { scheme, rate } = getSyncSettings();
if (scheme) { if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true; document.querySelector(`#sync-${scheme}`).checked = true;
+22 -35
View File
@@ -80,28 +80,34 @@ async function request (url, content) {
try { try {
const response = await fetch(url, content); const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type"); const contentType = response.headers.get("Content-Type");
let data = null; const res = {};
if (contentType.includes("application/json")) {
data = await response.json(); if (contentType === null) {
data.status = response.status; res.data = null;
res.status = response.status;
}
else if (contentType.includes("application/json")) {
res.data = await response.json();
res.status = response.status;
} }
else if (contentType.includes("text/html")) { else if (contentType.includes("text/html")) {
data = { data: await response.text() }; res.data = await response.text();
data.status = response.status; res.status = response.status;
} }
else if (contentType.includes("text/plain")) { else if (contentType.includes("text/plain")) {
data = { data: await response.text() }; res.data = await response.text();
data.status = response.status; res.status = response.status;
} }
else { else {
data = response; res.data = null;
res.status = response.status;
} }
if (!response.ok) { if (!response.ok) {
return { status: response.status, error: data ? data.error : response.status }; return { status: response.status, error: res.data ? res.data.error : response.status };
} }
else { else {
data.status = response.status; return res;
return data || response;
} }
} }
catch (error) { catch (error) {
@@ -114,20 +120,6 @@ export function goToPage (page, data = null) {
window.location.href = `${page}${data ? "?" : ""}${params}`; window.location.href = `${page}${data ? "?" : ""}${params}`;
} }
export function goToURL (href, data = {}, newwindow = false) {
const url = new URL(href);
for (const k in data) {
url.searchParams.append(k, data[k]);
}
if (newwindow) {
window.open(url, document.title, "height=480,width=848");
}
else {
window.location.assign(url.toString());
}
}
export function getURIData () { export function getURIData () {
const url = new URL(window.location.href); const url = new URL(window.location.href);
return Object.fromEntries(url.searchParams); return Object.fromEntries(url.searchParams);
@@ -201,15 +193,10 @@ export function setAppearance () {
} }
// assumes href is path to svg, and id to grab is #symb // assumes href is path to svg, and id to grab is #symb
export function setSVGSrc (svgElem, href) { export function setIconSrc (icon, path) {
let useElem = svgElem.querySelector("use"); icon.setAttribute("src", path);
if (!useElem) {
useElem = document.createElementNS("http://www.w3.org/2000/svg", "use");
}
useElem.setAttribute("href", `${href}#symb`);
svgElem.append(useElem);
} }
export function setSVGAlt (svgElem, alt) { export function setIconAlt (icon, alt) {
svgElem.setAttribute("aria-label", alt); icon.setAttribute("alt", alt);
} }
+121
View File
@@ -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: 0px;
padding: 0px;
}
</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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
+17 -14
View File
@@ -1,10 +1,10 @@
{{/* <head> common across all pages*/}}
{{define "head"}} {{define "head"}}
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.global.Organization}} - dashboard</title> <title>{{.global.Organization}} - dashboard</title>
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<script> <script>
window.PVE = "{{.global.PVE}}"; window.PVE = "{{.global.PVE}}";
window.API = "{{.global.API}}"; window.API = "{{.global.API}}";
@@ -15,18 +15,21 @@
<link rel="stylesheet" href="css/form.css"> <link rel="stylesheet" href="css/form.css">
{{end}} {{end}}
{{/* <header> common across all pages*/}}
{{define "header"}} {{define "header"}}
<h1>{{.global.Organization}}</h1> <header>
<label for="navtoggle">&#9776;</label> <h1>{{.global.Organization}}</h1>
<input type="checkbox" id="navtoggle"> <label for="navtoggle">&#9776;</label>
<nav id="navigation"> <input type="checkbox" id="navtoggle">
{{if eq .page "login"}} <nav id="navigation">
<a href="login" aria-current="page">Login</a> {{if eq .page "login"}}
{{else}} <a href="login" aria-current="page">Login</a>
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a> {{else}}
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a> <a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a> <a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
<a href="login">Logout</a> <a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
{{end}} <a href="login">Logout</a>
</nav> {{end}}
</nav>
</header>
{{end}} {{end}}
+328 -78
View File
@@ -1,50 +1,103 @@
{{define "proctype-input"}} {{define "proctype-input"}}
<svg aria-label="CPU Type"><use href="images/resources/cpu.svg#symb"></svg> <img alt="CPU Type" src="images/resources/cpu.svg#symb">
<label for="proctype">CPU Type</label> <label for="proctype">CPU Type</label>
{{template "select" .}} {{template "select" .}}
<div></div> <div></div>
{{end}} {{end}}
{{define "cores-input"}} {{define "cores-input"}}
<svg aria-label="CPU Amount"><use href="images/resources/cpu.svg#symb"></svg> <img alt="CPU Amount" src="images/resources/cpu.svg#symb">
<label for="cores">CPU Amount</label> <label for="cores">CPU Amount</label>
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}"> <input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
<p>Cores</p> <p>Cores</p>
{{end}} {{end}}
{{define "memory-input"}} {{define "memory-input"}}
<svg aria-label="Memory Amount"><use href="images/resources/ram.svg#symb"></svg> <img alt="Memory Amount" src="images/resources/ram.svg#symb">
<label for="ram">Memory</label> <label for="ram">Memory</label>
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}"> <input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p> <p>MiB</p>
{{end}} {{end}}
{{define "swap-input"}} {{define "swap-input"}}
<svg aria-label="Swap Amount"><use href="images/resources/swap.svg#symb"></svg> <img alt="Swap Amount" src="images/resources/swap.svg#symb">
<label for="swap">Swap</label> <label for="swap">Swap</label>
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}"> <input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p> <p>MiB</p>
{{end}} {{end}}
{{define "volumes"}} {{define "volumes"}}
{{range $k,$v := .}} {{range $k,$v := .Volumes}}
{{if eq $v.Type "rootfs"}} {{if eq $v.Type "rootfs"}}
{{ template "volume-rootfs" Map "Name" $k "Volume" $v}} {{ template "volume-rootfs" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "mp"}} {{else if eq $v.Type "mp"}}
{{ template "volume-mp" Map "Name" $k "Volume" $v}} {{ template "volume-mp" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "ide"}} {{else if eq $v.Type "ide"}}
{{ template "volume-ide" Map "Name" $k "Volume" $v}} {{ template "volume-ide" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}} {{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}}
{{ template "volume-scsi" Map "Name" $k "Volume" $v}} {{ template "volume-scsi" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "unused"}} {{else if eq $v.Type "unused"}}
{{ template "volume-unused" Map "Name" $k "Volume" $v}} {{ template "volume-unused" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else}} {{else}}
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}
{{define "volumes-add-disk"}}
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
<span class="large" style="margin: 0;">Add Disk</span>
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New Disk" src="images/actions/disk/add-disk.svg#symb">
</button>
<template id="add-disk-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "volume-rootfs"}} {{define "volume-rootfs"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p> <p>{{.Name}}</p>
<p>{{.Volume.File}}</p> <p>{{.Volume.File}}</p>
<div> <div>
@@ -56,7 +109,7 @@
{{end}} {{end}}
{{define "volume-mp"}} {{define "volume-mp"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p> <p>{{.Name}}</p>
<p>{{.Volume.File}}</p> <p>{{.Volume.File}}</p>
<div> <div>
@@ -68,7 +121,7 @@
{{end}} {{end}}
{{define "volume-ide"}} {{define "volume-ide"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p> <p>{{.Name}}</p>
<p>{{.Volume.File}}</p> <p>{{.Volume.File}}</p>
<div> <div>
@@ -80,7 +133,7 @@
{{end}} {{end}}
{{define "volume-scsi"}} {{define "volume-scsi"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p> <p>{{.Name}}</p>
<p>{{.Volume.File}}</p> <p>{{.Volume.File}}</p>
<div> <div>
@@ -92,7 +145,7 @@
{{end}} {{end}}
{{define "volume-unused"}} {{define "volume-unused"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p> <p>{{.Name}}</p>
<p>{{.Volume.File}}</p> <p>{{.Volume.File}}</p>
<div> <div>
@@ -107,7 +160,24 @@
<volume-action data-type="move" data-volume="{{.Name}}"> <volume-action data-type="move" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg> <img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -116,7 +186,7 @@
<volume-action data-type="none" data-volume="{{.Name}}"> <volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/move-inactive.svg#symb"></svg> <img alt="" src="images/actions/disk/move-inactive.svg#symb">
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -125,7 +195,24 @@
<volume-action data-type="resize" data-volume="{{.Name}}"> <volume-action data-type="resize" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg> <img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -134,7 +221,7 @@
<volume-action data-type="none" data-volume="{{.Name}}"> <volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/resize-inactive.svg#symb"></svg> <img alt="" src="images/actions/disk/resize-inactive.svg#symb">
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -143,7 +230,23 @@
<volume-action data-type="delete" data-volume="{{.Name}}"> <volume-action data-type="delete" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg> <img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -152,7 +255,7 @@
<volume-action data-type="none" data-volume="{{.Name}}"> <volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/delete-inactive.svg#symb"></svg> <img alt="" src="images/actions/disk/delete-inactive.svg#symb">
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -161,7 +264,31 @@
<volume-action data-type="attach" data-volume="{{.Name}}"> <volume-action data-type="attach" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg> <img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -170,7 +297,23 @@
<volume-action data-type="detach" data-volume="{{.Name}}"> <volume-action data-type="detach" data-volume="{{.Name}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg> <img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -179,7 +322,7 @@
<volume-action data-type="none"> <volume-action data-type="none">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg aria-label=""></svg> <img alt="" src="images/common/blank.svg">
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -190,21 +333,80 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "nets-add-net"}}
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
<span class="large" style="margin: 0;">Add Network</span>
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New Network Interface" src="images/actions/network/add.svg#symb">
</button>
<template id="add-net-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "net"}} {{define "net"}}
<svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg> <img data-network="{{.Net_ID}}" alt="Net {{.Net_ID}}" src="images/resources/network.svg#symb">
<p>{{.Net_ID}}</p> <p>{{.Net_ID}}</p>
<p>{{.Value}}</p> <p>{{.Value}}</p>
<div> <div>
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}"> <network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg> <img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</network-action> </network-action>
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}"> <network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg> <img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</network-action> </network-action>
</div> </div>
@@ -216,86 +418,134 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "devices-add-device"}}
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
<span class="large" style="margin: 0;">Add Device</span>
<img class="small" style="height: 1lh; width: 1lh;" alt="Add New PCIe Device" src="images/actions/device/add.svg#symb">
</button>
<template id="add-device-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "device"}} {{define "device"}}
<svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg> <img data-device="{{.Device_ID}}" alt="Device {{.Device_ID}}" src="images/resources/device.svg#symb">
<p>{{.Device_ID}}</p> <p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p> <p>{{.Device_Name}}</p>
<div> <div>
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}"> <device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg> <img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</device-action> </device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}"> <device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg> <img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</device-action> </device-action>
</div> </div>
{{end}} {{end}}
{{define "boot"}} {{define "boot"}}
<draggable-container id="enabled" data-group="boot"> {{template "boot-container" Map "ID" "enabled" "Name" "Enabled" "Targets" .Enabled}}
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Enabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Enabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
<hr style="padding: 0; margin: 0;"> <hr style="padding: 0; margin: 0;">
<draggable-container id="disabled" data-group="boot"> {{template "boot-container" Map "ID" "disabled" "Name" "Disabled" "Targets" .Disabled}}
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Disabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Disabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
{{end}} {{end}}
{{define "boot-style"}} {{define "boot-container"}}
<style> <draggable-container id="{{.ID}}" data-group="boot">
div.draggable-item.ghost { <template shadowrootmode="open">
border: 1px dashed var(--main-text-color); <style>
border-radius: 5px; * {
margin: -1px; box-sizing: border-box;
} }
div.draggable-item { div.draggable-item.ghost {
cursor: grab; border: 1px dashed var(--main-text-color);
} border-radius: 5px;
div.draggable-item svg { margin: -1px;
height: 1em; }
width: 1em; div.draggable-item {
} cursor: grab;
* { }
-webkit-box-sizing: border-box; div.draggable-item svg {
-moz-box-sizing: border-box; height: 1em;
box-sizing: border-box; width: 1em;
} }
</style> #wrapper {
padding-bottom: 1em;
}
</style>
<label>{{.Name}}</label>
<div id="wrapper">
{{range .Targets}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
{{end}} {{end}}
{{define "boot-target"}} {{define "boot-target"}}
{{if .volume_id}} {{if .volume_id}}
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;"> <div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg> <img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb">
<svg aria-label="Volume"><use href="images/resources/drive.svg#symb"></use></svg> <img style="height: 1em; width: 1em" alt="Volume" src="images/resources/drive.svg#symb">
<p style="margin: 0px;">{{.volume_id}}</p> <p style="margin: 0px;">{{.volume_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p> <p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
</div> </div>
{{else if .net_id}} {{else if .net_id}}
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;"> <div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg> <img style="height: 1em; width: 1em" alt="Drag" src="images/actions/drag.svg#symb">
<svg aria-label="Net"><use href="images/resources/network.svg#symb"></use></svg> <img style="height: 1em; width: 1em" alt="Net" src="images/resources/network.svg#symb">
<p style="margin: 0px;">{{.net_id}}</p> <p style="margin: 0px;">{{.net_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p> <p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
</div> </div>
+149 -48
View File
@@ -2,61 +2,162 @@
<instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}"> <instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0;
width: fit-content;
}
a, svg, img {
line-height: 1em;
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
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: 10px;
align-items: center;
}
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-wrap: nowrap;
}
@media screen and (width >=993px) {
.hide-large {display: none !important;}
}
@media screen and (width <=993px) and (width >=601px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
}
@media screen and (width <=601px) and (width >=440px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
} }
</style> </style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;"> <p>{{.VMID}}</p>
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;"> <p id="instance-name">{{.Name}}</p>
<p class="w3-col l1 m2 s6">{{.VMID}}</p> <p class="hide-small">{{.Type}}</p>
<p class="w3-col l2 m3 s6" id="instance-name">{{.Name}}</p> <div class="flex row nowrap hide-tiny">
<p class="w3-col l1 m2 w3-hide-small">{{.Type}}</p> {{if eq .Status "running"}}
<div class="w3-col l2 m3 s6 flex row nowrap"> <img id="status" alt="instance is running" src="images/status/active.svg#symb">
{{if eq .Status "running"}} {{else if eq .Status "stopped"}}
<svg id="status" aria-label="instance is running"><use href="images/status/active.svg#symb"></svg> <img id="status" alt="instance is stopped" src="images/status/inactive.svg#symb">
{{else if eq .Status "stopped"}} {{else if eq .Status "loading"}}
<svg id="status" aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg> <img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
{{else if eq .Status "loading"}} {{else}}
<svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg> <img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
{{else}} {{end}}
{{end}} <p>{{.Status}}</p>
<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>
</div> </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" style="height: 1lh;">
{{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" style="text-align: center;">
{{if eq .Status "running"}}
Stop {{.VMID}}
{{else if eq .Status "stopped"}}
Start {{.VMID}}
{{else}}
{{end}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
{{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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<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" style="text-align: center;">
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" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</instance-card> </instance-card>
{{end}} {{end}}
+34
View File
@@ -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}}
+15 -13
View File
@@ -2,26 +2,19 @@
<resource-chart> <resource-chart>
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<style> <style>
* {
box-sizing: border-box;
font-family: monospace;
}
#container{ #container{
margin: 0; margin: 0;
width: 100%; width: 100%;
height: fit-content; height: fit-content;
padding: 10px 10px 10px 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
} }
progress { progress {
width: 100%; width: 100%;
border: 0; border: 0;
height: 1em; height: 1em;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none; appearance: none;
} }
#caption { #caption {
@@ -30,14 +23,23 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
progress::-moz-progress-bar {
background: #{{.ColorHex}};
}
progress::-webkit-progress-bar {
background: var(--main-text-color);
}
progress::-webkit-progress-value {
background: #{{.ColorHex}};
}
</style> </style>
<div id="container"> <div id="container">
<progress value="{{.Used}}" max="{{.Max}}"></progress> <progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
<p id="caption"> <label id="caption" for="resource">
<span>{{.Name}}</span> <span>{{.Name}}</span>
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span> <span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</p> </label>
</div> </div>
</template> </template>
</resource-chart> </resource-chart>
{{end}}- {{end}}
+19 -3
View File
@@ -1,11 +1,27 @@
{{/*
Select: generic data driven <select> element template
.ID = (string) select element id & name attribute
.Required = (bool) select element required attribute
.Options = ([]Options) array of Options
*/}}
{{define "select"}} {{define "select"}}
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}> <select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
{{range .Options}} {{range .Options}}
{{template "option" .}}
{{end}}
</select>
{{end}}
{{/*
Options: generic data driven <option> element template
.Selected = (bool) option element selected attribute
.Value = (string) option element value attribute
.Display = (string) option element innerText
*/}}
{{define "option"}}
{{if .Selected}} {{if .Selected}}
<option value="{{.Value}}" selected>{{.Display}}</option> <option value="{{.Value}}" selected>{{.Display}}</option>
{{else}} {{else}}
<option value="{{.Value}}">{{.Display}}</option> <option value="{{.Value}}">{{.Display}}</option>
{{end}} {{end}}
{{end}} {{end}}
</select>
{{end}}