60 Commits

Author SHA1 Message Date
alu 2ef8837a03 cleanup log messaging 2026-06-10 20:17:27 +00:00
alu b58e28ebb5 remove unused Icon struct type 2026-06-10 20:04:22 +00:00
alu 8f72aaf255 reduce inline css usage,
fix additional font inconsistencies,
fix px unit used with 0 values
2026-06-09 23:33:27 +00:00
alu 10ef24e76b move some util definitions and code to common lib 2026-06-08 20:28:39 +00:00
alu 00fa5f3152 fix incorrect display in backups 2026-06-08 18:34:18 +00:00
alu 94233000df fix search button 2026-06-06 23:11:51 +00:00
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
54 changed files with 1243 additions and 925 deletions
+3 -3
View File
@@ -1,3 +1,3 @@
[submodule "ProxmoxAAS-Fabric"]
path = ProxmoxAAS-Fabric
url = https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric
[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 ======================="
# resolve symbolic links in web by copying it into dist/web/
cp -rL web/ dist/web/
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
test: clean
go run .
+9 -6
View File
@@ -4,32 +4,33 @@ import (
"flag"
"fmt"
"log"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2"
)
func Run() {
gin.SetMode(gin.ReleaseMode)
configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
common.Global = common.GetConfig(*configPath)
// setup static resources
gin.SetMode(gin.ReleaseMode)
router := gin.Default()
m := common.InitMinify()
ServeStatic(router, m)
html := common.MinifyStatic(m, web.Templates)
common.TMPL = common.LoadHTMLToGin(router, html)
router.GET("/account", routes.HandleGETAccount)
// dynamic routes for pages and page fragments
router.GET("/", routes.HandleGETIndex)
router.GET("/index", routes.HandleGETIndex)
router.GET("/index/instances", routes.HandleGETInstancesFragment)
router.GET("/account", routes.HandleGETAccount)
router.GET("/config", routes.HandleGETConfig)
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
@@ -40,9 +41,11 @@ func Run() {
router.GET("/login", routes.HandleGETLogin)
router.GET("/settings", routes.HandleGETSettings)
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
// run on all interfaces with port
log.Fatal("[ERR ] starting gin router: ", router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
}
// setup static resources under web (css, images, modules, scripts)
func ServeStatic(router *gin.Engine, m *minify.M) {
css := common.MinifyStatic(m, web.CSS_fs)
router.GET("/css/*css", func(c *gin.Context) {
+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
import (
@@ -38,6 +41,10 @@ var MimeTypes = map[string]MimeType{
Type: "image/svg+xml",
Minifier: svg.Minify,
},
"png": {
Type: "image/png",
Minifier: nil,
},
"js": {
Type: "application/javascript",
Minifier: js.Minify,
+9 -19
View File
@@ -1,5 +1,9 @@
package common
import "html/template"
var Global Config
type Config struct {
Port int `json:"listenPort"`
Organization string `json:"organization"`
@@ -8,6 +12,11 @@ type Config struct {
API string `json:"apiurl"`
}
// variable for html template root
// generated from LoadHTMLToGin
var TMPL *template.Template
// static served file type containing data and mimetype
type StaticFile struct {
Data string
MimeType MimeType
@@ -26,22 +35,3 @@ type Option struct {
Value string
Display string
}
type RequestType int
type RequestContext struct {
Cookies map[string]string
}
type Auth struct {
Username string
Token string
CSRF string
}
type Icon struct {
ID string
Src string
Alt string
Clickable bool
}
+74 -39
View File
@@ -14,34 +14,38 @@ import (
"net/http"
"os"
"reflect"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2"
paas "proxmoxaas-common-lib"
)
var TMPL *template.Template
var Global Config
type VMPath struct {
Node string
Type string
VMID string
}
// get config file from configPath
func GetConfig(configPath string) Config {
content, err := os.ReadFile(configPath)
root, err := os.OpenRoot(".")
if err != nil {
log.Fatal("Error when opening config file: ", err)
log.Fatal("[ERR ] error opening root dir: ", err)
}
defer root.Close()
content, err := root.ReadFile(configPath)
if err != nil {
log.Fatal("[ERR ] error opening config file: ", err)
}
var config Config
err = json.Unmarshal(content, &config)
if err != nil {
log.Fatal("Error during parsing config file: ", err)
log.Fatal("[ERR ] error parsing config file: ", err)
}
return config
}
// initialize minifier using the meta types specified
func InitMinify() *minify.M {
m := minify.New()
for _, v := range MimeTypes {
@@ -54,14 +58,14 @@ func InitMinify() *minify.M {
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
minified := make(map[string]StaticFile)
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() {
v, err := files.ReadFile(path)
if err != nil {
log.Fatalf("error parsing template file %s: %s", path, err.Error())
log.Fatalf("[ERR ] error parsing template file %s: %s", path, err.Error())
}
x := strings.Split(entry.Name(), ".")
if len(x) >= 2 { // file has extension
@@ -69,7 +73,7 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier
min, err := m.String(mimetype.Type, string(v)) // try to minify
if err != nil {
log.Fatalf("error minifying file %s: %s", path, err.Error())
log.Fatalf("[ERR ] error minifying file %s: %s", path, err.Error())
}
minified[path] = StaticFile{
Data: min,
@@ -91,7 +95,13 @@ func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
}
return nil
})
return minified
if err != nil {
log.Printf("[ERR ] error in MinifyStatic: %s", err)
return nil
} else {
return minified
}
}
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
@@ -125,7 +135,7 @@ func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Tem
},
}
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
engine.SetHTMLTemplate(tmpl)
engine.SetHTMLTemplate(root)
return tmpl
}
@@ -162,17 +172,20 @@ func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string
}
func HandleNonFatalError(c *gin.Context, err error) {
log.Printf("[Error] encountered an error: %s", err.Error())
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
c.Status(http.StatusInternalServerError)
}
func RequestGetAPI(path string, context RequestContext, body any) (*http.Response, int, error) {
func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil {
return nil, 0, err
}
for k, v := range context.Cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
if auth != nil {
for k, v := range GetRequestContextFromCookies(*auth) {
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
}
}
client := &http.Client{}
@@ -192,7 +205,6 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
if err != nil {
return nil, response.StatusCode, err
}
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
case *map[string]any:
err = json.Unmarshal(data, &body)
@@ -210,34 +222,41 @@ func RequestGetAPI(path string, context RequestContext, body any) (*http.Respons
return response, response.StatusCode, nil
}
func GetAuth(c *gin.Context) (Auth, error) {
func GetAuthFromRequest(c *gin.Context) (paas.Auth, error) {
_, errAuth := c.Cookie("auth")
username, errUsername := c.Cookie("username")
token, errToken := c.Cookie("PVEAuthCookie")
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
access, errAccess := c.Cookie("PAASAccessManagerTicket")
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil || errAccess != nil {
return paas.Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
} else {
return Auth{username, token, csrf}, nil
return paas.Auth{Username: username, Token: token, CSRF: csrf, AccessManagerTicket: access}, nil
}
}
func ExtractVMPath(c *gin.Context) (VMPath, error) {
func GetInstancePathFromRequest(c *gin.Context) (paas.InstancePath, error) {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
return paas.InstancePath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
if err != nil {
return paas.InstancePath{}, err
}
vm_path := paas.InstancePath{
NodeName: req_node,
InstanceType: paas.InstanceType(req_type),
InstanceID: paas.InstanceID(vmid_int),
}
return vm_path, nil
}
func FormatNumber(val int64, base int64) (float64, string) {
func FormatNumber(val int64, base int64) (string, string) {
valf := float64(val)
basef := float64(base)
steps := 0
@@ -246,13 +265,29 @@ func FormatNumber(val int64, base int64) (float64, string) {
steps++
}
if base == 1000 {
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 valf, prefixes[steps]
} else if base == 1024 {
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 valf, prefixes[steps]
} else {
return 0, ""
return s, prefixes[steps]
default:
return "0", ""
}
}
func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
return map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
"PAASAccessManagerTicket": auth.AccessManagerTicket,
}
}
+140 -125
View File
@@ -3,6 +3,7 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color"
@@ -11,22 +12,18 @@ import (
)
type Account struct {
Username string
Pools map[string]bool
Nodes map[string]bool
VMID struct {
Min int
Max int
}
Resources map[string]map[string]any
paas.User
Pools map[string]paas.Pool
}
// numerical constraint
type Constraint struct {
Max int64
Used int64
Avail int64
}
// match constraint
type Match struct {
Name string
Match string
@@ -80,7 +77,7 @@ type ResourceChart struct {
Name string
Used int64
Max int64
Avail float64
Avail string
Prefix string
Unit string
ColorHex string
@@ -99,71 +96,86 @@ var Green = color.RGB{
}
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
account, err := GetUserAccount(auth)
account, err := GetUser(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
for category, resources := range account.Resources {
for resource, v := range resources {
switch t := v.(type) {
case NumericResource:
avail, prefix := common.FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[category][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)
account.Resources[category][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{},
}
pools, err := GetUserPools(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
for _, r := range t.Total {
l.Resources = append(l.Resources, ResourceChart{
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: r.Name,
Used: r.Used,
Max: r.Max,
Avail: float64(r.Avail), // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
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.Resources[category][resource] = l
}
}
}
account.Pools = pools
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
@@ -174,97 +186,100 @@ func HandleGETAccount(c *gin.Context) {
}
}
func GetUserAccount(auth common.Auth) (Account, error) {
account := Account{
Resources: map[string]map[string]any{},
}
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
// get user account basic data
func GetUser(auth paas.Auth) (Account, error) {
account := Account{}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body)
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
if err != nil {
return account, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
return account, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body, &account)
if err != nil {
return account, err
} else {
account.Username = auth.Username
}
return account, err
}
body = map[string]any{}
// get user resources
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
func GetUserPools(auth paas.Auth) (map[string]paas.Pool, error) {
pools := map[string]paas.Pool{}
// get all pools
body := map[string]any{}
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
if err != nil {
return account, err
return pools, err
}
if code != 200 {
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
}
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
if err != nil {
return pools, err
}
resources := body
// get global config for resource type metadata
body = map[string]any{}
// get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
res, code, err = common.RequestGetAPI("/global/config/resources", &auth, &body)
if err != nil {
return account, err
return pools, err
}
if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
}
meta := body["resources"].(map[string]any)
// build each resource by its meta type
for k, v := range meta {
m := v.(map[string]any)
t := m["type"].(string)
r := resources[k].(map[string]any)
category := m["category"].(string)
if _, ok := account.Resources[category]; !ok {
account.Resources[category] = map[string]any{}
}
if t == "numeric" {
n := NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
// for each pool
for poolname, pool := range pools {
// for each resource in pool data
for k, v := range pool.Resources {
m := meta[k].(map[string]any)
t := m["type"].(string)
r := v.(map[string]any)
category := m["category"].(string)
// create a category if it does not already exist
if _, ok := pool.Resources[category]; !ok {
pool.Resources[category] = map[string]any{}
}
account.Resources[category][k] = n
} else if t == "storage" {
n := StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
// depending on type, decode the pool data into the corresponding resource type
switch t {
case "numeric":
n := NumericResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "storage":
n := StorageResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
case "list":
n := ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
pools[poolname].Resources[category].(map[string]any)[k] = n
}
account.Resources[category][k] = n
} else if t == "list" {
n := ListResource{}
n.Type = t
err_m := mapstructure.Decode(m, &n)
err_r := mapstructure.Decode(r, &n)
if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
}
account.Resources[category][k] = n
// delete the old entry, only categories should be left at the end of the loop
delete(pools[poolname].Resources, k)
}
}
return account, nil
return pools, nil
}
// interpolate between min and max by normalized (0 - 1) val
+17 -20
View File
@@ -2,8 +2,8 @@ package routes
import (
"fmt"
"log"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"time"
@@ -21,11 +21,12 @@ type InstanceBackup struct {
}
func HandleGETBackups(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
backups, err := GetInstanceBackups(vm_path, auth)
@@ -38,8 +39,6 @@ func HandleGETBackups(c *gin.Context) {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
log.Printf("%+v", backups)
c.HTML(http.StatusOK, "html/backups.html", gin.H{
"global": common.Global,
"page": "backups",
@@ -52,11 +51,12 @@ func HandleGETBackups(c *gin.Context) {
}
func HandleGETBackupsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
backups, err := GetInstanceBackups(vm_path, auth)
@@ -65,27 +65,24 @@ func HandleGETBackupsFragment(c *gin.Context) {
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
"backups": backups,
})
c.Status(http.StatusOK)
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) {
func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return backups, err
}
@@ -100,7 +97,7 @@ func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, e
for i := range backups {
size, prefix := common.FormatNumber(backups[i].Size, 1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix)
backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
+67 -74
View File
@@ -3,12 +3,11 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"slices"
"sort"
fabric "proxmoxaas-fabric/app"
"github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2"
)
@@ -16,16 +15,7 @@ import (
// imported types from fabric
type InstanceConfig struct {
Type fabric.InstanceType `json:"type"`
Name string `json:"name"`
CPU 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"`
paas.Instance `mapstructure:",squash"`
// overrides
ProctypeSelect common.Select
}
@@ -36,23 +26,20 @@ type GlobalConfig struct {
}
}
type UserConfigResources struct {
type PoolConfig struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
Global []paas.MatchLimit
Nodes map[string][]paas.MatchLimit
}
}
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
@@ -61,13 +48,13 @@ func HandleGETConfig(c *gin.Context) {
}
if config.Type == "VM" { // if VM, fetch CPU types from node
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
}
}
for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.CPU {
if cpu.Value == config.Proctype {
config.ProctypeSelect.Options[i].Selected = true
}
}
@@ -83,11 +70,12 @@ func HandleGETConfig(c *gin.Context) {
}
func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
@@ -96,21 +84,26 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
"config": config,
})
c.Status(http.StatusOK)
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
@@ -119,21 +112,26 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
"config": config,
})
c.Status(http.StatusOK)
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
@@ -142,21 +140,26 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
"config": config,
})
c.Status(http.StatusOK)
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
vm_path, err := common.GetInstancePathFromRequest(c)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
config, err := GetInstanceConfig(vm_path, auth)
@@ -165,27 +168,24 @@ func HandleGETConfigBootFragment(c *gin.Context) {
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
"config": config,
})
c.Status(http.StatusOK)
if err != nil {
c.Status(http.StatusInternalServerError)
} else {
c.Status(http.StatusOK)
}
} else {
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return config, err
}
@@ -204,60 +204,53 @@ func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, erro
return config, nil
}
func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
cputypes := common.Select{
ID: "proctype",
Required: true,
}
// get global resource config
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{}
path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx, &body)
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
global := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &global)
globalConfig := GlobalConfig{}
err = mapstructure.Decode(body["resources"], &globalConfig)
if err != nil {
return cputypes, err
}
// get user resource config
// get pool resource config
body = map[string]any{}
path = "/user/config/resources"
res, code, err = common.RequestGetAPI(path, ctx, &body)
path = fmt.Sprintf("/access/pools/%s", pool)
res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
user := UserConfigResources{}
err = mapstructure.Decode(body, &user)
poolCPUConfig := PoolConfig{}
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
if err != nil {
return cputypes, err
}
// use node specific rules if present, otherwise use global rules
var userCPU []CPUConfig
if _, ok := user.CPU.Nodes[vm.Node]; ok {
userCPU = user.CPU.Nodes[vm.Node]
var userCPU []paas.MatchLimit
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
} else {
userCPU = user.CPU.Global
userCPU = poolCPUConfig.CPU.Global
}
if global.CPU.Whitelist { // cpu is a whitelist
if globalConfig.CPU.Whitelist { // cpu is a whitelist
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
cputypes.Options = append(cputypes.Options, common.Option{
Display: cpu.Name,
@@ -267,8 +260,8 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
} else { // cpu is a blacklist
// get the supported cpu types from the node
body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx, &body)
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
res, code, err = common.RequestGetAPI(path, &auth, &body)
if err != nil {
return cputypes, err
}
@@ -276,7 +269,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
}
supported := struct {
data []CPUConfig
data []paas.MatchLimit
}{}
err = mapstructure.Decode(body, supported)
if err != nil {
@@ -285,7 +278,7 @@ func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
for _, cpu := range supported.data {
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
contains := slices.ContainsFunc(userCPU, func(c paas.MatchLimit) bool {
return c.Name == cpu.Name
})
if !contains {
+63 -44
View File
@@ -3,6 +3,7 @@ package routes
import (
"fmt"
"net/http"
paas "proxmoxaas-common-lib"
"proxmoxaas-dashboard/app/common"
"strconv"
@@ -10,13 +11,7 @@ import (
"github.com/go-viper/mapstructure/v2"
)
// used in constructing instance cards in index
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
}
// used in constructing instance cards in index
// primary type used in constructing instance cards in index
type InstanceCard struct {
VMID uint
Name string
@@ -29,6 +24,12 @@ type InstanceCard struct {
BackupsPath string
}
// used in constructing instance cards in index
type Node struct {
Node string `json:"node"`
Status string `json:"status"`
}
// used in retriving cluster tasks
type Task struct {
Type string
@@ -45,11 +46,12 @@ type InstanceStatus struct {
}
func HandleGETIndex(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
page := gin.H{
"global": common.Global,
@@ -63,53 +65,53 @@ func HandleGETIndex(c *gin.Context) {
}
func HandleGETInstancesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
auth, err := common.GetAuthFromRequest(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
err = common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
"instances": instances,
})
c.Status(http.StatusOK)
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.RequestContext{
Cookies: map[string]string{
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow
if code != 200 { // if we did not successfully retrieve resources, then return the error because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
}
instances := map[uint]InstanceCard{}
nodes := map[string]Node{}
// if we successfully retrieved the resources, then process it and return index
for _, v := range body["data"].([]any) {
// parse /proxmox/cluster/resources to separate instances and nodes
for _, v := range body {
m := v.(map[string]any)
if m["type"] == "node" {
switch m["type"] {
case "node": // if type is node -> parse as Node object
node := Node{}
err := mapstructure.Decode(v, &node)
if err != nil {
return nil, nil, err
}
nodes[node.Node] = node
} else if m["type"] == "lxc" || m["type"] == "qemu" {
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
instance := InstanceCard{}
err := mapstructure.Decode(v, &instance)
if err != nil {
@@ -118,21 +120,27 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
instances[instance.VMID] = instance
}
}
// once all basic instance and node stuff is parsed, go back and fill in cross referenced data
for vmid, instance := range instances {
nodestatus := nodes[instance.Node].Status
instance.NodeStatus = nodestatus
// set instance's node status
instance.NodeStatus = nodes[instance.Node].Status
// set instance's config link path
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
if instance.Type == "qemu" {
// 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)
} else if instance.Type == "lxc" {
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
}
body = map[string]any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
body = []any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
if err != nil {
return nil, nil, err
}
@@ -141,19 +149,24 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
}
most_recent_task := map[uint]uint{}
expected_state := map[uint]string{}
expected_states := map[uint]string{}
for _, v := range body["data"].([]any) {
// iterate through recent user accessible tasks to find the task most recently made on an instance
for _, v := range body {
// parse task as Task object
task := Task{}
err := mapstructure.Decode(v, &task)
if err != nil {
return nil, nil, err
}
// try to get task vmid but continue if it would be an invalid vmid
x, err := strconv.Atoi(task.ID)
task.VMID = uint(x)
// if there was an error converting the task's vmid, skip it
if err != nil {
return nil, nil, err
continue
}
task.VMID = uint(x)
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
continue
@@ -167,23 +180,25 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
continue
} else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task
if task.Type == "qmstart" || task.Type == "vzstart" { // if the task was a start task, update the expected state to running
expected_state[task.VMID] = "running"
} else if task.Type == "qmstop" || task.Type == "vzstop" { // if the task was a stop task, update the expected state to stopped
expected_state[task.VMID] = "stopped"
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"
}
}
}
}
for vmid, expected_state := range expected_state { // for the expected states from recent tasks
// iterate through the instances with recent tasks, refetch their state from a more reliable source
for vmid, expected_state := range expected_states { // for the expected states from recent tasks
if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state
// get /status/current which is updated faster than /cluster/resources
instance := instances[vmid]
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
body = map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
body := map[string]any{}
res, code, err := common.RequestGetAPI(path, &auth, &body)
if err != nil {
return nil, nil, err
}
@@ -191,8 +206,12 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
}
// attempt to decode task status as instance status
status := InstanceStatus{}
mapstructure.Decode(body["data"], &status)
err = mapstructure.Decode(body, &status)
if err != nil { // did not successfully decode task status, just skip
continue
}
instance.Status = status.Status
instances[vmid] = instance
+3 -7
View File
@@ -24,12 +24,8 @@ type Realm struct {
func GetLoginRealms() ([]Realm, error) {
realms := []Realm{}
ctx := common.RequestContext{
Cookies: nil,
//Body: map[string]any{},
}
body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
body := []any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
if err != nil {
return realms, err
}
@@ -37,7 +33,7 @@ func GetLoginRealms() ([]Realm, error) {
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
}
for _, v := range body["data"].([]any) {
for _, v := range body {
v = v.(map[string]any)
realm := Realm{}
err := mapstructure.Decode(v, &realm)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func HandleGETSettings(c *gin.Context) {
_, err := common.GetAuth(c)
_, err := common.GetAuthFromRequest(c)
if err == nil {
c.HTML(http.StatusOK, "html/settings.html", gin.H{
"global": common.Global,
-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"]
},
}]);
+28 -41
View File
@@ -1,59 +1,46 @@
module proxmoxaas-dashboard
go 1.25.1
go 1.26.4
require (
github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
github.com/gin-gonic/gin v1.11.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/tdewolff/minify v2.3.6+incompatible
github.com/tdewolff/minify/v2 v2.24.3
proxmoxaas-fabric v0.0.0
github.com/gin-gonic/gin v1.12.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/tdewolff/minify/v2 v2.24.13
proxmoxaas-common-lib v0.0.0
)
replace proxmoxaas-fabric => ./ProxmoxAAS-Fabric
replace proxmoxaas-common-lib => ./proxmoxaas-common-lib
require (
github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/luthermonson/go-proxmox v0.2.3 // indirect
github.com/magefile/mage v1.15.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/tdewolff/parse v2.3.4+incompatible // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/tdewolff/parse/v2 v2.8.13 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.21.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.37.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+7 -8
View File
@@ -4,17 +4,16 @@
"description": "Front-end for ProxmoxAAS",
"type": "module",
"scripts": {
"lint": "html-validate --config configs/.htmlvalidate.json web/html/*; stylelint --config configs/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config configs/.eslintrc.json --fix web/scripts/",
"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"
},
"devDependencies": {
"eslint": "^8.43.0",
"eslint-config-standard": "^17.1.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-n": "^16.0.1",
"eslint-plugin-promise": "^6.1.1",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"html-validate": "^9.4.0",
"stylelint": "^15.9.0",
"stylelint-config-standard": "^33.0.0",
"html-validate": "^9.4.0"
"stylelint-config-standard": "^33.0.0"
}
}
+32 -12
View File
@@ -1,6 +1,11 @@
input, select, textarea {
background-color: var(--main-input-bg-color);
color: var(--main-text-color);
}
.input-grid {
display: grid;
gap: 5px 10px;
gap: 0.5em 1em;
align-items: center;
width: 100%;
}
@@ -8,15 +13,15 @@
.input-grid * {
margin-top: 0;
margin-bottom: 0;
padding-top: 8px;
padding-bottom: 8px;
padding-top: 0.5em;
padding-bottom: 0.5em;
}
.input-grid input {
padding: 8px;
padding: 0.5em;
}
.input-grid svg {
.input-grid img {
padding: 0;
}
@@ -29,17 +34,17 @@ legend {
width: 100%;
margin: 0;
padding: 0;
line-height: 1.5em;
margin-top: 0.25lh;
margin-bottom: 0.25lh;
margin-top: 0.25em;
margin-bottom: 0.25em;
}
fieldset {
border: 0;
padding: 0;
}
fieldset > *:last-child {
margin-bottom: 8px;
margin-bottom: 0.5em;
}
fieldset > .input-grid {
@@ -61,7 +66,7 @@ input[type="radio"] {
}
.w3-select, select {
padding: 8px;
padding: 0.5em;
}
.w3-check {
@@ -72,10 +77,25 @@ input[type="radio"] {
:not(.input-grid) .input-grid + * {
display: inline-block;
width: 100%;
margin-top: 5px;
margin-top: 0.5em;
}
dialog {
max-width: calc(min(50%, 80ch));
margin: auto;
max-width: calc(min(100% - 16px, 80ch));
color: var(--main-text-color);
}
dialog #prompt {
margin-top: 1em;
margin-bottom: 1em;
text-align: center;
}
dialog button[value="confirm"] {
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
}
dialog button[value="cancel"] {
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
}
+6 -6
View File
@@ -53,7 +53,7 @@ header {
}
header h1 {
font-size: 18px;
font-size: var(--small-font-size);
margin: 0;
background-color: var(--nav-header-bg-color);
color: var(--nav-header-text-color);
@@ -61,15 +61,15 @@ header h1 {
}
nav {
font-size: var(--small-font-size);
overflow: hidden;
font-size: larger;
width: fit-content;
}
nav a, header h1, label[for="navtoggle"] {
text-align: left;
padding-left: 8px;
padding-right: 8px;
padding-left: 0.5em;
padding-right: 0.5em;
text-decoration: none;
vertical-align: middle;
height: 2em;
@@ -80,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
display: none;
}
@media screen and (width >= 600px){
@media screen and (width >= 601px){
header {
grid-template-columns: auto 1fr;
}
@@ -106,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
}
}
@media screen and (width <= 600px){
@media screen and (width <= 601px){
header {
grid-template-columns: 1fr auto;
}
+66 -36
View File
@@ -3,6 +3,9 @@
--positive-color: #0f0;
--highlight-color: yellow;
--lightbg-text-color: black;
--large-font-size: 32px;
--medium-font-size: 24px;
--small-font-size: 16px;
}
@media screen and (prefers-color-scheme: dark) {
@@ -11,7 +14,6 @@
--main-text-color: white;
--main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-table-header-bg-color: black;
--main-input-bg-color: #404040;
}
@@ -20,7 +22,6 @@
--main-text-color: black;
--main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-table-header-bg-color: #808080;
--main-input-bg-color: white;
}
}
@@ -31,7 +32,6 @@
--main-text-color: black;
--main-card-bg-color: white;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
--main-table-header-bg-color: #808080;
--main-input-bg-color: white;
}
@@ -40,18 +40,43 @@
--main-text-color: white;
--main-card-bg-color: #202020;
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
--main-table-header-bg-color: black;
--main-input-bg-color: #404040;
}
}
html {
*, h1, h2, h3, h4, h5, h6, p {
box-sizing: border-box;
background-color: var(--main-bg-color);
font-family: monospace;
line-height: normal;
margin: 0;
padding: 0;
}
* {
font-family: monospace;
h2 {
font-size: var(--large-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h3 {
font-size: var(--medium-font-size);
margin-top: 0.5em;
margin-bottom: 0.5em;
}
h4, legend {
font-size: var(--small-font-size);
text-decoration: underline;
margin-top: 0.5em;
margin-bottom: 0.5em;
}
p {
font-size: var(--small-font-size);
}
html {
background-color: var(--main-bg-color);
}
body {
@@ -77,33 +102,24 @@ main {
margin-top: 16px;
}
th {
background-color: var(--main-table-header-bg-color);
}
td {
background-color: var(--main-card-bg-color);
}
input, select, textarea {
background-color: var(--main-input-bg-color);
color: var(--main-text-color);
}
img.clickable, svg.clickable {
cursor: pointer;
}
img, svg {
height: 1em;
width: 1em;
color: var(--main-text-color)
}
hr, * {
a img {
vertical-align: unset;
}
hr {
border-color: var(--main-text-color);
}
.clickable {
cursor: pointer;
}
.flex {
display: flex;
}
@@ -114,6 +130,12 @@ hr, * {
align-items: center;
}
.column-reverse {
flex-direction: column-reverse;
row-gap: 10px;
align-items: center;
}
.wrap {
flex-wrap: wrap;
row-gap: 10px;
@@ -156,18 +178,26 @@ hr, * {
}
/* add hide large class similar to w3-hide-medium and w3-hide-small */
@media (width >=993px) {
.w3-hide-large {
display: none !important;
}
@media screen and (width >=993px) {
.hide-large {display: none !important;}
}
/* fix edge case in w3-hide-medium where width between 992 and 993 */
@media (width <=993px) and (width >=601px){
.w3-hide-medium{display:none!important}
/* fixes edge case in w3-hide-medium where width between 992 and 993 */
@media screen and (width <=993px) and (width >=601px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
}
/* fix edge case in w3-hide-small when width between 600 and 601 */
@media (width <=601px) {
.w3-hide-small{display:none!important}
/* fixes edge case in w3-hide-small when width between 600 and 601 */
@media screen and (width <=601px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
}
+10 -43
View File
@@ -34,58 +34,25 @@
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
{{template "header" .}}
<main>
<h2>Account</h2>
<section class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username: {{.account.Username}}</p>
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
</section>
<section class="w3-card w3-padding">
<div class="flex row nowrap">
<h3>Password</h3>
<button class="w3-button w3-margin" id="change-password" type="button">Change Password</button>
</div>
</section>
<section class="w3-card w3-padding">
<h3>Cluster Resources</h3>
<div>
{{range $category, $v := .account.Resources}}
{{if ne $category ""}}
<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>
<p id="username">Username: {{.account.Username.UserID}}@{{.account.Username.Realm}}</p>
<p id="email">Email: {{.account.Mail}}</p>
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
</section>
{{range $poolname, $pool := .account.Pools}}
{{template "pool-resources" $pool}}
{{end}}
</main>
<template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Change Password
</p>
<div id="body">
@@ -97,8 +64,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
+3 -5
View File
@@ -9,16 +9,14 @@
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
{{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 w3-hide-small">Notes</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Size</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">
+2 -5
View File
@@ -5,7 +5,6 @@
<script src="scripts/config.js" type="module"></script>
<script src="scripts/draggable.js" type="module"></script>
<script src="modules/Sortable.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<link rel="modulepreload" href="scripts/utils.js">
<link rel="modulepreload" href="scripts/dialog.js">
<style>
@@ -20,12 +19,10 @@
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
{{template "header" .}}
<main>
<section>
<h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
<form id="config-form">
<fieldset class="w3-card w3-padding">
<legend>Resources</legend>
+62 -25
View File
@@ -8,12 +8,6 @@
<link rel="modulepreload" href="scripts/dialog.js">
<link rel="modulepreload" href="scripts/clientsync.js">
<style>
#instance-container > div {
border-bottom: 1px solid white;
}
#instance-container > div:last-child {
border-bottom: none;
}
@media screen and (width >= 440px) {
#vm-search {
max-width: calc(100% - 10px - 152px);
@@ -24,29 +18,71 @@
max-width: calc(100% - 10px - 47px);
}
}
@media screen and (width >= 993px) {
#instance-table {
display: grid;
grid-template-columns: repeat(7, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 993px) and (width >= 601px){
#instance-table {
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 601px) and (width >= 440px){
#instance-table {
display: grid;
grid-template-columns: repeat(4, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 440px) {
#instance-table {
display: grid;
grid-template-columns: repeat(3, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
{{template "header" .}}
<main>
<section>
<h2>Instances</h2>
<div class="w3-card w3-padding">
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
<button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="images/common/search.svg#symb"></button>
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
</form>
<!--Add Instance Button & Dialog Template-->
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
<span class="large" style="margin: 0;">Create Instance</span>
<svg class="small" style="height: 1lh; width: 1lh;" role="img" aria-label="Create New Instance"><use href="images/actions/instance/add.svg#symb"></use></svg>
<img class="small" style="height: 1lh; width: 1lh;" alt="Create Instance" aria-label="Create Instance" src="images/actions/instance/add.svg#symb">
</button>
<template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Create New Instance
</p>
<div id="body">
@@ -56,14 +92,14 @@
<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="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>
@@ -84,22 +120,23 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
</div>
<div>
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;">
<p class="w3-col l1 m2 w3-hide-small">ID</p>
<p class="w3-col l2 m3 w3-hide-small">Name</p>
<p class="w3-col l1 m2 w3-hide-small">Type</p>
<p class="w3-col l2 m3 w3-hide-small">Status</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Name</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Status</p>
<p class="w3-col l2 m2 w3-hide-small">Actions</p>
<div id="instance-table">
<div id="instance-table-header">
<p>ID</p>
<p>Name</p>
<p class="hide-tiny">Type</p>
<p class="hide-small">Status</p>
<p class="hide-medium">Host Name</p>
<p class="hide-medium">Host Status</p>
<p>Actions</p>
</div>
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
<div id="instance-container">
{{range .instances}}
{{template "instance-card" .}}
+1 -3
View File
@@ -7,9 +7,7 @@
<link rel="modulepreload" href="scripts/dialog.js">
</head>
<body>
<header>
{{template "header" .}}
</header>
{{template "header" .}}
<main class="flex" style="justify-content: center; align-items: center;">
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
<h2 class="w3-center">{{.global.Organization}} Login</h2>
+7 -10
View File
@@ -5,30 +5,25 @@
<script src="scripts/settings.js" type="module"></script>
<link rel="modulepreload" href="scripts/utils.js">
<style>
legend {
margin-bottom: 25px;
}
label {
display: flex;
height: fit-content;
width: 100%;
align-items: center;
justify-content: left;
column-gap: 10px;
column-gap: 0.5em;
}
label + p {
margin-top: 5px;
margin-bottom: 25px;
margin-top: 0.5em;
margin-bottom: 1em;
}
p:last-child {
margin-bottom: 0px;
margin-bottom: 0;
}
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
{{template "header" .}}
<main>
<h2>Settings</h2>
<form id="settings">
@@ -42,6 +37,8 @@
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
</fieldset>
<fieldset>
<legend>App Sync Frequency</legend>
@@ -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

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

+2 -2
View File
File diff suppressed because one or more lines are too long
+18 -2
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}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
.w3-danger{color:#fff!important;background-color:#dd0000!important}
.w3-note{color:#000!important;background-color:#fff599!important}
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
.w3-warning{color:#000!important;background-color:#ffb305!important}
.w3-success{color:#fff!important;background-color:#008a00!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
@@ -232,4 +248,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
+2 -2
View File
File diff suppressed because one or more lines are too long
Binary file not shown.
+2 -2
View File
@@ -83,7 +83,7 @@ class BackupCard extends HTMLElement {
async handleDeleteButton () {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => {
dialog(template, async (result, _form) => {
if (result === "confirm") {
const body = {
volid: this.volid
@@ -99,7 +99,7 @@ class BackupCard extends HTMLElement {
async handleRestoreButton () {
const template = this.shadowRoot.querySelector("#restore-dialog");
dialog(template, async (result, form) => {
dialog(template, async (result, _form) => {
if (result === "confirm") {
const body = {
volid: this.volid
+5 -3
View File
@@ -2,8 +2,10 @@ import { getSyncSettings, requestAPI } from "./utils.js";
export async function setupClientSync (callback) {
const { scheme, rate } = getSyncSettings();
if (scheme === "always") {
if (scheme === "never") {
return;
}
else if (scheme === "always") {
window.setInterval(callback, rate * 1000);
}
else if (scheme === "hash") {
@@ -19,7 +21,7 @@ export async function setupClientSync (callback) {
}
else if (scheme === "interrupt") {
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
socket.addEventListener("open", (event) => {
socket.addEventListener("open", (_event) => {
socket.send(`rate ${rate}`);
});
socket.addEventListener("message", (event) => {
+17 -17
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";
window.addEventListener("DOMContentLoaded", init);
@@ -48,13 +48,13 @@ class VolumeAction extends HTMLElement {
}
async setStatusLoading () {
const svg = document.querySelector(`svg[data-volume="${this.dataset.volume}"]`);
setSVGSrc(svg, "images/status/loading.svg");
const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
setIconSrc(icon, "images/status/loading.svg");
}
async handleDiskDetach () {
const disk = this.dataset.volume;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
@@ -136,7 +136,7 @@ class VolumeAction extends HTMLElement {
async handleDiskDelete () {
const disk = this.dataset.volume;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
@@ -224,7 +224,7 @@ async function handleCDAdd () {
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) {
select.add(new Option(iso.name, iso.volid));
}
select.selectedIndex = -1;
@@ -247,8 +247,8 @@ class NetworkAction extends HTMLElement {
}
async setStatusLoading () {
const svg = document.querySelector(`svg[data-network="${this.dataset.network}"]`);
setSVGSrc(svg, "images/status/loading.svg");
const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
setIconSrc(icon, "images/status/loading.svg");
}
async handleNetworkConfig () {
@@ -275,9 +275,9 @@ class NetworkAction extends HTMLElement {
async handleNetworkDelete () {
const netID = this.dataset.network;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `${netID}`;
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
if (result.status !== 200) {
@@ -349,8 +349,8 @@ class DeviceAction extends HTMLElement {
}
async setStatusLoading () {
const svg = document.querySelector(`svg[data-device="${this.dataset.device}"]`);
setSVGSrc(svg, "images/status/loading.svg");
const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
setIconSrc(icon, "images/status/loading.svg");
}
async handleDeviceConfig () {
@@ -375,7 +375,7 @@ class DeviceAction extends HTMLElement {
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
for (const availDevice of availDevices) {
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
@@ -383,7 +383,7 @@ class DeviceAction extends HTMLElement {
async handleDeviceDelete () {
const deviceID = this.dataset.device;
dialog(this.template, async (result, form) => {
dialog(this.template, async (result, _form) => {
if (result === "confirm") {
this.setStatusLoading();
const device = `${deviceID}`;
@@ -437,8 +437,8 @@ async function handleDeviceAdd () {
}
});
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
for (const availDevice of availDevices) {
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
for (const availDevice of availDevices.data) {
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
}
d.querySelector("#pcie").checked = true;
@@ -449,7 +449,7 @@ async function refreshBoot () {
if (boot.status !== 200) {
alert("Error fetching instance boot order.");
}
else {
else if (type === "qemu") {
boot = boot.data;
const order = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot);
+106 -16
View File
@@ -17,7 +17,7 @@
* 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) => { }) {
export function dialog (template, onclose = async (_result, _form) => { }) {
const dialog = template.content.querySelector("dialog").cloneNode(true);
document.body.append(dialog);
dialog.addEventListener("close", async () => {
@@ -42,23 +42,113 @@ export function dialog (template, onclose = async (result, form) => { }) {
}
export function alert (message) {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<form method="dialog">
<p class="w3-center" style="margin-bottom: 0px;">${message}</p>
<div class="w3-center">
<button class="w3-button w3-margin" id="submit">OK</button>
</div>
</form>
`;
dialog.className = "w3-container w3-card w3-border-0";
const dialog = document.querySelector("#alert-dialog");
if (dialog == null) {
const dialog = document.createElement("dialog");
dialog.id = "alert-dialog";
dialog.innerHTML = `
<form method="dialog">
<p class="w3-center" style="margin-bottom: 0;">${message}</p>
<div class="w3-center">
<button class="w3-button w3-margin" id="submit">OK</button>
</div>
</form>
`;
dialog.className = "w3-container w3-card w3-border-0";
document.body.append(dialog);
dialog.showModal();
dialog.addEventListener("close", () => {
dialog.parentElement.removeChild(dialog);
});
return dialog;
}
else {
console.error("Attempted to create a new alert while one already exists!");
return null;
}
}
document.body.append(dialog);
dialog.showModal();
class ErrorDialog extends HTMLElement {
shadowRoot = null;
dialog = null;
errors = null;
dialog.addEventListener("close", () => {
dialog.parentElement.removeChild(dialog);
});
constructor () {
super();
this.shadowRoot = this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<style>
#errors {
margin-bottom: 0;
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
}
#errors * {
margin: 0;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt">Error</p>
<div id="errors" class="flex column-reverse"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
</div>
</form>
</dialog>
`;
this.dialog = this.shadowRoot.querySelector("dialog");
this.errors = this.shadowRoot.querySelector("#errors");
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
control.addEventListener("click", async (e) => {
e.preventDefault();
this.dialog.close(e.target.value);
});
}
this.dialog.addEventListener("close", () => {
if (this.dialog.returnValue === "copy") {
let errors = "";
for (const error of this.errors.childNodes) {
errors += `${error.innerText}\n`;
}
navigator.clipboard.writeText(errors);
}
this.parentElement.removeChild(this);
});
}
appendError (error) {
error = `${(new Date()).toUTCString()}: ${error}`;
const p = document.createElement("p");
p.innerText = error;
this.errors.appendChild(p);
}
showModal () {
this.dialog.showModal();
}
}
customElements.define("error-dialog", ErrorDialog);
export function error (message) {
let dialog = document.querySelector("error-dialog");
if (dialog == null) {
dialog = document.createElement("error-dialog");
document.body.append(dialog);
dialog.appendError(message);
dialog.showModal();
}
else {
dialog.appendError(message);
dialog.showModal();
}
return dialog;
}
+3 -2
View File
@@ -1,4 +1,5 @@
const blank = document.createElement("img");
blank.src = "images/common/blank.png"; // for whatever reason an svg does NOT work here
class DraggableContainer extends HTMLElement {
shadowRoot = null;
@@ -12,7 +13,7 @@ class DraggableContainer extends HTMLElement {
window.Sortable.create(this.content, {
group: this.dataset.group,
ghostClass: "ghost",
setData: function (dataTransfer, dragEl) {
setData: function (dataTransfer, _dragEl) {
dataTransfer.setDragImage(blank, 0, 0);
}
});
@@ -48,7 +49,7 @@ class DraggableContainer extends HTMLElement {
get value () {
const value = [];
this.content.childNodes.forEach((element) => {
this.content.querySelectorAll(".draggable-item").forEach((element) => {
if (element.dataset.value) {
value.push(element.dataset.value);
}
+79 -79
View File
@@ -1,5 +1,5 @@
import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setSVGSrc, setSVGAlt } from "./utils.js";
import { alert, dialog } from "./dialog.js";
import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setIconSrc, setIconAlt } from "./utils.js";
import { alert, dialog, error } from "./dialog.js";
import { setupClientSync } from "./clientsync.js";
import wfaInit from "../modules/wfa.js";
@@ -120,25 +120,29 @@ class InstanceCard extends HTMLElement {
}
const powerButton = this.shadowRoot.querySelector("#power-btn");
if (powerButton.classList.contains("clickable")) {
powerButton.onclick = this.handlePowerButton.bind(this);
powerButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handlePowerButton();
}
};
if (powerButton !== null) {
if (powerButton.classList.contains("clickable")) {
powerButton.onclick = this.handlePowerButton.bind(this);
powerButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handlePowerButton();
}
};
}
}
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
if (deleteButton !== null) {
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
}
}
@@ -146,16 +150,16 @@ class InstanceCard extends HTMLElement {
this.status = "loading";
const statusicon = this.shadowRoot.querySelector("#status");
const powerbtn = this.shadowRoot.querySelector("#power-btn");
setSVGSrc(statusicon, "images/status/loading.svg");
setSVGAlt(statusicon, "instance is loading");
setSVGSrc(powerbtn, "images/status/loading.svg");
setSVGAlt(powerbtn, "");
setIconSrc(statusicon, "images/status/loading.svg");
setIconAlt(statusicon, "instance is loading");
setIconSrc(powerbtn, "images/status/loading.svg");
setIconAlt(powerbtn, "");
}
async handlePowerButton () {
if (!this.actionLock) {
const template = this.shadowRoot.querySelector("#power-dialog");
dialog(template, async (result, form) => {
dialog(template, async (result, _form) => {
if (result === "confirm") {
this.actionLock = true;
const targetAction = this.status === "running" ? "stop" : "start";
@@ -189,7 +193,7 @@ class InstanceCard extends HTMLElement {
handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => {
dialog(template, async (result, _form) => {
if (result === "confirm") {
this.actionLock = true;
@@ -219,7 +223,7 @@ async function getInstancesFragment () {
async function refreshInstances () {
let instances = await getInstancesFragment();
if (instances.status !== 200) {
alert("Error fetching instances.");
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
}
else {
instances = instances.data;
@@ -243,7 +247,7 @@ function sortInstances () {
const searchQuery = document.querySelector("#search").value || null;
let criteria;
if (!searchQuery) {
criteria = (item, query = null) => {
criteria = (item, _query = null) => {
return { score: item.vmid, alignment: null };
};
}
@@ -253,9 +257,9 @@ function sortInstances () {
if (substrInc) {
const substrStartIndex = item.indexOf(query);
const queryLength = query.length;
const remaining = item.length - substrInc - queryLength;
const remaining = item.length - substrInc - queryLength + 1;
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
return { score: 1, alignment };
return { score: -1, alignment };
}
else {
const alignment = `${"X".repeat(item.length)}`;
@@ -272,8 +276,8 @@ function sortInstances () {
};
criteria = (item, query) => {
// lower is better
const { score, CIGAR } = global.wfAlign(query, item, penalties, true);
const alignment = global.DecodeCIGAR(CIGAR);
const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
const alignment = global.wfa.DecodeCIGAR(CIGAR);
return { score: score / item.length, alignment };
};
}
@@ -338,11 +342,11 @@ async function handleInstanceAddButton () {
}
}
});
const templates = await requestAPI("/user/ct-templates", "GET");
// setup type select
const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1;
// on type change, reveal or hide the container specific section
typeSelect.addEventListener("change", () => {
if (typeSelect.value === "qemu") {
d.querySelectorAll(".container-specific").forEach((element) => {
@@ -362,66 +366,62 @@ async function handleInstanceAddButton () {
element.disabled = true;
});
const rootfsContent = "rootdir";
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
const userResources = await requestAPI("/user/dynamic/resources", "GET");
const userCluster = await requestAPI("/user/config/cluster", "GET");
const nodeSelect = d.querySelector("#node");
nodeSelect.innerHTML = "";
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;
// on node change, get the available storages and repopulate the storage selector
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
rootfsStorage.innerHTML = "";
storage.data.forEach((element) => {
storage.forEach((element) => {
if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage));
}
});
rootfsStorage.selectedIndex = -1;
// set core and memory min/max depending on node selected
if (node in userResources.cores.nodes) {
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
}
else {
d.querySelector("#cores").max = userResources.cores.global.avail;
}
if (node in userResources.memory.nodes) {
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
}
else {
d.querySelector("#memory").max = userResources.memory.global.avail;
}
});
// set vmid min/max
d.querySelector("#vmid").min = userCluster.vmid.min;
d.querySelector("#vmid").max = userCluster.vmid.max;
// add user pools to selector
const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// setup root dir select
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
// set rootfs content type (rootdir)
const rootfsContent = "rootdir";
// setup templateImage depending on selected image storage
const templateImage = d.querySelector("#template-image");
// add template images to selector
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
for (const template of templates) {
const templates = await requestAPI("/user/ct-templates", "GET");
for (const template of templates.data) {
templateImage.append(new Option(template.name, template.volid));
}
templateImage.selectedIndex = -1;
+1
View File
@@ -4,6 +4,7 @@ window.addEventListener("DOMContentLoaded", init);
function init () {
setAppearance();
const { scheme, rate } = getSyncSettings();
if (scheme) {
document.querySelector(`#sync-${scheme}`).checked = true;
+18 -22
View File
@@ -80,33 +80,34 @@ async function request (url, content) {
try {
const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type");
let data = null;
const res = {};
if (contentType === null) {
data = {};
res.data = null;
res.status = response.status;
}
else if (contentType.includes("application/json")) {
data = await response.json();
data.status = response.status;
res.data = await response.json();
res.status = response.status;
}
else if (contentType.includes("text/html")) {
data = { data: await response.text() };
data.status = response.status;
res.data = await response.text();
res.status = response.status;
}
else if (contentType.includes("text/plain")) {
data = { data: await response.text() };
data.status = response.status;
res.data = await response.text();
res.status = response.status;
}
else {
data = {};
res.data = null;
res.status = response.status;
}
if (!response.ok) {
return { status: response.status, error: data ? data.error : response.status };
return { status: response.status, error: res.data ? res.data.error : response.status };
}
else {
data.status = response.status;
return data || response;
return res;
}
}
catch (error) {
@@ -192,15 +193,10 @@ export function setAppearance () {
}
// assumes href is path to svg, and id to grab is #symb
export function setSVGSrc (svgElem, href) {
let useElem = svgElem.querySelector("use");
if (!useElem) {
useElem = document.createElementNS("http://www.w3.org/2000/svg", "use");
}
useElem.setAttribute("href", `${href}#symb`);
svgElem.append(useElem);
export function setIconSrc (icon, path) {
icon.setAttribute("src", path);
}
export function setSVGAlt (svgElem, alt) {
svgElem.setAttribute("aria-label", alt);
export function setIconAlt (icon, alt) {
icon.setAttribute("alt", alt);
}
+20 -25
View File
@@ -2,7 +2,6 @@
<backup-card data-volid="{{.Volid}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css">
<style>
* {
@@ -11,22 +10,18 @@
a {
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
svg {
height: 1em;
width: 1em;
margin: 0;
padding: 0;
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
<p class="w3-col l2 m4 s8">{{.TimeFormatted}}</p>
<p class="w3-col l6 m6 w3-hide-small">{{.Notes}}</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.SizeFormatted}}</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;">
<svg id="edit-btn" class="clickable" aria-label="change notes"><use href="images/actions/backups/config.svg#symb"></svg>
<svg id="delete-btn" class="clickable" aria-label="delete backup" role="button" tabindex=0><use href="images/actions/backups/delete-active.svg#symb"></svg>
<svg id="restore-btn" class="clickable" aria-label="restore from backup" role="button" tabindex=0><use href="images/actions/backups/restore.svg#symb"></svg>
<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">
@@ -34,7 +29,7 @@
<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;">
<p class="w3-large" id="prompt">
Edit Backup
</p>
<div id="body">
@@ -44,8 +39,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -54,7 +49,7 @@
<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;">
<p class="w3-large" id="prompt">
Delete Backup
</p>
<div id="body">
@@ -65,8 +60,8 @@
</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>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -75,7 +70,7 @@
<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;">
<p class="w3-large" id="prompt">
Restore From Backup?
</p>
<div id="body">
@@ -89,8 +84,8 @@
</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>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -101,14 +96,14 @@
{{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>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Create Backup"><use href="images/actions/network/add.svg#symb"></use></svg>
<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;">
<p class="w3-large" id="prompt">
Create Backup
</p>
<div id="body">
@@ -118,8 +113,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
+17 -14
View File
@@ -1,10 +1,10 @@
{{/* <head> common across all pages*/}}
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.global.Organization}} - dashboard</title>
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<script>
window.PVE = "{{.global.PVE}}";
window.API = "{{.global.API}}";
@@ -15,18 +15,21 @@
<link rel="stylesheet" href="css/form.css">
{{end}}
{{/* <header> common across all pages*/}}
{{define "header"}}
<h1>{{.global.Organization}}</h1>
<label for="navtoggle">&#9776;</label>
<input type="checkbox" id="navtoggle">
<nav id="navigation">
{{if eq .page "login"}}
<a href="login" aria-current="page">Login</a>
{{else}}
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
<a href="login">Logout</a>
{{end}}
</nav>
<header>
<h1>{{.global.Organization}}</h1>
<label for="navtoggle">&#9776;</label>
<input type="checkbox" id="navtoggle">
<nav id="navigation">
{{if eq .page "login"}}
<a href="login" aria-current="page">Login</a>
{{else}}
<a href="index" {{if eq .page "index"}} aria-current="page" {{end}}>Instances</a>
<a href="account" {{if eq .page "account"}} aria-current="page" {{end}}>Account</a>
<a href="settings" {{if eq .page "settings"}} aria-current="page" {{end}}>Settings</a>
<a href="login">Logout</a>
{{end}}
</nav>
</header>
{{end}}
+87 -80
View File
@@ -1,26 +1,26 @@
{{define "proctype-input"}}
<svg aria-label="CPU Type"><use href="images/resources/cpu.svg#symb"></svg>
<img alt="CPU Type" src="images/resources/cpu.svg#symb">
<label for="proctype">CPU Type</label>
{{template "select" .}}
<div></div>
{{end}}
{{define "cores-input"}}
<svg aria-label="CPU Amount"><use href="images/resources/cpu.svg#symb"></svg>
<img alt="CPU Amount" src="images/resources/cpu.svg#symb">
<label for="cores">CPU Amount</label>
<input id="cores" name="cores" class="w3-input w3-border" type="number" required value="{{.}}">
<p>Cores</p>
{{end}}
{{define "memory-input"}}
<svg aria-label="Memory Amount"><use href="images/resources/ram.svg#symb"></svg>
<img alt="Memory Amount" src="images/resources/ram.svg#symb">
<label for="ram">Memory</label>
<input id="ram" name="ram" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p>
{{end}}
{{define "swap-input"}}
<svg aria-label="Swap Amount"><use href="images/resources/swap.svg#symb"></svg>
<img alt="Swap Amount" src="images/resources/swap.svg#symb">
<label for="swap">Swap</label>
<input id="swap" name="swap" class="w3-input w3-border" type="number" required value="{{.}}">
<p>MiB</p>
@@ -46,11 +46,11 @@
{{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>
<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>
<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;">
<p class="w3-large" id="prompt">
Create New Disk
</p>
<div id="body">
@@ -65,8 +65,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -75,11 +75,11 @@
{{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>
<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>
<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;">
<p class="w3-large" id="prompt">
Mount a CDROM
</p>
<div id="body">
@@ -89,15 +89,15 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "volume-rootfs"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
@@ -109,7 +109,7 @@
{{end}}
{{define "volume-mp"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
@@ -121,7 +121,7 @@
{{end}}
{{define "volume-ide"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
@@ -133,7 +133,7 @@
{{end}}
{{define "volume-scsi"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
@@ -145,7 +145,7 @@
{{end}}
{{define "volume-unused"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<img data-volume={{.Name}} alt="Drive" src="images/resources/drive.svg#symb">
<p>{{.Name}}</p>
<p>{{.Volume.File}}</p>
<div>
@@ -160,10 +160,10 @@
<volume-action data-type="move" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg>
<img class="clickable" alt="Move {{.Name}}" src="images/actions/disk/move-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Move {{.Name}}
</p>
<div id="body">
@@ -173,8 +173,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -186,7 +186,7 @@
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/move-inactive.svg#symb"></svg>
<img alt="" src="images/actions/disk/move-inactive.svg#symb">
</template>
</volume-action>
{{end}}
@@ -195,10 +195,10 @@
<volume-action data-type="resize" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg>
<img class="clickable" alt="Resize {{.Name}}" src="images/actions/disk/resize-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Resize {{.Name}}
</p>
<div id="body">
@@ -208,8 +208,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -221,7 +221,7 @@
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/resize-inactive.svg#symb"></svg>
<img alt="" src="images/actions/disk/resize-inactive.svg#symb">
</template>
</volume-action>
{{end}}
@@ -230,10 +230,10 @@
<volume-action data-type="delete" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg>
<img class="clickable" alt="Delete {{.Name}}" src="images/actions/disk/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Delete {{.Name}}
</p>
<div id="body">
@@ -242,8 +242,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -255,7 +255,7 @@
<volume-action data-type="none" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""><use href="images/actions/disk/delete-inactive.svg#symb"></svg>
<img alt="" src="images/actions/disk/delete-inactive.svg#symb">
</template>
</volume-action>
{{end}}
@@ -264,10 +264,10 @@
<volume-action data-type="attach" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg>
<img class="clickable" alt="Attach {{.Name}}" src="images/actions/disk/attach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Attach {{.Name}}
</p>
<div id="body">
@@ -284,8 +284,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -297,10 +297,10 @@
<volume-action data-type="detach" data-volume="{{.Name}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg>
<img class="clickable" alt="Detach {{.Name}}" src="images/actions/disk/detach.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Detach {{.Name}}
</p>
<div id="body">
@@ -309,8 +309,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -322,7 +322,7 @@
<volume-action data-type="none">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg aria-label=""></svg>
<img alt="" src="images/common/blank.svg">
</template>
</volume-action>
{{end}}
@@ -336,11 +336,11 @@
{{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>
<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>
<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;">
<p class="w3-large" id="prompt">
Create Network Interface
</p>
<div id="body">
@@ -353,25 +353,25 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "net"}}
<svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg>
<img data-network="{{.Net_ID}}" alt="Net {{.Net_ID}}" src="images/resources/network.svg#symb">
<p>{{.Net_ID}}</p>
<p>{{.Value}}</p>
<div>
<network-action data-type="config" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg>
<img class="clickable" alt="Configure Net {{.Net_ID}}" src="images/actions/network/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Edit {{.Net_ID}}
</p>
<div id="body">
@@ -380,8 +380,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -390,10 +390,10 @@
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg>
<img class="clickable" alt="Delete Net {{.Net_ID}}" src="images/actions/network/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Delete {{.Net_ID}}
</p>
<div id="body">
@@ -402,8 +402,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -421,11 +421,11 @@
{{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>
<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>
<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;">
<p class="w3-large" id="prompt">
Add Expansion Card
</p>
<div id="body">
@@ -436,25 +436,25 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "device"}}
<svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg>
<img data-device="{{.Device_ID}}" alt="Device {{.Device_ID}}" src="images/resources/device.svg#symb">
<p>{{.Device_ID}}</p>
<p>{{.Device_Name}}</p>
<div>
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<device-action data-type="config" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg>
<img class="clickable" alt="Configure Device {{.Device_ID}}" src="images/actions/device/config.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
Edit Expansion Card {{.Device_ID}}
</p>
<div id="body">
@@ -463,20 +463,20 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
</template>
</device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Device_ID}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg>
<img class="clickable" alt="Delete Device {{.Device_ID}}" src="images/actions/device/delete-active.svg#symb">
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
remove Expansion Card {{.Device_ID}}
</p>
<div id="body">
@@ -485,8 +485,8 @@
</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>
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -516,10 +516,17 @@
div.draggable-item {
cursor: grab;
}
div.draggable-item svg {
div.draggable-item img {
height: 1em;
width: 1em;
}
div.draggable-item p {
margin: 0;
}
div.draggable-item p.volume-file {
overflow: hidden;
white-space: nowrap;
}
#wrapper {
padding-bottom: 1em;
}
@@ -536,18 +543,18 @@
{{define "boot-target"}}
{{if .volume_id}}
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
<svg aria-label="Volume"><use href="images/resources/drive.svg#symb"></use></svg>
<p style="margin: 0px;">{{.volume_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.file}}</p>
<div class="draggable-item" data-value="{{.volume_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img alt="Drag" src="images/actions/drag.svg#symb">
<img alt="Volume" src="images/resources/drive.svg#symb">
<p class="volume-id">{{.volume_id}}</p>
<p class="volume-file">{{.file}}</p>
</div>
{{else if .net_id}}
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
<svg aria-label="Drag"><use href="images/actions/drag.svg#symb"></use></svg>
<svg aria-label="Net"><use href="images/resources/network.svg#symb"></use></svg>
<p style="margin: 0px;">{{.net_id}}</p>
<p style="margin: 0px; overflow: hidden; white-space: nowrap;">{{.value}}</p>
<div class="draggable-item" data-value="{{.net_id}}" style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 0.5em; align-items: center;">
<img alt="Drag" src="images/actions/drag.svg#symb">
<img alt="Net" src="images/resources/network.svg#symb">
<p class="volume-id">{{.net_id}}</p>
<p class="volume-file">{{.value}}</p>
</div>
{{else}}
{{end}}
+103 -70
View File
@@ -2,87 +2,120 @@
<instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css">
<style>
* {
margin: 0;
padding: 0;
width: fit-content;
}
a {
a, svg, img {
line-height: 1em;
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
margin: 0;
padding: 0;
}
svg {
height: 1em;
width: 1em;
a img {
vertical-align: unset;
}
#instance-name {
overflow-x: hidden;
min-width: 0;
width: auto;
white-space: nowrap;
}
.flex { /* needed for some reason to avoid a flickering issue on chrome ONLY */
display: flex;
}
.row { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-direction: row;
column-gap: 0.5em;
align-items: center;
}
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-wrap: nowrap;
}
@media screen and (width >=993px) {
.hide-large {display: none !important;}
}
@media screen and (width <=993px) and (width >=601px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
}
@media screen and (width <=601px) and (width >=440px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;">
<p class="w3-col l1 m2 s6">{{.VMID}}</p>
<p class="w3-col l2 m3 s6" id="instance-name">{{.Name}}</p>
<p class="w3-col l1 m2 w3-hide-small">{{.Type}}</p>
<div class="w3-col l2 m3 s6 flex row nowrap">
{{if eq .Status "running"}}
<svg id="status" aria-label="instance is running"><use href="images/status/active.svg#symb"></svg>
{{else if eq .Status "stopped"}}
<svg id="status" aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg>
{{else if eq .Status "loading"}}
<svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
{{else}}
<svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
{{end}}
<p>{{.Status}}</p>
</div>
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.Node}}</p>
<div class="w3-col l2 w3-hide-medium w3-hide-small flex row nowrap">
{{if eq .NodeStatus "online"}}
<svg aria-label="node is online"><use href="images/status/active.svg#symb"></svg>
{{else if eq .NodeStatus "offline"}}
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
{{else if eq .NodeStatus "unknown"}}
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
{{else}}
{{end}}
<p>{{.NodeStatus}}</p>
</div>
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
<svg id="power-btn" class="clickable" aria-label="shutdown instance" role="button" tabindex=0><use href="images/actions/instance/stop.svg#symb"></svg>
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<svg id="backup-btn" aria-disabled="true" role="none"><use href="images/actions/instance/backup-inactive.svg#symb"></svg>
<a href="{{.ConsolePath}}" target="_blank">
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
</a>
<svg id="delete-btn" aria-disabled="true" role="none"><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" role="button" tabindex=0><use href="images/actions/instance/start.svg#symb"></svg>
<a href="{{.ConfigPath}}">
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
</a>
<a href="{{.BackupsPath}}">
<svg id="backup-btn" class="clickable" aria-label="manage backups"><use href="images/actions/instance/backup-active.svg#symb"></svg>
</a>
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" class="clickable" aria-label="delete instance" role="button" tabindex=0><use href="images/actions/instance/delete-active.svg#symb"></svg>
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
<svg id="power-btn" aria-disabled="true" role="none"><use href="images/actions/instance/loading.svg#symb"></svg>
<svg id="configure-btn" aria-disabled="true" role="none"><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<svg id="backup-btn" aria-disabled="true" role="none"><use href="images/actions/instance/backup-inactive.svg#symb"></svg>
<svg id="console-btn" aria-disabled="true" role="none"><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" aria-disabled="true" role="none"><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
{{else}}
{{end}}
</div>
<p>{{.VMID}}</p>
<p id="instance-name">{{.Name}}</p>
<p class="hide-small">{{.Type}}</p>
<div class="flex row nowrap hide-tiny">
{{if eq .Status "running"}}
<img id="status" alt="instance is running" src="images/status/active.svg#symb">
{{else if eq .Status "stopped"}}
<img id="status" alt="instance is stopped" src="images/status/inactive.svg#symb">
{{else if eq .Status "loading"}}
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
{{else}}
<img id="status" alt="instance is loading" src="images/status/loading.svg#symb">
{{end}}
<p>{{.Status}}</p>
</div>
<p class="hide-medium">{{.Node}}</p>
<div class="flex row nowrap hide-medium">
{{if eq .NodeStatus "online"}}
<img alt="node is online" src="images/status/active.svg#symb">
{{else if eq .NodeStatus "offline"}}
<img alt="node is offline" src="images/status/inactive.svg#symb">
{{else if eq .NodeStatus "unknown"}}
<img alt="node is offline" src="images/status/inactive.svg#symb">
{{else}}
{{end}}
<p>{{.NodeStatus}}</p>
</div>
<div class="flex row nowrap">
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
<img id="power-btn" class="clickable" alt="shutdown instance" role="button" tabindex=0 src="images/actions/instance/stop.svg#symb">
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
<a href="{{.ConsolePath}}" target="_blank">
<img id="console-btn" class="clickable" alt="open console" src="images/actions/instance/console-active.svg#symb">
</a>
<img id="delete-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/delete-inactive.svg#symb">
{{else if and (eq .NodeStatus "online") (eq .Status "stopped")}}
<img id="power-btn" class="clickable" alt="start instance" role="button" tabindex=0 src="images/actions/instance/start.svg#symb">
<a href="{{.ConfigPath}}">
<img id="configure-btn" class="clickable" alt="change configuration" src="images/actions/instance/config-active.svg#symb">
</a>
<a href="{{.BackupsPath}}">
<img id="backup-btn" class="clickable" alt="manage backups" src="images/actions/instance/backup-active.svg#symb">
</a>
<img id="console-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/console-inactive.svg#symb">
<img id="delete-btn" class="clickable" alt="delete instance" role="button" tabindex=0 src="images/actions/instance/delete-active.svg#symb">
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
<img id="power-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/loading.svg#symb">
<img id="configure-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/config-inactive.svg#symb">
<img id="backup-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/backup-inactive.svg#symb">
<img id="console-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/console-inactive.svg#symb">
<img id="delete-btn" alt="" aria-disabled="true" role="none" src="images/actions/instance/delete-inactive.svg#symb">
{{else}}
{{end}}
</div>
<template id="power-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
<p class="w3-large" id="prompt">
{{if eq .Status "running"}}
Stop {{.VMID}}
{{else if eq .Status "stopped"}}
@@ -101,8 +134,8 @@
</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>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
</div>
</dialog>
</template>
@@ -111,7 +144,7 @@
<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;">
<p class="w3-large" id="prompt">
Delete {{.VMID}}
</p>
<div id="body">
@@ -120,8 +153,8 @@
</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>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
</div>
</dialog>
</template>
+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}}
+3 -9
View File
@@ -2,19 +2,13 @@
<resource-chart>
<template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css">
<style>
* {
box-sizing: border-box;
font-family: monospace;
}
#container{
margin: 0;
width: 100%;
height: fit-content;
padding: 10px;
border-radius: 5px;
padding: 0.5em;
}
progress {
width: 100%;
@@ -24,7 +18,7 @@
}
#caption {
text-align: center;
margin-top: 10px;
margin-top: 0.5em;
display: flex;
flex-direction: column;
}
@@ -42,7 +36,7 @@
<progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
<label id="caption" for="resource">
<span>{{.Name}}</span>
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
<span>{{.Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</label>
</div>
</template>
+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"}}
<select class="w3-select w3-border" id="{{.ID}}" name="{{.ID}}" {{if .Required}}required{{end}}>
{{range .Options}}
{{range .Options}}
{{template "option" .}}
{{end}}
</select>
{{end}}
{{/*
Options: generic data driven <option> element template
.Selected = (bool) option element selected attribute
.Value = (string) option element value attribute
.Display = (string) option element innerText
*/}}
{{define "option"}}
{{if .Selected}}
<option value="{{.Value}}" selected>{{.Display}}</option>
{{else}}
<option value="{{.Value}}">{{.Display}}</option>
{{end}}
{{end}}
</select>
{{end}}