55 Commits

Author SHA1 Message Date
7db0bea35c temporary fix for error dialog 2025-10-16 23:16:14 +00:00
ff98eb318e fix linting 2025-10-14 04:50:04 +00:00
05ced39598 fix exact match bug, 2025-10-13 20:56:05 +00:00
4e2b6278d8 fix chrome flicker for smaller screen sizes 2025-10-10 22:14:57 +00:00
75e098b7b4 fix wrong css class definition in instance card to fix chrome flicker issue 2025-10-10 22:12:37 +00:00
8c378a3b49 update go mod 2025-10-10 21:53:35 +00:00
3d5989a946 fix flickering issue with instance-card refresh and instance search 2025-10-10 21:41:10 +00:00
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
2f21b23535 fix instance card action area slightly too small in medium layouts 2025-10-03 03:58:08 +00:00
e8dd28b519 update index with wfa update 2025-10-02 22:04:12 +00:00
e0c7a53d85 update wfa,
minor style fix
2025-10-02 22:03:23 +00:00
118b7dac53 minor style fix 2025-10-02 17:52:08 +00:00
db32f318b9 update go mod 2025-10-02 17:45:40 +00:00
c13a4c8539 update wfa 2025-09-30 17:50:36 +00:00
8d490cd336 remove remaining external css links 2025-09-29 22:05:08 +00:00
343c149330 update w3 css,
remove external css link to speedup render times
2025-09-29 21:59:26 +00:00
d95a82f248 move flag parsing to main, add debug mime types 2025-09-25 00:27:24 +00:00
fc42de2c49 fix bugs with boot order interface 2025-09-25 00:27:00 +00:00
3f723394c4 update go mod 2025-09-23 03:56:07 +00:00
e7627b5787 update go mod, update fabric submodule 2025-09-23 03:53:11 +00:00
7732da0642 fix bug in CPU type selector 2025-09-13 21:42:18 +00:00
87c42495ad bump version to 1.0.0 2025-09-07 05:54:39 +00:00
89065254d2 update fabric 2025-09-07 05:44:32 +00:00
08e5f8b392 fix linting 2025-09-07 05:44:22 +00:00
8be935a421 update go mod, update fabric 2025-09-02 20:12:20 +00:00
f94dca7e0c update go mod 2025-08-12 22:14:58 +00:00
8905886065 fix favicon viewbox issue 2025-08-11 22:41:43 +00:00
df6772c72b add categorization to account resources 2025-08-11 22:40:39 +00:00
f3b6c0abf4 update go mod,
update submodules
2025-08-11 22:37:13 +00:00
69fae92313 add mp selector to config 2025-08-06 19:42:33 +00:00
a79dd96d2a disable backup when instance is running 2025-08-04 21:22:52 +00:00
ee397c48e1 add restore function to backups 2025-07-25 21:49:52 +00:00
33b0a4b5ff add edit, delete, and restore options to backups
update fabric submodule
2025-07-14 20:58:10 +00:00
65c8fbdca8 implement user triggered backups 2025-07-07 20:59:12 +00:00
e932165a98 various minor fixes 2025-07-04 05:33:20 +00:00
8c339794b3 move getAPI request interface body to parameter,
move VMPath and FormatNumber methods to common utils
2025-06-30 23:44:28 +00:00
a62fc83386 fix viewbox on delete icons 2025-06-25 22:21:17 +00:00
756aef587d fix viewbox on instance action icons 2025-06-25 21:35:32 +00:00
ca555a7116 add unknown status to instance,
add svg minimize
2025-06-20 21:37:19 +00:00
85c3ab49fc format index 2025-06-19 20:28:35 +00:00
9ec277ce65 implement ssr dialog for config 2025-06-19 20:23:10 +00:00
e41c8d2a07 revert ssr dialog interface 2025-06-18 21:21:00 +00:00
308d133e6e implement ssr modal dialog for index and account 2025-06-13 00:30:54 +00:00
99d58eb250 add fabric as submodule 2025-06-02 19:25:27 +00:00
acd6eba520 fix bug in ssr dialog with multiple event listeners, add ssr dialog for instance delete 2025-05-29 18:33:01 +00:00
478ca20451 implement ssr modal dialog form construction for create instances 2025-05-28 20:08:52 +00:00
28c60aecc9 rename html fragments to go.tmpl extension 2025-05-28 20:08:25 +00:00
3d677a46ee fix some accessibility issues with non focusable elements 2025-05-22 18:34:45 +00:00
e170d7f93d update go mod 2025-05-20 17:58:36 +00:00
85bd81ef30 readd coloring to resource chart bars 2025-05-20 17:55:15 +00:00
53832b67a2 minor optimizations to instance card config and console actions 2025-05-18 01:12:58 +00:00
e6cd1fbb3d fix linting 2025-05-13 18:37:06 +00:00
3f21f3c4a4 Merge pull request 'Fix Sync Issue with Instance Power Action' (#4) from instance-power-sync-fix into main
Reviewed-on: #4
2025-05-13 17:41:28 +00:00
989f59223a reimplement updating instance to loading when power action is taken 2025-05-13 17:35:11 +00:00
233d4255ba fixes #3 by using /status/current for recently modified instances 2025-05-13 17:33:59 +00:00
55 changed files with 1820 additions and 736 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "ProxmoxAAS-Fabric"]
path = ProxmoxAAS-Fabric
url = https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric

1
ProxmoxAAS-Fabric Submodule

Submodule ProxmoxAAS-Fabric added at 9be70e5900

View File

@@ -1,40 +1,37 @@
package app package app
import ( import (
"flag"
"fmt" "fmt"
"log" "log"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"proxmoxaas-dashboard/app/routes" "proxmoxaas-dashboard/app/routes"
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tdewolff/minify/v2" "github.com/tdewolff/minify/v2"
) )
func Run() { func Run(configPath *string) {
gin.SetMode(gin.ReleaseMode)
configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
common.Global = common.GetConfig(*configPath) common.Global = common.GetConfig(*configPath)
gin.SetMode(gin.ReleaseMode)
router := gin.Default() router := gin.Default()
m := common.InitMinify() m := common.InitMinify()
ServeStatic(router, m) ServeStatic(router, m)
html := common.MinifyStatic(m, web.Templates) html := common.MinifyStatic(m, web.Templates)
common.TMPL = common.LoadHTMLToGin(router, html) common.TMPL = common.LoadHTMLToGin(router, html)
router.GET("/account", routes.HandleGETAccount)
router.GET("/", routes.HandleGETIndex) router.GET("/", routes.HandleGETIndex)
router.GET("/index", routes.HandleGETIndex) router.GET("/index", routes.HandleGETIndex)
router.GET("/index/instances", routes.HandleGETInstancesFragment) router.GET("/index/instances", routes.HandleGETInstancesFragment)
router.GET("/account", routes.HandleGETAccount)
router.GET("/config", routes.HandleGETConfig) router.GET("/config", routes.HandleGETConfig)
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment) router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
router.GET("/config/nets", routes.HandleGETConfigNetsFragment) router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment) router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
router.GET("/config/boot", routes.HandleGETConfigBootFragment) router.GET("/config/boot", routes.HandleGETConfigBootFragment)
router.GET("/backups", routes.HandleGETBackups)
router.GET("/backups/backups", routes.HandleGETBackupsFragment)
router.GET("/login", routes.HandleGETLogin) router.GET("/login", routes.HandleGETLogin)
router.GET("/settings", routes.HandleGETSettings) router.GET("/settings", routes.HandleGETSettings)

View File

@@ -7,6 +7,7 @@ import (
"github.com/tdewolff/minify/v2/css" "github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/html" "github.com/tdewolff/minify/v2/html"
"github.com/tdewolff/minify/v2/js" "github.com/tdewolff/minify/v2/js"
"github.com/tdewolff/minify/v2/svg"
) )
// defines mime type and associated minifier // defines mime type and associated minifier
@@ -35,7 +36,7 @@ var MimeTypes = map[string]MimeType{
}, },
"svg": { "svg": {
Type: "image/svg+xml", Type: "image/svg+xml",
Minifier: nil, Minifier: svg.Minify,
}, },
"js": { "js": {
Type: "application/javascript", Type: "application/javascript",
@@ -50,3 +51,41 @@ var MimeTypes = map[string]MimeType{
Minifier: nil, Minifier: nil,
}, },
} }
// 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,
},
"js": {
Type: "application/javascript",
Minifier: nil,
},
"wasm": {
Type: "application/wasm",
Minifier: nil,
},
"*": {
Type: "text/plain",
Minifier: nil,
},
}
*/

View File

@@ -31,7 +31,6 @@ type RequestType int
type RequestContext struct { type RequestContext struct {
Cookies map[string]string Cookies map[string]string
Body map[string]any
} }
type Auth struct { type Auth struct {

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"math"
"net/http" "net/http"
"os" "os"
"reflect" "reflect"
@@ -22,6 +23,12 @@ import (
var TMPL *template.Template var TMPL *template.Template
var Global Config var Global Config
type VMPath struct {
Node string
Type string
VMID string
}
func GetConfig(configPath string) Config { func GetConfig(configPath string) Config {
content, err := os.ReadFile(configPath) content, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -159,7 +166,7 @@ func HandleNonFatalError(c *gin.Context, err error) {
c.Status(http.StatusInternalServerError) c.Status(http.StatusInternalServerError)
} }
func RequestGetAPI(path string, context RequestContext) (*http.Response, int, error) { func RequestGetAPI(path string, context RequestContext, body any) (*http.Response, int, error) {
req, err := http.NewRequest("GET", Global.API+path, nil) req, err := http.NewRequest("GET", Global.API+path, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -186,9 +193,18 @@ func RequestGetAPI(path string, context RequestContext) (*http.Response, int, er
return nil, response.StatusCode, err return nil, response.StatusCode, err
} }
err = json.Unmarshal(data, &context.Body) switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
if err != nil { case *map[string]any:
return nil, response.StatusCode, err err = json.Unmarshal(data, &body)
if err != nil {
return nil, response.StatusCode, err
}
case *[]any:
err = json.Unmarshal(data, &body)
if err != nil {
return nil, response.StatusCode, err
}
default:
} }
return response, response.StatusCode, nil return response, response.StatusCode, nil
@@ -205,3 +221,38 @@ func GetAuth(c *gin.Context) (Auth, error) {
return Auth{username, token, csrf}, nil return Auth{username, token, csrf}, nil
} }
} }
func ExtractVMPath(c *gin.Context) (VMPath, error) {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
return vm_path, nil
}
func FormatNumber(val int64, base int64) (float64, string) {
valf := float64(val)
basef := float64(base)
steps := 0
for math.Abs(valf) > basef && steps < 4 {
valf /= basef
steps++
}
if base == 1000 {
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
return 0, ""
}
}

View File

@@ -2,85 +2,14 @@ package routes
import ( import (
"fmt" "fmt"
"math"
"net/http" "net/http"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"github.com/gerow/go-color"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
account, err := GetUserAccount(auth)
if err != nil {
common.HandleNonFatalError(c, err)
return
}
for k, v := range account.Resources {
switch t := v.(type) {
case NumericResource:
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[k] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
}
case StorageResource:
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
account.Resources[k] = ResourceChart{
Type: t.Type,
Display: t.Display,
Name: t.Name,
Used: t.Total.Used,
Max: t.Total.Max,
Avail: avail,
Prefix: prefix,
Unit: t.Unit,
}
case ListResource:
l := struct {
Type string
Display bool
Resources []ResourceChart
}{
Type: t.Type,
Display: t.Display,
Resources: []ResourceChart{},
}
for _, r := range t.Total {
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: float64(r.Avail), // usually an int
Unit: "",
})
}
account.Resources[k] = l
}
}
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"account": account,
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
type Account struct { type Account struct {
Username string Username string
Pools map[string]bool Pools map[string]bool
@@ -89,7 +18,7 @@ type Account struct {
Min int Min int
Max int Max int
} }
Resources map[string]any Resources map[string]map[string]any
} }
type Constraint struct { type Constraint struct {
@@ -117,6 +46,7 @@ type NumericResource struct {
Global Constraint Global Constraint
Nodes map[string]Constraint Nodes map[string]Constraint
Total Constraint Total Constraint
Category string
} }
type StorageResource struct { type StorageResource struct {
@@ -131,6 +61,7 @@ type StorageResource struct {
Global Constraint Global Constraint
Nodes map[string]Constraint Nodes map[string]Constraint
Total Constraint Total Constraint
Category string
} }
type ListResource struct { type ListResource struct {
@@ -140,22 +71,112 @@ type ListResource struct {
Global []Match Global []Match
Nodes map[string][]Match Nodes map[string][]Match
Total []Match Total []Match
Category string
} }
type ResourceChart struct { type ResourceChart struct {
Type string Type string
Display bool Display bool
Name string Name string
Used int64 Used int64
Max int64 Max int64
Avail float64 Avail float64
Prefix string Prefix string
Unit string Unit string
ColorHex string
}
var Red = color.RGB{
R: 1,
G: 0,
B: 0,
}
var Green = color.RGB{
R: 0,
G: 1,
B: 0,
}
func HandleGETAccount(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
account, err := GetUserAccount(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{},
}
for _, r := range t.Total {
l.Resources = append(l.Resources, ResourceChart{
Type: t.Type,
Display: t.Display,
Name: r.Name,
Used: r.Used,
Max: r.Max,
Avail: float64(r.Avail), // usually an int
Unit: "",
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
})
}
account.Resources[category][resource] = l
}
}
}
c.HTML(http.StatusOK, "html/account.html", gin.H{
"global": common.Global,
"page": "account",
"account": account,
})
} else {
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
} }
func GetUserAccount(auth common.Auth) (Account, error) { func GetUserAccount(auth common.Auth) (Account, error) {
account := Account{ account := Account{
Resources: map[string]any{}, Resources: map[string]map[string]any{},
} }
ctx := common.RequestContext{ ctx := common.RequestContext{
@@ -164,51 +185,55 @@ func GetUserAccount(auth common.Auth) (Account, error) {
"PVEAuthCookie": auth.Token, "PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF, "CSRFPreventionToken": auth.CSRF,
}, },
Body: map[string]any{},
} }
// get user account basic data // get user account basic data
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx) body := map[string]any{}
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx, &body)
if err != nil { if err != nil {
return account, err return account, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res) return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
} }
err = mapstructure.Decode(ctx.Body, &account) err = mapstructure.Decode(body, &account)
if err != nil { if err != nil {
return account, err return account, err
} else { } else {
account.Username = auth.Username account.Username = auth.Username
} }
ctx.Body = map[string]any{} body = map[string]any{}
// get user resources // get user resources
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx) res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx, &body)
if err != nil { if err != nil {
return account, err return account, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res) return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
} }
resources := ctx.Body resources := body
ctx.Body = map[string]any{} body = map[string]any{}
// get resource meta data // get resource meta data
res, code, err = common.RequestGetAPI("/global/config/resources", ctx) res, code, err = common.RequestGetAPI("/global/config/resources", ctx, &body)
if err != nil { if err != nil {
return account, err return account, err
} }
if code != 200 { if code != 200 {
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res) return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
} }
meta := ctx.Body["resources"].(map[string]any) meta := body["resources"].(map[string]any)
// build each resource by its meta type // build each resource by its meta type
for k, v := range meta { for k, v := range meta {
m := v.(map[string]any) m := v.(map[string]any)
t := m["type"].(string) t := m["type"].(string)
r := resources[k].(map[string]any) 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" { if t == "numeric" {
n := NumericResource{} n := NumericResource{}
n.Type = t n.Type = t
@@ -217,7 +242,7 @@ func GetUserAccount(auth common.Auth) (Account, error) {
if err_m != nil || err_r != nil { if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
} }
account.Resources[k] = n account.Resources[category][k] = n
} else if t == "storage" { } else if t == "storage" {
n := StorageResource{} n := StorageResource{}
n.Type = t n.Type = t
@@ -226,7 +251,7 @@ func GetUserAccount(auth common.Auth) (Account, error) {
if err_m != nil || err_r != nil { if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
} }
account.Resources[k] = n account.Resources[category][k] = n
} else if t == "list" { } else if t == "list" {
n := ListResource{} n := ListResource{}
n.Type = t n.Type = t
@@ -235,29 +260,21 @@ func GetUserAccount(auth common.Auth) (Account, error) {
if err_m != nil || err_r != nil { if err_m != nil || err_r != nil {
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error()) return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
} }
account.Resources[k] = n account.Resources[category][k] = n
} }
} }
return account, nil return account, nil
} }
func FormatNumber(val int64, base int64) (float64, string) { // interpolate between min and max by normalized (0 - 1) val
valf := float64(val) func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
basef := float64(base) minhsl := min.ToHSL()
steps := 0 maxhsl := max.ToHSL()
for math.Abs(valf) > basef && steps < 4 { interphsl := color.HSL{
valf /= basef H: (1-val)*minhsl.H + (val)*maxhsl.H,
steps++ S: (1-val)*minhsl.S + (val)*maxhsl.S,
} L: (1-val)*minhsl.L + (val)*maxhsl.L,
if base == 1000 {
prefixes := []string{"", "K", "M", "G", "T"}
return valf, prefixes[steps]
} else if base == 1024 {
prefixes := []string{"", "Ki", "Mi", "Gi", "Ti"}
return valf, prefixes[steps]
} else {
return 0, ""
} }
return interphsl.ToRGB()
} }

110
app/routes/backups.go Normal file
View File

@@ -0,0 +1,110 @@
package routes
import (
"fmt"
"log"
"net/http"
"proxmoxaas-dashboard/app/common"
"time"
"github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2"
)
type InstanceBackup struct {
Volid string `json:"volid"`
Notes string `json:"notes"`
Size int64 `json:"size"`
CTime int64 `json:"ctime"`
SizeFormatted string
TimeFormatted string
}
func HandleGETBackups(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil {
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
}
config, err := GetInstanceConfig(vm_path, auth) // only used for the VM's name
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
}
log.Printf("%+v", backups)
c.HTML(http.StatusOK, "html/backups.html", gin.H{
"global": common.Global,
"page": "backups",
"backups": backups,
"config": config,
})
} else {
c.Redirect(http.StatusFound, "/login")
}
}
func HandleGETBackupsFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
vm_path, err := common.ExtractVMPath(c)
if err != nil {
common.HandleNonFatalError(c, err)
}
backups, err := GetInstanceBackups(vm_path, auth)
if err != nil {
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/backups-backups.go.tmpl", gin.H{
"backups": backups,
})
c.Status(http.StatusOK)
} else { // return 401
c.Status(http.StatusUnauthorized)
}
}
func GetInstanceBackups(vm common.VMPath, auth common.Auth) ([]InstanceBackup, error) {
backups := []InstanceBackup{}
path := fmt.Sprintf("/cluster/%s/%s/%s/backup", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{
Cookies: map[string]string{
"username": auth.Username,
"PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF,
},
}
body := []any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil {
return backups, err
}
if code != 200 {
return backups, fmt.Errorf("request to %s resulted in %+v", path, res)
}
err = mapstructure.Decode(body, &backups)
if err != nil {
return backups, err
}
for i := range backups {
size, prefix := common.FormatNumber(backups[i].Size, 1024)
backups[i].SizeFormatted = fmt.Sprintf("%.3g %sB", size, prefix)
t := time.Unix(backups[i].CTime, 0)
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
}
return backups, nil
}

View File

@@ -4,19 +4,52 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
fabric "proxmoxaas-fabric/app"
"slices" "slices"
"sort" "sort"
fabric "proxmoxaas-fabric/app"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
// imported types from fabric
type InstanceConfig struct {
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"`
// overrides
ProctypeSelect common.Select
}
type GlobalConfig struct {
CPU struct {
Whitelist bool
}
}
type UserConfigResources struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
}
}
type CPUConfig struct {
Name string
}
func HandleGETConfig(c *gin.Context) { func HandleGETConfig(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
} }
@@ -33,7 +66,7 @@ func HandleGETConfig(c *gin.Context) {
} }
} }
for i, cpu := range config.ProctypeSelect.Options { for i, cpu := range config.ProctypeSelect.Options {
if cpu.Value == config.Proctype { if cpu.Value == config.CPU {
config.ProctypeSelect.Options[i].Selected = true config.ProctypeSelect.Options[i].Selected = true
} }
} }
@@ -51,7 +84,7 @@ func HandleGETConfig(c *gin.Context) {
func HandleGETConfigVolumesFragment(c *gin.Context) { func HandleGETConfigVolumesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
} }
@@ -62,7 +95,7 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -74,7 +107,7 @@ func HandleGETConfigVolumesFragment(c *gin.Context) {
func HandleGETConfigNetsFragment(c *gin.Context) { func HandleGETConfigNetsFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
} }
@@ -85,7 +118,7 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -97,7 +130,7 @@ func HandleGETConfigNetsFragment(c *gin.Context) {
func HandleGETConfigDevicesFragment(c *gin.Context) { func HandleGETConfigDevicesFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
} }
@@ -108,7 +141,7 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -120,7 +153,7 @@ func HandleGETConfigDevicesFragment(c *gin.Context) {
func HandleGETConfigBootFragment(c *gin.Context) { func HandleGETConfigBootFragment(c *gin.Context) {
auth, err := common.GetAuth(c) auth, err := common.GetAuth(c)
if err == nil { if err == nil {
vm_path, err := ExtractVMPath(c) vm_path, err := common.ExtractVMPath(c)
if err != nil { if err != nil {
common.HandleNonFatalError(c, err) common.HandleNonFatalError(c, err)
} }
@@ -131,7 +164,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
} }
c.Header("Content-Type", "text/plain") c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{ common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.go.tmpl", gin.H{
"config": config, "config": config,
}) })
c.Status(http.StatusOK) c.Status(http.StatusOK)
@@ -140,45 +173,7 @@ func HandleGETConfigBootFragment(c *gin.Context) {
} }
} }
func ExtractVMPath(c *gin.Context) (VMPath, error) { func GetInstanceConfig(vm common.VMPath, auth common.Auth) (InstanceConfig, error) {
req_node := c.Query("node")
req_type := c.Query("type")
req_vmid := c.Query("vmid")
if req_node == "" || req_type == "" || req_vmid == "" {
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
}
vm_path := VMPath{
Node: req_node,
Type: req_type,
VMID: req_vmid,
}
return vm_path, nil
}
type VMPath struct {
Node string
Type string
VMID string
}
// imported types from fabric
type InstanceConfig struct {
Type fabric.InstanceType `json:"type"`
Name string `json:"name"`
Proctype string `json:"cpu"`
Cores uint64 `json:"cores"`
Memory uint64 `json:"memory"`
Swap uint64 `json:"swap"`
Volumes map[string]*fabric.Volume `json:"volumes"`
Nets map[string]*fabric.Net `json:"nets"`
Devices map[string]*fabric.Device `json:"devices"`
Boot fabric.BootOrder `json:"boot"`
// overrides
ProctypeSelect common.Select
}
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
config := InstanceConfig{} config := InstanceConfig{}
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID) path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
ctx := common.RequestContext{ ctx := common.RequestContext{
@@ -187,9 +182,9 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
"PVEAuthCookie": auth.Token, "PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF, "CSRFPreventionToken": auth.CSRF,
}, },
Body: map[string]any{},
} }
res, code, err := common.RequestGetAPI(path, ctx) body := map[string]any{}
res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return config, err return config, err
} }
@@ -197,7 +192,7 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
return config, fmt.Errorf("request to %s resulted in %+v", path, res) return config, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
err = mapstructure.Decode(ctx.Body, &config) err = mapstructure.Decode(body, &config)
if err != nil { if err != nil {
return config, err return config, err
} }
@@ -208,24 +203,7 @@ func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
return config, nil return config, nil
} }
type GlobalConfig struct { func GetCPUTypes(vm common.VMPath, auth common.Auth) (common.Select, error) {
CPU struct {
Whitelist bool
}
}
type UserConfig struct {
CPU struct {
Global []CPUConfig
Nodes map[string][]CPUConfig
}
}
type CPUConfig struct {
Name string
}
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
cputypes := common.Select{ cputypes := common.Select{
ID: "proctype", ID: "proctype",
Required: true, Required: true,
@@ -238,10 +216,10 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
"PVEAuthCookie": auth.Token, "PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF, "CSRFPreventionToken": auth.CSRF,
}, },
Body: map[string]any{},
} }
body := map[string]any{}
path := "/global/config/resources" path := "/global/config/resources"
res, code, err := common.RequestGetAPI(path, ctx) res, code, err := common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
@@ -249,23 +227,23 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
global := GlobalConfig{} global := GlobalConfig{}
err = mapstructure.Decode(ctx.Body["resources"], &global) err = mapstructure.Decode(body["resources"], &global)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
// get user resource config // get user resource config
ctx.Body = map[string]any{} body = map[string]any{}
path = "/user/config/resources" path = "/user/config/resources"
res, code, err = common.RequestGetAPI(path, ctx) res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
if code != 200 { if code != 200 {
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res) return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
} }
user := UserConfig{} user := UserConfigResources{}
err = mapstructure.Decode(ctx.Body, &user) err = mapstructure.Decode(body, &user)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
@@ -287,9 +265,9 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
} }
} else { // cpu is a blacklist } else { // cpu is a blacklist
// get the supported cpu types from the node // get the supported cpu types from the node
ctx.Body = map[string]any{} body = map[string]any{}
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node) path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
res, code, err = common.RequestGetAPI(path, ctx) res, code, err = common.RequestGetAPI(path, ctx, &body)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }
@@ -299,7 +277,7 @@ func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
supported := struct { supported := struct {
data []CPUConfig data []CPUConfig
}{} }{}
err = mapstructure.Decode(ctx.Body, supported) err = mapstructure.Decode(body, supported)
if err != nil { if err != nil {
return cputypes, err return cputypes, err
} }

View File

@@ -4,46 +4,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"proxmoxaas-dashboard/app/common" "proxmoxaas-dashboard/app/common"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
) )
func HandleGETIndex(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(c, err)
}
c.HTML(http.StatusOK, "html/index.html", gin.H{
"global": common.Global,
"page": "index",
"instances": instances,
})
} else { // return index without populating
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func HandleGETInstancesFragment(c *gin.Context) {
Auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(Auth)
if err != nil {
common.HandleNonFatalError(c, err)
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.frag", gin.H{
"instances": instances,
})
c.Status(http.StatusOK)
} else { // return 401
c.Status(http.StatusUnauthorized)
}
}
// used in constructing instance cards in index // used in constructing instance cards in index
type Node struct { type Node struct {
Node string `json:"node"` Node string `json:"node"`
@@ -52,12 +18,66 @@ type Node struct {
// used in constructing instance cards in index // used in constructing instance cards in index
type InstanceCard struct { type InstanceCard struct {
VMID uint VMID uint
Name string Name string
Type string Type string
Status string Status string
Node string Node string
NodeStatus string NodeStatus string
ConfigPath string
ConsolePath string
BackupsPath string
}
// used in retriving cluster tasks
type Task struct {
Type string
Node string
User string
ID string
VMID uint
Status string
EndTime uint
}
type InstanceStatus struct {
Status string
}
func HandleGETIndex(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(c, err)
}
page := gin.H{
"global": common.Global,
"page": "index",
"instances": instances,
}
c.HTML(http.StatusOK, "html/index.html", page)
} else { // return index without populating
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
}
}
func HandleGETInstancesFragment(c *gin.Context) {
auth, err := common.GetAuth(c)
if err == nil { // user should be authed, try to return index with population
instances, _, err := GetClusterResources(auth)
if err != nil {
common.HandleNonFatalError(c, err)
}
c.Header("Content-Type", "text/plain")
common.TMPL.ExecuteTemplate(c.Writer, "html/index-instances.go.tmpl", gin.H{
"instances": instances,
})
c.Status(http.StatusOK)
} else { // return 401
c.Status(http.StatusUnauthorized)
}
} }
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) { func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
@@ -66,21 +86,21 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
"PVEAuthCookie": auth.Token, "PVEAuthCookie": auth.Token,
"CSRFPreventionToken": auth.CSRF, "CSRFPreventionToken": auth.CSRF,
}, },
Body: map[string]any{},
} }
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx) body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx, &body)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/resources/ resulted in %+v", res) return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
} }
instances := map[uint]InstanceCard{} instances := map[uint]InstanceCard{}
nodes := map[string]Node{} nodes := map[string]Node{}
// if we successfully retrieved the resources, then process it and return index // if we successfully retrieved the resources, then process it and return index
for _, v := range ctx.Body["data"].([]any) { for _, v := range body["data"].([]any) {
m := v.(map[string]any) m := v.(map[string]any)
if m["type"] == "node" { if m["type"] == "node" {
node := Node{} node := Node{}
@@ -101,7 +121,83 @@ func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]No
for vmid, instance := range instances { for vmid, instance := range instances {
nodestatus := nodes[instance.Node].Status nodestatus := nodes[instance.Node].Status
instance.NodeStatus = nodestatus instance.NodeStatus = nodestatus
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
if instance.Type == "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" {
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)
}
instance.BackupsPath = fmt.Sprintf("backups?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
instances[vmid] = instance instances[vmid] = instance
} }
body = map[string]any{}
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx, &body)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
}
most_recent_task := map[uint]uint{}
expected_state := map[uint]string{}
for _, v := range body["data"].([]any) {
task := Task{}
err := mapstructure.Decode(v, &task)
if err != nil {
return nil, nil, err
}
x, err := strconv.Atoi(task.ID)
task.VMID = uint(x)
if err != nil {
return nil, nil, err
}
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
continue
} else if _, ok := instances[task.VMID]; !ok { // task does not refer to an instance in user's instances
continue
} else if instances[task.VMID].Node != task.Node { // task does not have the correct node reference (should not happen)
continue
} else if !(task.Type == "qmstart" || task.Type == "qmstop" || task.Type == "vzstart" || task.Type == "vzstop") { // task is not start/stop for qemu or lxc
continue
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
continue
} else { // recent task is a start or stop task for user instance which is running or "OK"
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
most_recent_task[task.VMID] = task.EndTime // update the most recent task
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"
}
}
}
}
for vmid, expected_state := range expected_state { // 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)
if err != nil {
return nil, nil, err
}
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
}
status := InstanceStatus{}
mapstructure.Decode(body["data"], &status)
instance.Status = status.Status
instances[vmid] = instance
}
}
return instances, nodes, nil return instances, nodes, nil
} }

View File

@@ -26,9 +26,10 @@ func GetLoginRealms() ([]Realm, error) {
ctx := common.RequestContext{ ctx := common.RequestContext{
Cookies: nil, Cookies: nil,
Body: map[string]any{}, //Body: map[string]any{},
} }
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx) body := map[string]any{}
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx, &body)
if err != nil { if err != nil {
return realms, err return realms, err
} }
@@ -36,7 +37,7 @@ func GetLoginRealms() ([]Realm, error) {
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res) return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
} }
for _, v := range ctx.Body["data"].([]any) { for _, v := range body["data"].([]any) {
v = v.(map[string]any) v = v.(map[string]any)
realm := Realm{} realm := Realm{}
err := mapstructure.Decode(v, &realm) err := mapstructure.Decode(v, &realm)

55
go.mod
View File

@@ -1,51 +1,56 @@
module proxmoxaas-dashboard module proxmoxaas-dashboard
go 1.24 go 1.25.1
require ( require (
github.com/gin-gonic/gin v1.10.0 github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1
github.com/go-viper/mapstructure/v2 v2.2.1 github.com/gin-gonic/gin v1.11.0
github.com/tdewolff/minify v2.3.6+incompatible github.com/go-viper/mapstructure/v2 v2.4.0
github.com/tdewolff/minify/v2 v2.24.3
proxmoxaas-fabric v0.0.0 proxmoxaas-fabric v0.0.0
) )
replace proxmoxaas-fabric => ../ProxmoxAAS-Fabric replace proxmoxaas-fabric => ./ProxmoxAAS-Fabric
require ( require (
github.com/buger/goterm v1.0.4 // indirect github.com/buger/goterm v1.0.4 // indirect
github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/bytedance/sonic v1.14.1 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/diskfs/go-diskfs v1.5.2 // 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/djherbis/times v1.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/goccy/go-json v0.10.5 // 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/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/copier v0.4.0 // indirect github.com/jinzhu/copier v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/luthermonson/go-proxmox v0.2.2 // indirect github.com/luthermonson/go-proxmox v0.2.3 // indirect
github.com/magefile/mage v1.15.0 // 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.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/tdewolff/minify/v2 v2.23.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/tdewolff/parse v2.3.4+incompatible // indirect github.com/quic-go/quic-go v0.55.0 // indirect
github.com/tdewolff/parse/v2 v2.7.23 // indirect github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tdewolff/test v1.0.11 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.16.0 // indirect go.uber.org/mock v0.6.0 // indirect
golang.org/x/crypto v0.37.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.39.0 // indirect golang.org/x/crypto v0.43.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/net v0.46.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect golang.org/x/sync v0.17.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
) )

View File

@@ -1,6 +1,6 @@
{ {
"name": "proxmoxaas-dashboard", "name": "proxmoxaas-dashboard",
"version": "0.0.1", "version": "1.0.0",
"description": "Front-end for ProxmoxAAS", "description": "Front-end for ProxmoxAAS",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,9 +1,12 @@
package main package main
import ( import (
"flag"
app "proxmoxaas-dashboard/app" app "proxmoxaas-dashboard/app"
) )
func main() { func main() {
app.Run() configPath := flag.String("config", "config.json", "path to config.json file")
flag.Parse()
app.Run(configPath)
} }

View File

@@ -76,7 +76,6 @@ input[type="radio"] {
} }
dialog { dialog {
max-width: calc(min(50%, 80ch)); max-width: calc(min(100% - 16px, 80ch));
background-color: var(--main-bg-color);
color: var(--main-text-color); color: var(--main-text-color);
} }

View File

@@ -100,7 +100,7 @@ img, svg {
color: var(--main-text-color) color: var(--main-text-color)
} }
hr, * { hr {
border-color: var(--main-text-color); border-color: var(--main-text-color);
} }
@@ -114,6 +114,12 @@ hr, * {
align-items: center; align-items: center;
} }
.column-reverse {
flex-direction: column-reverse;
row-gap: 10px;
align-items: center;
}
.wrap { .wrap {
flex-wrap: wrap; flex-wrap: wrap;
row-gap: 10px; row-gap: 10px;
@@ -156,18 +162,26 @@ hr, * {
} }
/* add hide large class similar to w3-hide-medium and w3-hide-small */ /* add hide large class similar to w3-hide-medium and w3-hide-small */
@media (width >=993px) { @media screen and (width >=993px) {
.w3-hide-large { .hide-large {display: none !important;}
display: none !important;
}
} }
/* fix edge case in w3-hide-medium where width between 992 and 993 */ /* fixes edge case in w3-hide-medium where width between 992 and 993 */
@media (width <=993px) and (width >=601px){ @media screen and (width <=993px) and (width >=601px){
.w3-hide-medium{display:none!important} .hide-large {display: none !important;}
.hide-medium {display:none !important}
} }
/* fix edge case in w3-hide-small when width between 600 and 601 */ /* fixes edge case in w3-hide-small when width between 600 and 601 */
@media (width <=601px) { @media screen and (width <=601px) {
.w3-hide-small{display:none!important} .hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
} }

View File

@@ -7,7 +7,7 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
<style> <style>
@media screen and (width >= 1264px){ @media screen and (width >= 1264px){
#resource-container { .resource-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, calc(100% / 6)); grid-template-columns: repeat(auto-fill, calc(100% / 6));
grid-gap: 0; grid-gap: 0;
@@ -15,7 +15,7 @@
} }
} }
@media screen and (width <= 1264px) and (width >= 680px) { @media screen and (width <= 1264px) and (width >= 680px) {
#resource-container { .resource-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, 200px); grid-template-columns: repeat(auto-fill, 200px);
grid-gap: 0; grid-gap: 0;
@@ -23,7 +23,7 @@
} }
} }
@media screen and (width <= 680px) { @media screen and (width <= 680px) {
#resource-container { .resource-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
@@ -54,24 +54,53 @@
</section> </section>
<section class="w3-card w3-padding"> <section class="w3-card w3-padding">
<h3>Cluster Resources</h3> <h3>Cluster Resources</h3>
<div id="resource-container"> <div>
{{range .account.Resources}} {{range $category, $v := .account.Resources}}
{{if .Display}} {{if ne $category ""}}
{{if eq .Type "numeric"}} <h4>{{$category}}</h4>
{{template "resource-chart" .}} {{end}}
{{end}} <div class="resource-container">
{{if eq .Type "storage"}} {{range $v}}
{{template "resource-chart" .}} {{if .Display}}
{{end}} {{if eq .Type "numeric"}}
{{if eq .Type "list"}}
{{range .Resources}}
{{template "resource-chart" .}} {{template "resource-chart" .}}
{{end}} {{end}}
{{if eq .Type "storage"}}
{{template "resource-chart" .}}
{{end}}
{{if eq .Type "list"}}
{{range .Resources}}
{{template "resource-chart" .}}
{{end}}
{{end}}
{{end}} {{end}}
{{end}} {{end}}
</div>
{{end}} {{end}}
</div> </div>
</section> </section>
</main> </main>
<template id="change-password-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Change Password
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,3 @@
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}

38
web/html/backups.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{template "head" .}}
<script src="scripts/backups.js" type="module"></script>
<link rel="modulepreload" href="scripts/utils.js">
<link rel="modulepreload" href="scripts/dialog.js">
<style>
</style>
</head>
<body>
<header>
{{template "header" .}}
</header>
<main>
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
<section class="w3-card w3-padding">
<div class="w3-row" style="border-bottom: 1px solid;">
<p class="w3-col l2 m4 s8">Time</p>
<p class="w3-col l6 m6 hide-small">Notes</p>
<p class="w3-col l2 hide-medium">Size</p>
<p class="w3-col l2 m2 s4">Actions</p>
</div>
<div id="backups-container">
{{range $i, $x := .backups}}
{{template "backup-card" $x}}
{{end}}
</div>
<div class="w3-container w3-center">
{{template "backups-add-backup" .}}
</div>
</section>
<div class="w3-container w3-center">
<a class="w3-button w3-margin" id="exit" href="index">EXIT</a>
</div>
</main>
</body>
</html>

View File

@@ -1 +0,0 @@
{{template "volumes" .config.Volumes}}

View File

@@ -0,0 +1 @@
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}

View File

@@ -25,8 +25,8 @@
</header> </header>
<main> <main>
<section> <section>
<h2><a href="index">Instances</a> / {{.config.Name}}</h2> <h2><a href="index">Instances</a> / {{.config.Name}} / Config</h2>
<form> <form id="config-form">
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
<legend>Resources</legend> <legend>Resources</legend>
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;"> <div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
@@ -43,18 +43,14 @@
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
<legend>Volumes</legend> <legend>Volumes</legend>
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;"> <div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
{{template "volumes" .config.Volumes}} {{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk"> <!--Add Disk Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Disk</span> {{template "volumes-add-disk" .}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg> <!--Add CD Button & Dialog Template-->
</button>
{{if eq .config.Type "VM"}} {{if eq .config.Type "VM"}}
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD"> {{template "volumes-add-cd"}}
<span class="large" style="margin: 0;">Mount CD</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
</button>
{{end}} {{end}}
</div> </div>
</fieldset> </fieldset>
@@ -64,10 +60,8 @@
{{template "nets" .config.Nets}} {{template "nets" .config.Nets}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface"> <!--Add Net Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Network</span> {{template "nets-add-net"}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Network Interface"><use href="images/actions/network/add.svg#symb"></use></svg>
</button>
</div> </div>
</fieldset> </fieldset>
{{if eq .config.Type "VM"}} {{if eq .config.Type "VM"}}
@@ -77,10 +71,8 @@
{{template "devices" .config.Devices}} {{template "devices" .config.Devices}}
</div> </div>
<div class="w3-container w3-center"> <div class="w3-container w3-center">
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device"> <!--Add Device Button & Dialog Template-->
<span class="large" style="margin: 0;">Add Device</span> {{template "devices-add-device"}}
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New PCIe Device"><use href="images/actions/device/add.svg#symb"></use></svg>
</button>
</div> </div>
</fieldset> </fieldset>
<fieldset class="w3-card w3-padding"> <fieldset class="w3-card w3-padding">
@@ -91,7 +83,7 @@
</fieldset> </fieldset>
{{end}} {{end}}
<div class="w3-container w3-center" id="form-actions"> <div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button> <button class="w3-button w3-margin" id="exit" type="submit">EXIT</button>
</div> </div>
</form> </form>
</section> </section>

View File

@@ -8,12 +8,6 @@
<link rel="modulepreload" href="scripts/dialog.js"> <link rel="modulepreload" href="scripts/dialog.js">
<link rel="modulepreload" href="scripts/clientsync.js"> <link rel="modulepreload" href="scripts/clientsync.js">
<style> <style>
#instance-container > div {
border-bottom: 1px solid white;
}
#instance-container > div:last-child {
border-bottom: none;
}
@media screen and (width >= 440px) { @media screen and (width >= 440px) {
#vm-search { #vm-search {
max-width: calc(100% - 10px - 152px); max-width: calc(100% - 10px - 152px);
@@ -24,6 +18,50 @@
max-width: calc(100% - 10px - 47px); max-width: calc(100% - 10px - 47px);
} }
} }
@media screen and (width >= 993px) {
#instance-table {
display: grid;
grid-template-columns: repeat(7, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 993px) and (width >= 601px){
#instance-table {
display: grid;
grid-template-columns: repeat(5, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 601px) and (width >= 440px){
#instance-table {
display: grid;
grid-template-columns: repeat(4, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
@media screen and (width <= 440px) {
#instance-table {
display: grid;
grid-template-columns: repeat(3, auto);
grid-column-gap: 1em;
grid-row-gap: 0.25em;
}
#instance-table-header, #instance-container, #instance-table instance-card {
display: contents;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -35,25 +73,72 @@
<h2>Instances</h2> <h2>Instances</h2>
<div class="w3-card w3-padding"> <div class="w3-card w3-padding">
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;"> <div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
<form id="vm-search" role="search" class="flex row nowrap"> <form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg> <svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name"> <input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
</form> </form>
<!--Add Instance Button & Dialog Template-->
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance"> <button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
<span class="large" style="margin: 0;">Create Instance</span> <span class="large" style="margin: 0;">Create Instance</span>
<svg class="small" style="height: 1lh; width: 1lh;" role="img" aria-label="Create New Instance"><use href="images/actions/instance/add.svg#symb"></use></svg> <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>
</button> </button>
<template id="create-instance-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create New Instance
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" selected-index="-1" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="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>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16" step="1" required>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</div> </div>
<div> <div id="instance-table">
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;"> <div id="instance-table-header">
<p class="w3-col l1 m2 w3-hide-small">ID</p> <p>ID</p>
<p class="w3-col l2 m3 w3-hide-small">Name</p> <p>Name</p>
<p class="w3-col l1 m2 w3-hide-small">Type</p> <p class="hide-tiny">Type</p>
<p class="w3-col l2 m3 w3-hide-small">Status</p> <p class="hide-small">Status</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Name</p> <p class="hide-medium">Host Name</p>
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Status</p> <p class="hide-medium">Host Status</p>
<p class="w3-col l2 m2 w3-hide-small">Actions</p> <p>Actions</p>
</div> </div>
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
<div id="instance-container"> <div id="instance-container">
{{range .instances}} {{range .instances}}
{{template "instance-card" .}} {{template "instance-card" .}}

View File

@@ -0,0 +1 @@
../../common/config.svg

View File

@@ -0,0 +1 @@
../../common/delete-active.svg

View File

@@ -0,0 +1 @@
<svg id="symb" aria-label="config" viewBox="4 2 17 19" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M10 8H5V3m.291 13.357a8 8 0 10.188-8.991" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 332 B

View File

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 631 B

View File

@@ -0,0 +1 @@
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="#808080"/></svg>

After

Width:  |  Height:  |  Size: 540 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="instance console" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg> <svg id="symb" role="img" aria-label="instance console" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 565 B

After

Width:  |  Height:  |  Size: 565 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg> <svg id="symb" role="img" aria-label="" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg>

Before

Width:  |  Height:  |  Size: 458 B

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="2.8 2.4 12 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg> <svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="4.2 2.4 9 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg>

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-25 -25 380 380"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg> <svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 350 350"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg>

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="config device" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg> <svg id="symb" role="img" aria-label="config" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="delete" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg id="symb" role="img" aria-label="delete" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 310 B

After

Width:  |  Height:  |  Size: 310 B

View File

@@ -1 +1 @@
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> <svg id="symb" role="img" aria-label="" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -1 +1 @@
<svg id="symb" xmlns="http://www.w3.org/2000/svg"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg> <svg id="symb" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg>

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -1,4 +1,4 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ /* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */ /* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0} html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px} .w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px} .w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px} .w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px} .w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word} .w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%} .w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-button:hover{color:#000!important;background-color:#ccc!important} .w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important} .w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important} .w3-hover-none:hover{box-shadow:none!important}
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
/* Colors */ /* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important} .w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important} .w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important} .w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important} .w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important} .w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
.w3-danger{color:#fff!important;background-color:#dd0000!important}
.w3-note{color:#000!important;background-color:#fff599!important}
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
.w3-warning{color:#000!important;background-color:#ffb305!important}
.w3-success{color:#fff!important;background-color:#008a00!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important} .w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important} .w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important} .w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
@@ -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-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-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-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}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1,24 +1,17 @@
import { dialog } from "./dialog.js";
import { requestAPI, setAppearance } from "./utils.js"; import { requestAPI, setAppearance } from "./utils.js";
import { dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init); window.addEventListener("DOMContentLoaded", init);
async function init () { async function init () {
setAppearance(); setAppearance();
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm); document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
} }
function handlePasswordChangeForm () { function handlePasswordChangeButton () {
const body = ` const template = document.querySelector("#change-password-dialog");
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form"> const d = dialog(template, async (result, form) => {
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password"required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
</form>
`;
const d = dialog("Change Password", body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") }); const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
if (result.status !== 200) { if (result.status !== 200) {
@@ -29,11 +22,9 @@ function handlePasswordChangeForm () {
const password = d.querySelector("#new-password"); const password = d.querySelector("#new-password");
const confirmPassword = d.querySelector("#confirm-password"); const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () { function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : ""); confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
} }
password.addEventListener("change", validatePassword); password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword); confirmPassword.addEventListener("keyup", validatePassword);
} }

149
web/scripts/backups.js Normal file
View File

@@ -0,0 +1,149 @@
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
import { alert, dialog } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
let node;
let type;
let vmid;
async function init () {
setAppearance();
const uriData = getURIData();
node = uriData.node;
type = uriData.type;
vmid = uriData.vmid;
document.querySelector("#backup-add").addEventListener("click", handleBackupAddButton);
}
class BackupCard extends HTMLElement {
shadowRoot = null;
constructor () {
super();
const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot;
const editButton = this.shadowRoot.querySelector("#edit-btn");
if (editButton.classList.contains("clickable")) {
editButton.onclick = this.handleEditButton.bind(this);
editButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.editButton();
}
};
}
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
}
const restoreButton = this.shadowRoot.querySelector("#restore-btn");
if (restoreButton.classList.contains("clickable")) {
restoreButton.onclick = this.handleRestoreButton.bind(this);
restoreButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleRestoreButton();
}
};
}
}
get volid () {
return this.dataset.volid;
}
async handleEditButton () {
const template = this.shadowRoot.querySelector("#edit-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid,
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to edit backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
async handleDeleteButton () {
const template = this.shadowRoot.querySelector("#delete-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
if (result.status !== 200) {
alert(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
async handleRestoreButton () {
const template = this.shadowRoot.querySelector("#restore-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
volid: this.volid
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to delete backup but got: ${result.error}`);
}
refreshBackups();
}
});
}
}
customElements.define("backup-card", BackupCard);
async function getBackupsFragment () {
return await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
}
async function refreshBackups () {
let backups = await getBackupsFragment();
if (backups.status !== 200) {
alert("Error fetching backups.");
}
else {
backups = backups.data;
const container = document.querySelector("#backups-container");
container.setHTMLUnsafe(backups);
}
}
async function handleBackupAddButton () {
const template = document.querySelector("#create-backup-dialog");
dialog(template, async (result, form) => {
if (result === "confirm") {
const body = {
notes: form.get("notes")
};
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
if (result.status !== 200) {
alert(`Attempted to create backup but got: ${result.error}`);
}
refreshBackups();
}
});
}

View File

@@ -19,7 +19,7 @@ async function init () {
initNetworks(); initNetworks();
initDevices(); initDevices();
document.querySelector("#exit").addEventListener("click", handleFormExit); document.querySelector("#config-form").addEventListener("submit", handleFormExit);
} }
class VolumeAction extends HTMLElement { class VolumeAction extends HTMLElement {
@@ -29,6 +29,7 @@ class VolumeAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "move") { if (this.dataset.type === "move") {
this.addEventListener("click", this.handleDiskMove); this.addEventListener("click", this.handleDiskMove);
} }
@@ -53,9 +54,7 @@ class VolumeAction extends HTMLElement {
async handleDiskDetach () { async handleDiskDetach () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
const header = `Detach ${disk}`; dialog(this.template, async (result, form) => {
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
@@ -69,20 +68,13 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskAttach () { async handleDiskAttach () {
const header = `Attach ${this.dataset.volume}`; dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label>
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const device = form.get("device"); const device = form.get("device");
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
source: this.dataset.volume.replace("unused", "") source: this.dataset.volume.replace("unused", ""),
mp: form.get("mp")
}; };
const prefix = type === "qemu" ? "scsi" : "mp"; const prefix = type === "qemu" ? "scsi" : "mp";
const disk = `${prefix}${device}`; const disk = `${prefix}${device}`;
@@ -97,15 +89,7 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskResize () { async handleDiskResize () {
const header = `Resize ${this.dataset.volume}`; dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="size-increment">Size Increment (GiB)</label>
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072">
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const disk = this.dataset.volume; const disk = this.dataset.volume;
this.setStatusLoading(); this.setStatusLoading();
@@ -123,25 +107,7 @@ class VolumeAction extends HTMLElement {
} }
async handleDiskMove () { async handleDiskMove () {
const content = type === "qemu" ? "images" : "rootdir"; const d = dialog(this.template, async (result, form) => {
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const header = `Move ${this.dataset.volume}`;
let options = "";
storage.data.forEach((element) => {
if (element.content.includes(content)) {
options += `<option value="${element.storage}">${element.storage}</option>"`;
}
});
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"><option hidden disabled selected value></option>${options}</select>`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
${select}
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const disk = this.dataset.volume; const disk = this.dataset.volume;
this.setStatusLoading(); this.setStatusLoading();
@@ -157,13 +123,20 @@ class VolumeAction extends HTMLElement {
refreshBoot(); refreshBoot();
} }
}); });
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
} }
async handleDiskDelete () { async handleDiskDelete () {
const disk = this.dataset.volume; const disk = this.dataset.volume;
const header = `Delete ${disk}`; dialog(this.template, async (result, form) => {
const body = `<p>Are you sure you want to <strong>delete</strong> disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE"); const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
@@ -201,26 +174,8 @@ async function refreshVolumes () {
} }
async function handleDiskAdd () { async function handleDiskAdd () {
const content = type === "qemu" ? "images" : "rootdir"; const template = document.querySelector("#add-disk-dialog");
const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); const d = dialog(template, async (result, form) => {
const header = "Create New Disk";
let options = "";
storage.data.forEach((element) => {
if (element.content.includes(content)) {
options += `<option value="${element.storage}">${element.storage}</option>"`;
}
});
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${options}</select>`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SCSI" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "30" : "255"}" value="0" required>
${select}
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
storage: form.get("storage-select"), storage: form.get("storage-select"),
@@ -237,19 +192,21 @@ async function handleDiskAdd () {
refreshBoot(); refreshBoot();
} }
}); });
const content = type === "qemu" ? "images" : "rootdir";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const select = d.querySelector("#storage-select");
storage.data.forEach((element) => {
if (element.content.includes(content)) {
select.add(new Option(element.storage));
}
select.selectedIndex = -1;
});
} }
async function handleCDAdd () { async function handleCDAdd () {
const isos = await requestAPI("/user/vm-isos", "GET"); const template = document.querySelector("#add-cd-dialog");
const header = "Mount a CDROM"; const d = dialog(template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required>
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
iso: form.get("iso-select") iso: form.get("iso-select")
@@ -264,12 +221,13 @@ async function handleCDAdd () {
} }
}); });
const isoSelect = d.querySelector("#iso-select"); const isos = await requestAPI("/user/vm-isos", "GET");
const select = d.querySelector("#iso-select");
for (const iso of isos) { for (const iso of isos) {
isoSelect.append(new Option(iso.name, iso.volid)); select.add(new Option(iso.name, iso.volid));
} }
isoSelect.selectedIndex = -1; select.selectedIndex = -1;
} }
class NetworkAction extends HTMLElement { class NetworkAction extends HTMLElement {
@@ -279,6 +237,7 @@ class NetworkAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") { if (this.dataset.type === "config") {
this.addEventListener("click", this.handleNetworkConfig); this.addEventListener("click", this.handleNetworkConfig);
} }
@@ -293,16 +252,9 @@ class NetworkAction extends HTMLElement {
} }
async handleNetworkConfig () { async handleNetworkConfig () {
const netID = this.dataset.network;
const netDetails = this.dataset.value; const netDetails = this.dataset.value;
const header = `Edit ${netID}`; const netID = this.dataset.network;
const body = ` const d = dialog(this.template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
@@ -323,9 +275,7 @@ class NetworkAction extends HTMLElement {
async handleNetworkDelete () { async handleNetworkDelete () {
const netID = this.dataset.network; const netID = this.dataset.network;
const header = `Delete ${netID}`; dialog(this.template, async (result, form) => {
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg"); setSVGSrc(document.querySelector(`svg[data-network="${netID}"]`), "images/status/loading.svg");
const net = `${netID}`; const net = `${netID}`;
@@ -361,17 +311,8 @@ async function refreshNetworks () {
} }
async function handleNetworkAdd () { async function handleNetworkAdd () {
const header = "Create Network Interface"; const template = document.querySelector("#add-net-dialog");
let body = ` dialog(template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
`;
if (type === "lxc") {
body += "<label for=\"name\">Interface Name</label><input type=\"text\" id=\"name\" name=\"name\" class=\"w3-input w3-border\">";
}
body += "</form>";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
rate: form.get("rate") rate: form.get("rate")
@@ -398,6 +339,7 @@ class DeviceAction extends HTMLElement {
super(); super();
const internals = this.attachInternals(); const internals = this.attachInternals();
this.shadowRoot = internals.shadowRoot; this.shadowRoot = internals.shadowRoot;
this.template = this.shadowRoot.querySelector("#dialog-template");
if (this.dataset.type === "config") { if (this.dataset.type === "config") {
this.addEventListener("click", this.handleDeviceConfig); this.addEventListener("click", this.handleDeviceConfig);
} }
@@ -415,14 +357,7 @@ class DeviceAction extends HTMLElement {
const deviceID = this.dataset.device; const deviceID = this.dataset.device;
const deviceDetails = this.dataset.value; const deviceDetails = this.dataset.value;
const deviceName = this.dataset.name; const deviceName = this.dataset.name;
const header = `Edit Expansion Card ${deviceID}`; const d = dialog(this.template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const body = { const body = {
@@ -448,9 +383,7 @@ class DeviceAction extends HTMLElement {
async handleDeviceDelete () { async handleDeviceDelete () {
const deviceID = this.dataset.device; const deviceID = this.dataset.device;
const header = `Remove Expansion Card ${deviceID}`; dialog(this.template, async (result, form) => {
const body = "";
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.setStatusLoading(); this.setStatusLoading();
const device = `${deviceID}`; const device = `${deviceID}`;
@@ -487,15 +420,8 @@ async function refreshDevices () {
} }
async function handleDeviceAdd () { async function handleDeviceAdd () {
const header = "Add Expansion Card"; const template = document.querySelector("#add-device-dialog");
const body = ` const d = dialog(template, async (result, form) => {
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="hostpci">Device Bus</label><input type="number" id="hostpci" name="hostpci" class="w3-input w3-border">
<label for="device">Device</label><select id="device" name="device" required></select>
<label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const hostpci = form.get("hostpci"); const hostpci = form.get("hostpci");
const body = { const body = {
@@ -523,14 +449,15 @@ async function refreshBoot () {
if (boot.status !== 200) { if (boot.status !== 200) {
alert("Error fetching instance boot order."); alert("Error fetching instance boot order.");
} }
else { else if (type === "qemu") {
boot = boot.data; boot = boot.data;
const order = document.querySelector("#boot-order"); const order = document.querySelector("#boot-order");
order.setHTMLUnsafe(boot); order.setHTMLUnsafe(boot);
} }
} }
async function handleFormExit () { async function handleFormExit (event) {
event.preventDefault();
const body = { const body = {
cores: document.querySelector("#cores").value, cores: document.querySelector("#cores").value,
memory: document.querySelector("#ram").value memory: document.querySelector("#ram").value

View File

@@ -1,31 +1,40 @@
export function dialog (header, body, onclose = async (result, form) => { }) { /**
const dialog = document.createElement("dialog"); * Spawn modal dialog from template node. Assumes the following structure:
dialog.innerHTML = ` * <template>
<p class="w3-large" id="prompt" style="text-align: center;"></p> * <dialog>
<div id="body"></div> * <p id="prompt"></p>
<div class="w3-center w3-container"> * <div id="body">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button> * <form id="form"> ... </form>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button> * </div>
</div> * <div id="controls">
`; * <button value="..." form="form"
dialog.className = "w3-container w3-card w3-border-0"; * <button value="..." form="form"
dialog.querySelector("#prompt").innerText = header; * ...
dialog.querySelector("#body").innerHTML = body; * </div>
* </dialog>
* </template>
* Where prompt is the modal dialog's prompt or header,
* body contains an optional form or other information,
* and controls contains a series of buttons which controls the form
*/
export function dialog (template, onclose = async (result, form) => { }) {
const dialog = template.content.querySelector("dialog").cloneNode(true);
document.body.append(dialog);
dialog.addEventListener("close", async () => { dialog.addEventListener("close", async () => {
const formElem = dialog.querySelector("form"); const formElem = dialog.querySelector("form");
const formData = formElem ? new FormData(formElem) : null; const formData = formElem ? new FormData(formElem) : null;
await onclose(dialog.returnValue, formData); await onclose(dialog.returnValue, formData);
formElem.reset();
dialog.close();
dialog.parentElement.removeChild(dialog); dialog.parentElement.removeChild(dialog);
}); });
if (!dialog.querySelector("form")) { if (!dialog.querySelector("form")) {
dialog.querySelector("#confirm").addEventListener("click", async (e) => { for (const control of dialog.querySelector("#controls").childNodes) {
e.preventDefault(); control.addEventListener("click", async (e) => {
dialog.close(e.target.value); e.preventDefault();
}); dialog.close(e.target.value);
dialog.querySelector("#cancel").addEventListener("click", async (e) => { });
e.preventDefault(); }
dialog.close(e.target.value);
});
} }
document.body.append(dialog); document.body.append(dialog);
dialog.showModal(); dialog.showModal();
@@ -33,23 +42,114 @@ export function dialog (header, body, onclose = async (result, form) => { }) {
} }
export function alert (message) { export function alert (message) {
const dialog = document.createElement("dialog"); const dialog = document.querySelector("#alert-dialog");
dialog.innerHTML = ` if (dialog == null) {
<form method="dialog"> const dialog = document.createElement("dialog");
<p class="w3-center" style="margin-bottom: 0px;">${message}</p> dialog.id = "alert-dialog";
<div class="w3-center"> dialog.innerHTML = `
<button class="w3-button w3-margin" id="submit">OK</button> <form method="dialog">
</div> <p class="w3-center" style="margin-bottom: 0px;">${message}</p>
</form> <div class="w3-center">
`; <button class="w3-button w3-margin" id="submit">OK</button>
dialog.className = "w3-container w3-card w3-border-0"; </div>
</form>
`;
dialog.className = "w3-container w3-card w3-border-0";
document.body.append(dialog);
dialog.showModal();
dialog.addEventListener("close", () => {
dialog.parentElement.removeChild(dialog);
});
return dialog;
}
else {
console.error("Attempted to create a new alert while one already exists!");
return null;
}
}
document.body.append(dialog); class ErrorDialog extends HTMLElement {
dialog.showModal(); shadowRoot = null;
dialog = null;
errors = null;
dialog.addEventListener("close", () => { constructor () {
dialog.parentElement.removeChild(dialog); super();
}); this.shadowRoot = this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<style>
#errors {
margin-bottom: 0px;
max-height: 20lh;
min-height: 20lh;
overflow-y: scroll;
}
#errors * {
margin: 0px;
}
</style>
<dialog class="w3-container w3-card w3-border-0">
<form method="dialog">
<p class="w3-large" id="prompt" style="text-align: center;">Error</p>
<div id="errors" class="flex column-reverse"></div>
<div class="w3-center" id="controls">
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
</div>
</form>
</dialog>
`;
this.dialog = this.shadowRoot.querySelector("dialog");
this.errors = this.shadowRoot.querySelector("#errors")
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
control.addEventListener("click", async (e) => {
e.preventDefault();
this.dialog.close(e.target.value);
});
}
this.dialog.addEventListener("close", () => {
if (this.dialog.returnValue == "ok") {}
else if (this.dialog.returnValue == "copy") {
let errors = ""
for (const error of this.errors.childNodes) {
errors += `${error.innerText}\n`
}
navigator.clipboard.writeText(errors)
}
this.parentElement.removeChild(this);
});
}
appendError (error) {
error = `${(new Date()).toUTCString()}: ${error}`;
const p = document.createElement("p");
p.innerText = error;
this.errors.appendChild(p);
}
showModal () {
this.dialog.showModal();
}
}
customElements.define("error-dialog", ErrorDialog);
export function error (message) {
let dialog = document.querySelector("error-dialog");
if (dialog == null) {
dialog = document.createElement("error-dialog");
document.body.append(dialog);
dialog.appendError(message);
dialog.showModal();
}
else {
dialog.appendError(message);
dialog.showModal();
}
return dialog; return dialog;
} }

View File

@@ -48,7 +48,7 @@ class DraggableContainer extends HTMLElement {
get value () { get value () {
const value = []; const value = [];
this.content.childNodes.forEach((element) => { this.content.querySelectorAll(".draggable-item").forEach((element) => {
if (element.dataset.value) { if (element.dataset.value) {
value.push(element.dataset.value); value.push(element.dataset.value);
} }

View File

@@ -1,4 +1,4 @@
import { requestPVE, requestAPI, goToPage, setAppearance, getSearchSettings, goToURL, requestDash } from "./utils.js"; import { requestPVE, requestAPI, setAppearance, getSearchSettings, requestDash, setSVGSrc, setSVGAlt } from "./utils.js";
import { alert, dialog } from "./dialog.js"; import { alert, dialog } from "./dialog.js";
import { setupClientSync } from "./clientsync.js"; import { setupClientSync } from "./clientsync.js";
import wfaInit from "../modules/wfa.js"; import wfaInit from "../modules/wfa.js";
@@ -11,7 +11,7 @@ async function init () {
wfaInit("modules/wfa.wasm"); wfaInit("modules/wfa.wasm");
initInstances(); initInstances();
document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd); document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
document.querySelector("#vm-search").addEventListener("input", sortInstances); document.querySelector("#vm-search").addEventListener("input", sortInstances);
setupClientSync(refreshInstances); setupClientSync(refreshInstances);
@@ -122,35 +122,46 @@ class InstanceCard extends HTMLElement {
const powerButton = this.shadowRoot.querySelector("#power-btn"); const powerButton = this.shadowRoot.querySelector("#power-btn");
if (powerButton.classList.contains("clickable")) { if (powerButton.classList.contains("clickable")) {
powerButton.onclick = this.handlePowerButton.bind(this); powerButton.onclick = this.handlePowerButton.bind(this);
} powerButton.onkeydown = (event) => {
if (event.key === "Enter") {
const configButton = this.shadowRoot.querySelector("#configure-btn"); event.preventDefault();
if (configButton.classList.contains("clickable")) { this.handlePowerButton();
configButton.onclick = this.handleConfigButton.bind(this); }
} };
const consoleButton = this.shadowRoot.querySelector("#console-btn");
if (consoleButton.classList.contains("clickable")) {
consoleButton.classList.add("clickable");
consoleButton.onclick = this.handleConsoleButton.bind(this);
} }
const deleteButton = this.shadowRoot.querySelector("#delete-btn"); const deleteButton = this.shadowRoot.querySelector("#delete-btn");
if (deleteButton.classList.contains("clickable")) { if (deleteButton.classList.contains("clickable")) {
deleteButton.onclick = this.handleDeleteButton.bind(this); deleteButton.onclick = this.handleDeleteButton.bind(this);
deleteButton.onkeydown = (event) => {
if (event.key === "Enter") {
event.preventDefault();
this.handleDeleteButton();
}
};
} }
} }
setStatusLoading () {
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, "");
}
async handlePowerButton () { async handlePowerButton () {
if (!this.actionLock) { if (!this.actionLock) {
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`; const template = this.shadowRoot.querySelector("#power-dialog");
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`; dialog(template, async (result, form) => {
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
const targetAction = this.status === "running" ? "stop" : "start"; const targetAction = this.status === "running" ? "stop" : "start";
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid }); const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
this.setStatusLoading();
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay)); const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
@@ -175,26 +186,10 @@ class InstanceCard extends HTMLElement {
} }
} }
handleConfigButton () {
if (!this.actionLock && this.status === "stopped") { // if the action lock is false, and the node is stopped, then navigate to the config page with the node info in the search query
goToPage("config", { node: this.node.name, type: this.type, vmid: this.vmid });
}
}
handleConsoleButton () {
if (!this.actionLock && this.status === "running") {
const data = { console: `${this.type === "qemu" ? "kvm" : "lxc"}`, vmid: this.vmid, vmname: this.name, node: this.node.name, resize: "off", cmd: "" };
data[`${this.type === "qemu" ? "novnc" : "xtermjs"}`] = 1;
goToURL(window.PVE, data, true);
}
}
handleDeleteButton () { handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") { if (!this.actionLock && this.status === "stopped") {
const header = `Delete VM ${this.vmid}`; const template = this.shadowRoot.querySelector("#delete-dialog");
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`; dialog(template, async (result, form) => {
dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
this.actionLock = true; this.actionLock = true;
@@ -224,7 +219,7 @@ async function getInstancesFragment () {
async function refreshInstances () { async function refreshInstances () {
let instances = await getInstancesFragment(); let instances = await getInstancesFragment();
if (instances.status !== 200) { if (instances.status !== 200) {
alert("Error fetching instances."); error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
} }
else { else {
instances = instances.data; instances = instances.data;
@@ -258,9 +253,9 @@ function sortInstances () {
if (substrInc) { if (substrInc) {
const substrStartIndex = item.indexOf(query); const substrStartIndex = item.indexOf(query);
const queryLength = query.length; const queryLength = query.length;
const remaining = item.length - substrInc - queryLength; const remaining = item.length - substrInc - queryLength + 1;
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`; const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
return { score: 1, alignment }; return { score: -1, alignment };
} }
else { else {
const alignment = `${"X".repeat(item.length)}`; const alignment = `${"X".repeat(item.length)}`;
@@ -277,8 +272,8 @@ function sortInstances () {
}; };
criteria = (item, query) => { criteria = (item, query) => {
// lower is better // lower is better
const { score, CIGAR } = global.wfAlign(query, item, penalties, true); const { score, CIGAR } = global.wfa.wfAlign(query, item, penalties, true);
const alignment = global.DecodeCIGAR(CIGAR); const alignment = global.wfa.DecodeCIGAR(CIGAR);
return { score: score / item.length, alignment }; return { score: score / item.length, alignment };
}; };
} }
@@ -313,47 +308,9 @@ function sortInstances () {
} }
} }
async function handleInstanceAdd () { async function handleInstanceAddButton () {
const header = "Create New Instance"; const template = document.querySelector("#create-instance-dialog");
const d = dialog(template, async (result, form) => {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" required>
<label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
<label for="memory">Memory (MiB)</label>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled>
<label class="container-specific none" for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" required disabled>
</form>
`;
const templates = await requestAPI("/user/ct-templates", "GET");
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") { if (result === "confirm") {
const body = { const body = {
name: form.get("name"), name: form.get("name"),
@@ -382,6 +339,8 @@ async function handleInstanceAdd () {
} }
}); });
const templates = await requestAPI("/user/ct-templates", "GET");
const typeSelect = d.querySelector("#type"); const typeSelect = d.querySelector("#type");
typeSelect.selectedIndex = -1; typeSelect.selectedIndex = -1;
typeSelect.addEventListener("change", () => { typeSelect.addEventListener("change", () => {
@@ -398,6 +357,10 @@ async function handleInstanceAdd () {
}); });
} }
}); });
d.querySelectorAll(".container-specific").forEach((element) => {
element.classList.add("none");
element.disabled = true;
});
const rootfsContent = "rootdir"; const rootfsContent = "rootdir";
const rootfsStorage = d.querySelector("#rootfs-storage"); const rootfsStorage = d.querySelector("#rootfs-storage");
@@ -407,6 +370,7 @@ async function handleInstanceAdd () {
const userCluster = await requestAPI("/user/config/cluster", "GET"); const userCluster = await requestAPI("/user/config/cluster", "GET");
const nodeSelect = d.querySelector("#node"); const nodeSelect = d.querySelector("#node");
nodeSelect.innerHTML = "";
const clusterNodes = await requestPVE("/nodes", "GET"); const clusterNodes = await requestPVE("/nodes", "GET");
const allowedNodes = Object.keys(userCluster.nodes); const allowedNodes = Object.keys(userCluster.nodes);
clusterNodes.data.forEach((element) => { clusterNodes.data.forEach((element) => {
@@ -418,6 +382,7 @@ async function handleInstanceAdd () {
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value; const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET"); const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
rootfsStorage.innerHTML = "";
storage.data.forEach((element) => { storage.data.forEach((element) => {
if (element.content.includes(rootfsContent)) { if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage)); rootfsStorage.add(new Option(element.storage));
@@ -447,6 +412,7 @@ async function handleInstanceAdd () {
// add user pools to selector // add user pools to selector
const poolSelect = d.querySelector("#pool"); const poolSelect = d.querySelector("#pool");
poolSelect.innerHTML = "";
const userPools = Object.keys(userCluster.pools); const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => { userPools.forEach((element) => {
poolSelect.add(new Option(element)); poolSelect.add(new Option(element));
@@ -460,13 +426,14 @@ async function handleInstanceAdd () {
} }
templateImage.selectedIndex = -1; templateImage.selectedIndex = -1;
// setup custom password checker for containers
const password = d.querySelector("#password"); const password = d.querySelector("#password");
const confirmPassword = d.querySelector("#confirm-password"); const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () { function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : ""); confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
} }
password.addEventListener("change", validatePassword); password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword); confirmPassword.addEventListener("keyup", validatePassword);
d.showModal();
} }

View File

@@ -81,7 +81,11 @@ async function request (url, content) {
const response = await fetch(url, content); const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type"); const contentType = response.headers.get("Content-Type");
let data = null; let data = null;
if (contentType.includes("application/json")) {
if (contentType === null) {
data = {};
}
else if (contentType.includes("application/json")) {
data = await response.json(); data = await response.json();
data.status = response.status; data.status = response.status;
} }
@@ -94,8 +98,9 @@ async function request (url, content) {
data.status = response.status; data.status = response.status;
} }
else { else {
data = response; data = {};
} }
if (!response.ok) { if (!response.ok) {
return { status: response.status, error: data ? data.error : response.status }; return { status: response.status, error: data ? data.error : response.status };
} }
@@ -114,20 +119,6 @@ export function goToPage (page, data = null) {
window.location.href = `${page}${data ? "?" : ""}${params}`; window.location.href = `${page}${data ? "?" : ""}${params}`;
} }
export function goToURL (href, data = {}, newwindow = false) {
const url = new URL(href);
for (const k in data) {
url.searchParams.append(k, data[k]);
}
if (newwindow) {
window.open(url, document.title, "height=480,width=848");
}
else {
window.location.assign(url.toString());
}
}
export function getURIData () { export function getURIData () {
const url = new URL(window.location.href); const url = new URL(window.location.href);
return Object.fromEntries(url.searchParams); return Object.fromEntries(url.searchParams);

View File

@@ -0,0 +1,125 @@
{{define "backup-card"}}
<backup-card data-volid="{{.Volid}}">
<template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<style>
* {
margin: 0;
}
a {
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
svg {
height: 1em;
width: 1em;
}
</style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
<p class="w3-col l2 m4 s8">{{.TimeFormatted}}</p>
<p class="w3-col l6 m6 hide-small">{{.Notes}}</p>
<p class="w3-col l2 hide-medium">{{.SizeFormatted}}</p>
<div class="w3-col l2 m2 s4 flex row nowrap" style="height: 1lh;">
<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>
</div>
</div>
<template id="edit-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
<label for="rate">Notes</label>
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<template id="delete-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>
Are you sure you want to <strong>delete</strong> the backup made at {{.TimeFormatted}}?
</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<template id="restore-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Restore From Backup?
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>
Are you sure you want to <strong>restore</strong> from the backup made at {{.TimeFormatted}}?
<br>
<br>
<strong>WARNING: Restoring from a backup will WIPE disks NOT contained in the backup!!!</strong>
</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template>
</backup-card>
{{end}}
{{define "backups-add-backup"}}
<button type="button" id="backup-add" class="w3-button" aria-label="Create Backup">
<span class="large" style="margin: 0;">Create Backup</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Create Backup"><use href="images/actions/network/add.svg#symb"></use></svg>
</button>
<template id="create-backup-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create Backup
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto;" id="form">
<label for="rate">Notes</label>
<textarea id="notes" name="notes" class="w3-input w3-border">{{.Notes}}</textarea>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}

View File

@@ -4,7 +4,6 @@
<title>{{.global.Organization}} - dashboard</title> <title>{{.global.Organization}} - dashboard</title>
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<script> <script>
window.PVE = "{{.global.PVE}}"; window.PVE = "{{.global.PVE}}";
window.API = "{{.global.API}}"; window.API = "{{.global.API}}";

View File

@@ -27,22 +27,75 @@
{{end}} {{end}}
{{define "volumes"}} {{define "volumes"}}
{{range $k,$v := .}} {{range $k,$v := .Volumes}}
{{if eq $v.Type "rootfs"}} {{if eq $v.Type "rootfs"}}
{{ template "volume-rootfs" Map "Name" $k "Volume" $v}} {{ template "volume-rootfs" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "mp"}} {{else if eq $v.Type "mp"}}
{{ template "volume-mp" Map "Name" $k "Volume" $v}} {{ template "volume-mp" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "ide"}} {{else if eq $v.Type "ide"}}
{{ template "volume-ide" Map "Name" $k "Volume" $v}} {{ template "volume-ide" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}} {{else if or (eq $v.Type "scsi") (eq $v.Type "sata")}}
{{ template "volume-scsi" Map "Name" $k "Volume" $v}} {{ template "volume-scsi" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else if eq $v.Type "unused"}} {{else if eq $v.Type "unused"}}
{{ template "volume-unused" Map "Name" $k "Volume" $v}} {{ template "volume-unused" Map "Name" $k "Volume" $v "InstanceType" $.InstanceType}}
{{else}} {{else}}
{{end}} {{end}}
{{end}} {{end}}
{{end}} {{end}}
{{define "volumes-add-disk"}}
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
<span class="large" style="margin: 0;">Add Disk</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg>
</button>
<template id="add-disk-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create New Disk
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
{{if eq .config.Type "VM"}}
<label for="device">SCSI</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="30" value="0" required>
{{else}}
<label for="device">MP</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="255" value="0" required>
{{end}}
<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required></select>
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "volumes-add-cd"}}
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD">
<span class="large" style="margin: 0;">Mount CD</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
</button>
<template id="add-cd-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Mount a CDROM
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required>
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "volume-rootfs"}} {{define "volume-rootfs"}}
<svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg> <svg data-volume={{.Name}} xmlns="http://www.w3.org/2000/svg" aria-label="Drive"><use href="images/resources/drive.svg#symb"></svg>
<p>{{.Name}}</p> <p>{{.Name}}</p>
@@ -108,6 +161,23 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg> <svg class="clickable" aria-label="Move {{.Name}}"><use href="images/actions/disk/move-active.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Move {{.Name}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"></select>
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -126,6 +196,23 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg> <svg class="clickable" aria-label="Resize {{.Name}}"><use href="images/actions/disk/resize-active.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Resize {{.Name}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="size-increment">Size Increment (GiB)</label>
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072">
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -144,6 +231,22 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg> <svg class="clickable" aria-label="Delete {{.Name}}"><use href="images/actions/disk/delete-active.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.Name}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>Are you sure you want to <strong>delete</strong> disk {{.Name}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -162,6 +265,30 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg> <svg class="clickable" aria-label="Attach {{.Name}}"><use href="images/actions/disk/attach.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Attach {{.Name}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
{{if eq .InstanceType "VM"}}
<label for="device">SCSI</label>
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="30" required>
{{else}}
<label for="device">MP</label>
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="255" required>
<label for="device">Path</label>
<input class="w3-input w3-border" name="mp" id="mp" required>
{{end}}
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -171,6 +298,22 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg> <svg class="clickable" aria-label="Detach {{.Name}}"><use href="images/actions/disk/detach.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Detach {{.Name}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>Are you sure you want to detach disk {{.Name}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</volume-action> </volume-action>
{{end}} {{end}}
@@ -190,6 +333,33 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "nets-add-net"}}
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
<span class="large" style="margin: 0;">Add Network</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Network Interface"><use href="images/actions/network/add.svg#symb"></use></svg>
</button>
<template id="add-net-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Create Network Interface
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
{{if eq .config.Type "CT"}}
<label for="name">Interface Name</label><input type="text" id="name" name="name" class="w3-input w3-border">
{{end}}
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "net"}} {{define "net"}}
<svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg> <svg data-network="{{.Net_ID}}" aria-label="Net {{.Net_ID}}"><use href="images/resources/network.svg#symb"></svg>
<p>{{.Net_ID}}</p> <p>{{.Net_ID}}</p>
@@ -199,12 +369,44 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg> <svg class="clickable" aria-label="Configure Net {{.Net_ID}}"><use href="images/actions/network/config.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit {{.Net_ID}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</network-action> </network-action>
<network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}"> <network-action data-type="delete" data-network="{{.Net_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg> <svg class="clickable" aria-label="Delete Net {{.Net_ID}}"><use href="images/actions/network/delete-active.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.Net_ID}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>Are you sure you want to <strong>delete</strong> {{.Net_ID}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</network-action> </network-action>
</div> </div>
@@ -216,6 +418,31 @@
{{end}} {{end}}
{{end}} {{end}}
{{define "devices-add-device"}}
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
<span class="large" style="margin: 0;">Add Device</span>
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New PCIe Device"><use href="images/actions/device/add.svg#symb"></use></svg>
</button>
<template id="add-device-dialog">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Add Expansion Card
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="hostpci">Device Bus</label><input type="number" id="hostpci" name="hostpci" class="w3-input w3-border">
<label for="device">Device</label><select id="device" name="device" required></select>
<label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
{{end}}
{{define "device"}} {{define "device"}}
<svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg> <svg data-device="{{.Device_ID}}" aria-label="Device {{.Device_ID}}"><use href="images/resources/device.svg#symb"></svg>
<p>{{.Device_ID}}</p> <p>{{.Device_ID}}</p>
@@ -225,63 +452,86 @@
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg> <svg class="clickable" aria-label="Configure Device {{.Device_ID}}"><use href="images/actions/device/config.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Edit Expansion Card {{.Device_ID}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</device-action> </device-action>
<device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}"> <device-action data-type="delete" data-device="{{.Device_ID}}" data-value="{{.Value}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg> <svg class="clickable" aria-label="Delete Device {{.Device_ID}}"><use href="images/actions/device/delete-active.svg#symb"></svg>
<template id="dialog-template">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
remove Expansion Card {{.Device_ID}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>Are you sure you want to <strong>remove</strong> {{.Device_ID}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</device-action> </device-action>
</div> </div>
{{end}} {{end}}
{{define "boot"}} {{define "boot"}}
<draggable-container id="enabled" data-group="boot"> {{template "boot-container" Map "ID" "enabled" "Name" "Enabled" "Targets" .Enabled}}
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Enabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Enabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
<hr style="padding: 0; margin: 0;"> <hr style="padding: 0; margin: 0;">
<draggable-container id="disabled" data-group="boot"> {{template "boot-container" Map "ID" "disabled" "Name" "Disabled" "Targets" .Disabled}}
<template shadowrootmode="open">
{{template "boot-style"}}
<label>Disabled</label>
<div id="wrapper" style="padding-bottom: 1em;">
{{range .Disabled}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
{{end}} {{end}}
{{define "boot-style"}} {{define "boot-container"}}
<style> <draggable-container id="{{.ID}}" data-group="boot">
div.draggable-item.ghost { <template shadowrootmode="open">
border: 1px dashed var(--main-text-color); <style>
border-radius: 5px; * {
margin: -1px; box-sizing: border-box;
} }
div.draggable-item { div.draggable-item.ghost {
cursor: grab; border: 1px dashed var(--main-text-color);
} border-radius: 5px;
div.draggable-item svg { margin: -1px;
height: 1em; }
width: 1em; div.draggable-item {
} cursor: grab;
* { }
-webkit-box-sizing: border-box; div.draggable-item svg {
-moz-box-sizing: border-box; height: 1em;
box-sizing: border-box; width: 1em;
} }
</style> #wrapper {
padding-bottom: 1em;
}
</style>
<label>{{.Name}}</label>
<div id="wrapper">
{{range .Targets}}
{{template "boot-target" .}}
{{end}}
</div>
</template>
</draggable-container>
{{end}} {{end}}
{{define "boot-target"}} {{define "boot-target"}}

View File

@@ -2,61 +2,159 @@
<instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}"> <instance-card data-type="{{.Type}}" data-status="{{.Status}}" data-vmid="{{.VMID}}" data-name="{{.Name}}" data-node="{{.Node}}" data-nodestatus="{{.NodeStatus}}">
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0;
width: fit-content;
}
a, svg {
line-height: 1em;
height: 1em;
width: 1em;
margin: 0px;
padding: 0px;
}
#instance-name {
overflow-x: hidden;
min-width: 0;
width: auto;
white-space: nowrap;
}
.flex { /* needed for some reason to avoid a flickering issue on chrome ONLY */
display: flex;
}
.row { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-direction: row;
column-gap: 10px;
align-items: center;
}
.nowrap { /* needed for some reason to avoid a flickering issue on chrome ONLY */
flex-wrap: nowrap;
}
@media screen and (width >=993px) {
.hide-large {display: none !important;}
}
@media screen and (width <=993px) and (width >=601px){
.hide-large {display: none !important;}
.hide-medium {display:none !important}
}
@media screen and (width <=601px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
}
@media screen and (width <= 440px) {
.hide-large {display: none !important;}
.hide-medium {display:none !important}
.hide-small {display:none !important}
.hide-tiny { display: none !important;}
} }
</style> </style>
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;"> <p>{{.VMID}}</p>
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;"> <p id="instance-name">{{.Name}}</p>
<p class="w3-col l1 m2 s6">{{.VMID}}</p> <p class="hide-small">{{.Type}}</p>
<p class="w3-col l2 m3 s6" id="instance-name">{{.Name}}</p> <div class="flex row nowrap hide-tiny">
<p class="w3-col l1 m2 w3-hide-small">{{.Type}}</p> {{if eq .Status "running"}}
<div class="w3-col l2 m3 s6 flex row nowrap"> <svg id="status" aria-label="instance is running"><use href="images/status/active.svg#symb"></svg>
{{if eq .Status "running"}} {{else if eq .Status "stopped"}}
<svg aria-label="instance is running"><use href="images/status/active.svg#symb"></svg> <svg id="status" aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg>
{{else if eq .Status "stopped"}} {{else if eq .Status "loading"}}
<svg aria-label="instance is stopped"><use href="images/status/inactive.svg#symb"></svg> <svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
{{else if eq .Status "loading"}} {{else}}
<svg aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg> <svg id="status" aria-label="instance is loading"><use href="images/status/loading.svg#symb"></svg>
{{else}} {{end}}
{{end}} <p>{{.Status}}</p>
<p>{{.Status}}</p>
</div>
<p class="w3-col l2 w3-hide-medium w3-hide-small">{{.Node}}</p>
<div class="w3-col l2 w3-hide-medium w3-hide-small flex row nowrap">
{{if eq .NodeStatus "online"}}
<svg aria-label="node is online"><use href="images/status/active.svg#symb"></svg>
{{else if eq .NodeStatus "offline"}}
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
{{else if eq .NodeStatus "unknown"}}
<svg aria-label="node is offline"><use href="images/status/inactive.svg#symb"></svg>
{{else}}
{{end}}
<p>{{.NodeStatus}}</p>
</div>
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
{{if and (eq .NodeStatus "online") (eq .Status "running")}}
<svg id="power-btn" class="clickable" aria-label="shutdown instance"><use href="images/actions/instance/stop.svg#symb"></svg>
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<svg id="console-btn" class="clickable" aria-label="open console"><use href="images/actions/instance/console-active.svg#symb"></svg>
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
{{else if and (eq .NodeStatus "online") (eq .Status "stopped")}}
<svg id="power-btn" class="clickable" aria-label="start instance"><use href="images/actions/instance/start.svg#symb"></svg>
<svg id="configure-btn" class="clickable" aria-label="change configuration"><use href="images/actions/instance/config-active.svg#symb"></svg>
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" class="clickable" aria-label="delete instance"><use href="images/actions/instance/delete-active.svg#symb"></svg>
{{else if and (eq .NodeStatus "online") (eq .Status "loading")}}
<svg id="power-btn" aria-label=""><use href="images/actions/instance/loading.svg#symb"></svg>
<svg id="configure-btn" aria-label=""><use href="images/actions/instance/config-inactive.svg#symb"></svg>
<svg id="console-btn" aria-label=""><use href="images/actions/instance/console-inactive.svg#symb"></svg>
<svg id="delete-btn" aria-label=""><use href="images/actions/instance/delete-inactive.svg#symb"></svg>
{{else}}
{{end}}
</div>
</div> </div>
<p class="hide-medium">{{.Node}}</p>
<div class="flex row nowrap hide-medium">
{{if eq .NodeStatus "online"}}
<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="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>
<template id="power-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
{{if eq .Status "running"}}
Stop {{.VMID}}
{{else if eq .Status "stopped"}}
Start {{.VMID}}
{{else}}
{{end}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
{{if eq .Status "running"}}
<p>Are you sure you want to <strong>stop</strong> {{.VMID}}?</p>
{{else if eq .Status "stopped"}}
<p>Are you sure you want to <strong>start</strong> {{.VMID}}?</p>
{{else}}
{{end}}
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
<template id="delete-dialog">
<link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/form.css">
<dialog class="w3-container w3-card w3-border-0">
<p class="w3-large" id="prompt" style="text-align: center;">
Delete {{.VMID}}
</p>
<div id="body">
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<p>Are you sure you want to <strong>delete</strong> {{.VMID}}?</p>
</form>
</div>
<div id="controls" class="w3-center w3-container">
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
</dialog>
</template>
</template> </template>
</instance-card> </instance-card>
{{end}} {{end}}

View File

@@ -2,7 +2,6 @@
<resource-chart> <resource-chart>
<template shadowrootmode="open"> <template shadowrootmode="open">
<link rel="stylesheet" href="modules/w3.css"> <link rel="stylesheet" href="modules/w3.css">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
<style> <style>
* { * {
@@ -13,15 +12,13 @@
margin: 0; margin: 0;
width: 100%; width: 100%;
height: fit-content; height: fit-content;
padding: 10px 10px 10px 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
} }
progress { progress {
width: 100%; width: 100%;
border: 0; border: 0;
height: 1em; height: 1em;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none; appearance: none;
} }
#caption { #caption {
@@ -30,14 +27,23 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
progress::-moz-progress-bar {
background: #{{.ColorHex}};
}
progress::-webkit-progress-bar {
background: var(--main-text-color);
}
progress::-webkit-progress-value {
background: #{{.ColorHex}};
}
</style> </style>
<div id="container"> <div id="container">
<progress value="{{.Used}}" max="{{.Max}}"></progress> <progress value="{{.Used}}" max="{{.Max}}" id="resource"></progress>
<p id="caption"> <label id="caption" for="resource">
<span>{{.Name}}</span> <span>{{.Name}}</span>
<span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span> <span>{{printf "%g" .Avail}} {{.Prefix}}{{.Unit}} Avaliable</span>
</p> </label>
</div> </div>
</template> </template>
</resource-chart> </resource-chart>
{{end}}- {{end}}