Compare commits
70 Commits
wfa-fuzzy-
...
instance-p
Author | SHA1 | Date | |
---|---|---|---|
989f59223a | |||
233d4255ba | |||
2b5c1bbf11 | |||
800ad7cd60 | |||
bb0d363298 | |||
8530b50f9a | |||
099f9c4e42 | |||
844cf4dfb9 | |||
02baa71622 | |||
38849c1c41 | |||
455ed353c6 | |||
13339cc56d | |||
bd0387976f | |||
add58d849e | |||
a58620eacb | |||
79567ea58e | |||
b8ebbf6c3d | |||
75330e8a59 | |||
6e4f2505a2 | |||
6fdc647413 | |||
f37f89d5c7 | |||
59d12d2e99 | |||
8b508d14cc | |||
247f602359 | |||
209a8cc071 | |||
9bd1fc8321 | |||
323129185b | |||
7f7e17e8ee | |||
73df998985 | |||
cfceb32134 | |||
84cbe0e45d | |||
a80492605d | |||
e73e494f65 | |||
510da85c02 | |||
ce2e8867f3 | |||
2170077522 | |||
c57e2fe19f | |||
48afad63e7 | |||
e2706ce731 | |||
7c53cf5efe | |||
68fafc1e37 | |||
9b3d9767e1 | |||
e2f9f8e352 | |||
dc2dcf85cd | |||
7d64715c30 | |||
f09ca0507e | |||
39822819cc | |||
912cee33c7 | |||
746136724f | |||
07bf4b0967 | |||
4b57c5c8e8 | |||
7ec72a7443 | |||
58007a38b2 | |||
d574df8410 | |||
e1ef7998f6 | |||
3a8d9df103 | |||
b4fd1278cd | |||
c11c3579f3 | |||
9a6a9e4473 | |||
4a69c58125 | |||
bb3edcb005 | |||
77a060db6b | |||
6de16ec656 | |||
db06522d15 | |||
019a3a4455 | |||
f58abd4d4d | |||
d3281fc27f | |||
d2cfbdb2a2 | |||
53d820b685 | |||
bbfc77dc04 |
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
vars.js
|
||||
**/config.json
|
||||
**/package-lock.json
|
||||
**/node_modules
|
||||
dist/*
|
||||
go.sum
|
15
Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
.PHONY: build test clean
|
||||
|
||||
build: clean
|
||||
@echo "======================== Building Binary ======================="
|
||||
# resolve symbolic links in web by copying it into dist/web/
|
||||
cp -rL web/ dist/web/
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -v -o dist/ .
|
||||
|
||||
test: clean
|
||||
go run .
|
||||
|
||||
clean:
|
||||
@echo "======================== Cleaning Project ======================"
|
||||
go clean
|
||||
rm -rf dist/*
|
82
README.md
@@ -9,32 +9,70 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
|
||||
- Remote console
|
||||
- Extended account management
|
||||
|
||||
## Prerequisites
|
||||
- Proxmox VE Cluster (v7.0+)
|
||||
- Reverse proxy server which can proxy the dashboard and API
|
||||
- FQDN
|
||||
- Web server to host the ProxmoxAAS Dashboard (ie Apache2)
|
||||
# Installation
|
||||
|
||||
## Notes
|
||||
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS Dashboard. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `<FQDN>` in some of the configs.
|
||||
## ProxmoxAAS System Installation Overview
|
||||
|
||||
The ProxmoxAAS project is large and is split into multiple components. There are three required components, the Dashboard, API, and Fabric. There is also an optional LDAP component for organizations that want to use LDAP as their authentication backend. The instalation order should start with the Dashboard and then proceed to the other backend components. This will require some foresight into the setup process.
|
||||
|
||||
The supported setup is to use a reverse proxy to serve both the original Proxmox web interface and ProxmoxAAS components. It is possible other setups can work. Rather than provide specific steps to duplicate a certain setup, the steps included are intended as a guideline of steps required for proper function in most setups. Consequently, the examples provided are only to highlight key settings and do not represent complete working configurations. The instructions also assume you have your own domain name which will substitute `domain.net` in some of the configs.
|
||||
|
||||
We will assume different hosts for each component which are accessible by unique host names and public URL addresses. Below is a table of references in the setup instructions across all components. You will need to substitute real addresses and hostnames for these in configurations.
|
||||
|
||||
| component | internal address | external address |
|
||||
| --- | --- | --- |
|
||||
| Proxmox VE API | pve.local | pve.domain.net |
|
||||
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net |
|
||||
| ProxmoxAAS-API | api.local | paas.domain.net/api/ |
|
||||
| ProxmoxAAS-Fabric | fabric.local | N/A |
|
||||
| ProxmoxAAS-LDAP | ldap.local | N/A|
|
||||
|
||||
## Prerequisites - Dashboard
|
||||
- Proxmox VE Cluster (v7.0+)
|
||||
- Reverse proxy server which can proxy the dashboard, API, and Fabric
|
||||
- FQDN
|
||||
|
||||
## Installation - Dashboard
|
||||
1. Install Apache2 or another HTTP server onto a container or vm, which will be `Dashboard Host`
|
||||
2. Clone this repo onto `Dashboard Host`, the default location for web root is `/var/www/html/`
|
||||
4. Configure Apache2 to serve the app at port 80 by adding the file `dashboard.conf` to `/etc/apache2/sites-avaliable/` with at least the following:
|
||||
```
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot /var/www/html/ProxmoxAAS-Dashboard/
|
||||
</VirtualHost>
|
||||
```
|
||||
4. Enable the site by running `a2ensite dashboard`
|
||||
1. Initialize any host, which will be the `ProxmoxAAS-Dashboard` component host
|
||||
2. Download `proxmoxaas-dashboard` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
|
||||
Rename `template.config.json` to `config.json` and modify:
|
||||
- listenPort: port for PAAS-Dashboard to bind and listen on
|
||||
- organization: name of your org which is displayed on the top left corner
|
||||
- dashurl: url for the dashboard, ie. `https://paas.domain.net`
|
||||
- apiurl: url for PAAS-API, ie. `https://paas.domain.net/api`
|
||||
- pveurl: url for the Proxmox endpoint, ie. `https://pve.domain.net`
|
||||
3. Execute the binary or additionally download `proxmoxaas-dashboard.service` from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases) to run using systemd
|
||||
|
||||
After this step, the Dashboard should be available on the `Dashboard Host` at port `80`
|
||||
|
||||
## Configuration - Dashboard
|
||||
1. In `Dashboard Host`, navigate to this repo's root folder
|
||||
2. Rename `template.vars.js` to `vars.js` and assign the `API` variable with the value of the API's URL. This will likely be `paas.<FQDN>/api`
|
||||
After this step, the Dashboard should be available on the `ProxmoxAAS-Dashboard` host at the configured `listenPort`
|
||||
|
||||
## Installation - API
|
||||
|
||||
To install the API, go to [ProxmoxAAS API](https://git.tronnet.net/tronnet/ProxmoxAAS-API). This is required for the app to function. The API installation will also have steps for setting up the reverse proxy server.
|
||||
To install the API component, go to [ProxmoxAAS-API](https://git.tronnet.net/tronnet/ProxmoxAAS-API). This is required for the app to function. The API installation will also have steps for setting up the reverse proxy server.
|
||||
|
||||
## Installation - Fabric
|
||||
|
||||
To install the Fabric component, go to [ProxmoxAAS-Fabric](https://git.tronnet.net/tronnet/ProxmoxAAS-Fabric). This is required for the app to function. The Fabric installation will also have steps for setting up the reverse proxy server.
|
||||
|
||||
## Installation - LDAP
|
||||
|
||||
To install the LDAP component, go to [ProxmoxAAS-LDAP](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP).This is an optional component which adds a lightweight REST API server ontop of a simplified LDAP environment. It is only used by the API as a potential authentication backend.
|
||||
|
||||
## Installation - Reverse Proxy
|
||||
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. The configuration should include at least the following, ensuring that the configured ports are adjusted appropriately:
|
||||
```
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name paas.domain.net;
|
||||
location / {
|
||||
proxy_pass http://dashboard.local:8080/;
|
||||
proxy_redirect default;
|
||||
}
|
||||
location /api/ {
|
||||
proxy_pass http://api.local:8081/api/;
|
||||
proxy_redirect default;
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Start nginx with the new configurations
|
||||
|
||||
The dashboard, API, and Fabric should be avaliable and fully functional.
|
79
account.html
@@ -1,79 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>proxmox - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="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/nav.css">
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
<script src="scripts/account.js" type="module"></script>
|
||||
<script src="scripts/chart.js" type="module"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
@media screen and (width >= 1264px){
|
||||
#resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, calc(100% / 6));
|
||||
grid-gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 1264px) and (width >= 680px) {
|
||||
#resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 200px);
|
||||
grid-gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 680px) {
|
||||
#resource-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>proxmox</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav>
|
||||
<a href="index.html">Instances</a>
|
||||
<a href="account.html" aria-current="page">Account</a>
|
||||
<a href="settings.html">Settings</a>
|
||||
<a href="login.html">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Account</h2>
|
||||
<div class="w3-card w3-padding">
|
||||
<h3>Account Details</h3>
|
||||
<p id="username">Username:</p>
|
||||
<p id="pool">Pools:</p>
|
||||
<p id="vmid">VMID Range:</p>
|
||||
<p id="nodes">Nodes:</p>
|
||||
</div>
|
||||
<div class="w3-card w3-padding">
|
||||
<div class="flex row nowrap">
|
||||
<h3>Password</h3>
|
||||
<button class="w3-button w3-margin" id="change-password">Change Password</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w3-card w3-padding">
|
||||
<h3>Cluster Resources</h3>
|
||||
<div id="resource-container"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
69
app/app.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"proxmoxaas-dashboard/dist/web" // go will complain here until the first build
|
||||
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"proxmoxaas-dashboard/app/routes"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
)
|
||||
|
||||
func Run() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||
flag.Parse()
|
||||
common.Global = common.GetConfig(*configPath)
|
||||
|
||||
router := gin.Default()
|
||||
m := common.InitMinify()
|
||||
ServeStatic(router, m)
|
||||
html := common.MinifyStatic(m, web.Templates)
|
||||
common.TMPL = common.LoadHTMLToGin(router, html)
|
||||
|
||||
router.GET("/account", routes.HandleGETAccount)
|
||||
router.GET("/", routes.HandleGETIndex)
|
||||
router.GET("/index", routes.HandleGETIndex)
|
||||
router.GET("/index/instances", routes.HandleGETInstancesFragment)
|
||||
router.GET("/config", routes.HandleGETConfig)
|
||||
router.GET("/config/volumes", routes.HandleGETConfigVolumesFragment)
|
||||
router.GET("/config/nets", routes.HandleGETConfigNetsFragment)
|
||||
router.GET("/config/devices", routes.HandleGETConfigDevicesFragment)
|
||||
router.GET("/config/boot", routes.HandleGETConfigBootFragment)
|
||||
router.GET("/login", routes.HandleGETLogin)
|
||||
router.GET("/settings", routes.HandleGETSettings)
|
||||
|
||||
log.Fatal(router.Run(fmt.Sprintf("0.0.0.0:%d", common.Global.Port)))
|
||||
}
|
||||
|
||||
func ServeStatic(router *gin.Engine, m *minify.M) {
|
||||
css := common.MinifyStatic(m, web.CSS_fs)
|
||||
router.GET("/css/*css", func(c *gin.Context) {
|
||||
path, _ := c.Params.Get("css")
|
||||
data := css[fmt.Sprintf("css%s", path)]
|
||||
c.Data(200, data.MimeType.Type, []byte(data.Data))
|
||||
})
|
||||
images := common.MinifyStatic(m, web.Images_fs)
|
||||
router.GET("/images/*image", func(c *gin.Context) {
|
||||
path, _ := c.Params.Get("image")
|
||||
data := images[fmt.Sprintf("images%s", path)]
|
||||
c.Data(200, data.MimeType.Type, []byte(data.Data))
|
||||
})
|
||||
modules := common.MinifyStatic(m, web.Modules_fs)
|
||||
router.GET("/modules/*module", func(c *gin.Context) {
|
||||
path, _ := c.Params.Get("module")
|
||||
data := modules[fmt.Sprintf("modules%s", path)]
|
||||
c.Data(200, data.MimeType.Type, []byte(data.Data))
|
||||
})
|
||||
scripts := common.MinifyStatic(m, web.Scripts_fs)
|
||||
router.GET("/scripts/*script", func(c *gin.Context) {
|
||||
path, _ := c.Params.Get("script")
|
||||
data := scripts[fmt.Sprintf("scripts%s", path)]
|
||||
c.Data(200, data.MimeType.Type, []byte(data.Data))
|
||||
})
|
||||
}
|
52
app/common/meta.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
"github.com/tdewolff/minify/v2/css"
|
||||
"github.com/tdewolff/minify/v2/html"
|
||||
"github.com/tdewolff/minify/v2/js"
|
||||
)
|
||||
|
||||
// defines mime type and associated minifier
|
||||
type MimeType struct {
|
||||
Type string
|
||||
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
|
||||
}
|
||||
|
||||
// map file extension to mime types
|
||||
var MimeTypes = map[string]MimeType{
|
||||
"css": {
|
||||
Type: "text/css",
|
||||
Minifier: css.Minify,
|
||||
},
|
||||
"html": {
|
||||
Type: "text/html",
|
||||
Minifier: html.Minify,
|
||||
},
|
||||
"tmpl": {
|
||||
Type: "text/plain",
|
||||
Minifier: TemplateMinifier,
|
||||
},
|
||||
"frag": {
|
||||
Type: "text/plain",
|
||||
Minifier: TemplateMinifier,
|
||||
},
|
||||
"svg": {
|
||||
Type: "image/svg+xml",
|
||||
Minifier: nil,
|
||||
},
|
||||
"js": {
|
||||
Type: "application/javascript",
|
||||
Minifier: js.Minify,
|
||||
},
|
||||
"wasm": {
|
||||
Type: "application/wasm",
|
||||
Minifier: nil,
|
||||
},
|
||||
"*": {
|
||||
Type: "text/plain",
|
||||
Minifier: nil,
|
||||
},
|
||||
}
|
48
app/common/types.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package common
|
||||
|
||||
type Config struct {
|
||||
Port int `json:"listenPort"`
|
||||
Organization string `json:"organization"`
|
||||
DASH string `json:"dashurl"`
|
||||
PVE string `json:"pveurl"`
|
||||
API string `json:"apiurl"`
|
||||
}
|
||||
|
||||
type StaticFile struct {
|
||||
Data string
|
||||
MimeType MimeType
|
||||
}
|
||||
|
||||
// type used for templated <select>
|
||||
type Select struct {
|
||||
ID string
|
||||
Required bool
|
||||
Options []Option
|
||||
}
|
||||
|
||||
// type used for templated <option>
|
||||
type Option struct {
|
||||
Selected bool
|
||||
Value string
|
||||
Display string
|
||||
}
|
||||
|
||||
type RequestType int
|
||||
|
||||
type RequestContext struct {
|
||||
Cookies map[string]string
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
Username string
|
||||
Token string
|
||||
CSRF string
|
||||
}
|
||||
|
||||
type Icon struct {
|
||||
ID string
|
||||
Src string
|
||||
Alt string
|
||||
Clickable bool
|
||||
}
|
207
app/common/utils.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
)
|
||||
|
||||
var TMPL *template.Template
|
||||
var Global Config
|
||||
|
||||
func GetConfig(configPath string) Config {
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
log.Fatal("Error when opening config file: ", err)
|
||||
}
|
||||
var config Config
|
||||
err = json.Unmarshal(content, &config)
|
||||
if err != nil {
|
||||
log.Fatal("Error during parsing config file: ", err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func InitMinify() *minify.M {
|
||||
m := minify.New()
|
||||
for _, v := range MimeTypes {
|
||||
if v.Minifier != nil {
|
||||
m.AddFunc(v.Type, v.Minifier)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func MinifyStatic(m *minify.M, files embed.FS) map[string]StaticFile {
|
||||
minified := make(map[string]StaticFile)
|
||||
fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
v, err := files.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Fatalf("error parsing template file %s: %s", path, err.Error())
|
||||
}
|
||||
x := strings.Split(entry.Name(), ".")
|
||||
if len(x) >= 2 { // file has extension
|
||||
mimetype, ok := MimeTypes[x[len(x)-1]]
|
||||
if ok && mimetype.Minifier != nil { // if the extension is mapped in MimeTypes and has a minifier
|
||||
min, err := m.String(mimetype.Type, string(v)) // try to minify
|
||||
if err != nil {
|
||||
log.Fatalf("error minifying file %s: %s", path, err.Error())
|
||||
}
|
||||
minified[path] = StaticFile{
|
||||
Data: min,
|
||||
MimeType: mimetype,
|
||||
}
|
||||
} else { // if extension is not in MimeTypes and does not have minifier, skip minify
|
||||
minified[path] = StaticFile{
|
||||
Data: string(v),
|
||||
MimeType: mimetype,
|
||||
}
|
||||
}
|
||||
} else { // if the file has no extension, skip minify
|
||||
mimetype := MimeTypes["*"]
|
||||
minified[path] = StaticFile{
|
||||
Data: string(v),
|
||||
MimeType: mimetype,
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return minified
|
||||
}
|
||||
|
||||
func LoadHTMLToGin(engine *gin.Engine, html map[string]StaticFile) *template.Template {
|
||||
root := template.New("")
|
||||
engine.FuncMap = template.FuncMap{
|
||||
"MapKeys": func(x any, sep string) string {
|
||||
v := reflect.ValueOf(x)
|
||||
keys := v.MapKeys()
|
||||
s := ""
|
||||
for i := 0; i < len(keys); i++ {
|
||||
if i != 0 {
|
||||
s += sep
|
||||
}
|
||||
s += keys[i].String()
|
||||
}
|
||||
return s
|
||||
},
|
||||
"Map": func(values ...any) (map[string]any, error) {
|
||||
if len(values)%2 != 0 {
|
||||
return nil, errors.New("invalid dict call")
|
||||
}
|
||||
dict := make(map[string]interface{}, len(values)/2)
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key, ok := values[i].(string)
|
||||
if !ok {
|
||||
return nil, errors.New("dict keys must be strings")
|
||||
}
|
||||
dict[key] = values[i+1]
|
||||
}
|
||||
return dict, nil
|
||||
},
|
||||
}
|
||||
tmpl := template.Must(root, LoadAndAddToRoot(engine.FuncMap, root, html))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func LoadAndAddToRoot(FuncMap template.FuncMap, root *template.Template, html map[string]StaticFile) error {
|
||||
for name, file := range html {
|
||||
t := root.New(name).Funcs(FuncMap)
|
||||
_, err := t.Parse(file.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TemplateMinifier(m *minify.M, w io.Writer, r io.Reader, _ map[string]string) error {
|
||||
// remove newlines and tabs
|
||||
rb := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := rb.ReadString('\n')
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
line = strings.Replace(line, "\n", "", -1)
|
||||
line = strings.Replace(line, "\t", "", -1)
|
||||
line = strings.Replace(line, " ", "", -1)
|
||||
if _, errws := io.WriteString(w, line); errws != nil {
|
||||
return errws
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleNonFatalError(c *gin.Context, err error) {
|
||||
log.Printf("[Error] encountered an error: %s", err.Error())
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func RequestGetAPI(path string, context RequestContext) (*http.Response, int, error) {
|
||||
req, err := http.NewRequest("GET", Global.API+path, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range context.Cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
} else if response.StatusCode != 200 {
|
||||
return response, response.StatusCode, nil
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
}
|
||||
|
||||
err = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, &context.Body)
|
||||
if err != nil {
|
||||
return nil, response.StatusCode, err
|
||||
}
|
||||
|
||||
return response, response.StatusCode, nil
|
||||
}
|
||||
|
||||
func GetAuth(c *gin.Context) (Auth, error) {
|
||||
_, errAuth := c.Cookie("auth")
|
||||
username, errUsername := c.Cookie("username")
|
||||
token, errToken := c.Cookie("PVEAuthCookie")
|
||||
csrf, errCSRF := c.Cookie("CSRFPreventionToken")
|
||||
if errUsername != nil || errAuth != nil || errToken != nil || errCSRF != nil {
|
||||
return Auth{}, fmt.Errorf("error occured getting user cookies: (auth: %s, token: %s, csrf: %s)", errAuth, errToken, errCSRF)
|
||||
} else {
|
||||
return Auth{username, token, csrf}, nil
|
||||
}
|
||||
}
|
263
app/routes/account.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
func HandleGETAccount(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
account, err := GetUserAccount(auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
for k, v := range account.Resources {
|
||||
switch t := v.(type) {
|
||||
case NumericResource:
|
||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[k] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
}
|
||||
case StorageResource:
|
||||
avail, prefix := FormatNumber(t.Total.Avail*t.Multiplier, t.Base)
|
||||
account.Resources[k] = ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: t.Name,
|
||||
Used: t.Total.Used,
|
||||
Max: t.Total.Max,
|
||||
Avail: avail,
|
||||
Prefix: prefix,
|
||||
Unit: t.Unit,
|
||||
}
|
||||
case ListResource:
|
||||
l := struct {
|
||||
Type string
|
||||
Display bool
|
||||
Resources []ResourceChart
|
||||
}{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Resources: []ResourceChart{},
|
||||
}
|
||||
|
||||
for _, r := range t.Total {
|
||||
l.Resources = append(l.Resources, ResourceChart{
|
||||
Type: t.Type,
|
||||
Display: t.Display,
|
||||
Name: r.Name,
|
||||
Used: r.Used,
|
||||
Max: r.Max,
|
||||
Avail: float64(r.Avail), // usually an int
|
||||
Unit: "",
|
||||
})
|
||||
}
|
||||
account.Resources[k] = l
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "html/account.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "account",
|
||||
"account": account,
|
||||
})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login") // if user is not authed, redirect user to login page
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Pools map[string]bool
|
||||
Nodes map[string]bool
|
||||
VMID struct {
|
||||
Min int
|
||||
Max int
|
||||
}
|
||||
Resources map[string]any
|
||||
}
|
||||
|
||||
type Constraint struct {
|
||||
Max int64
|
||||
Used int64
|
||||
Avail int64
|
||||
}
|
||||
|
||||
type Match struct {
|
||||
Name string
|
||||
Match string
|
||||
Max int64
|
||||
Used int64
|
||||
Avail int64
|
||||
}
|
||||
|
||||
type NumericResource struct {
|
||||
Type string
|
||||
Name string
|
||||
Multiplier int64
|
||||
Base int64
|
||||
Compact bool
|
||||
Unit string
|
||||
Display bool
|
||||
Global Constraint
|
||||
Nodes map[string]Constraint
|
||||
Total Constraint
|
||||
}
|
||||
|
||||
type StorageResource struct {
|
||||
Type string
|
||||
Name string
|
||||
Multiplier int64
|
||||
Base int64
|
||||
Compact bool
|
||||
Unit string
|
||||
Display bool
|
||||
Disks []string
|
||||
Global Constraint
|
||||
Nodes map[string]Constraint
|
||||
Total Constraint
|
||||
}
|
||||
|
||||
type ListResource struct {
|
||||
Type string
|
||||
Whitelist bool
|
||||
Display bool
|
||||
Global []Match
|
||||
Nodes map[string][]Match
|
||||
Total []Match
|
||||
}
|
||||
|
||||
type ResourceChart struct {
|
||||
Type string
|
||||
Display bool
|
||||
Name string
|
||||
Used int64
|
||||
Max int64
|
||||
Avail float64
|
||||
Prefix string
|
||||
Unit string
|
||||
}
|
||||
|
||||
func GetUserAccount(auth common.Auth) (Account, error) {
|
||||
account := Account{
|
||||
Resources: map[string]any{},
|
||||
}
|
||||
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
|
||||
// get user account basic data
|
||||
res, code, err := common.RequestGetAPI("/user/config/cluster", ctx)
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /user/config/cluster resulted in %+v", res)
|
||||
}
|
||||
err = mapstructure.Decode(ctx.Body, &account)
|
||||
if err != nil {
|
||||
return account, err
|
||||
} else {
|
||||
account.Username = auth.Username
|
||||
}
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
// get user resources
|
||||
res, code, err = common.RequestGetAPI("/user/dynamic/resources", ctx)
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /user/dynamic/resources resulted in %+v", res)
|
||||
}
|
||||
resources := ctx.Body
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
// get resource meta data
|
||||
res, code, err = common.RequestGetAPI("/global/config/resources", ctx)
|
||||
if err != nil {
|
||||
return account, err
|
||||
}
|
||||
if code != 200 {
|
||||
return account, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
|
||||
}
|
||||
meta := ctx.Body["resources"].(map[string]any)
|
||||
|
||||
// build each resource by its meta type
|
||||
for k, v := range meta {
|
||||
m := v.(map[string]any)
|
||||
t := m["type"].(string)
|
||||
r := resources[k].(map[string]any)
|
||||
if t == "numeric" {
|
||||
n := NumericResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[k] = n
|
||||
} else if t == "storage" {
|
||||
n := StorageResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[k] = n
|
||||
} else if t == "list" {
|
||||
n := ListResource{}
|
||||
n.Type = t
|
||||
err_m := mapstructure.Decode(m, &n)
|
||||
err_r := mapstructure.Decode(r, &n)
|
||||
if err_m != nil || err_r != nil {
|
||||
return account, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||
}
|
||||
account.Resources[k] = n
|
||||
}
|
||||
}
|
||||
|
||||
return account, 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, ""
|
||||
}
|
||||
}
|
327
app/routes/config.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
fabric "proxmoxaas-fabric/app"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
func HandleGETConfig(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||
}
|
||||
|
||||
if config.Type == "VM" { // if VM, fetch CPU types from node
|
||||
config.ProctypeSelect, err = GetCPUTypes(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
||||
}
|
||||
}
|
||||
for i, cpu := range config.ProctypeSelect.Options {
|
||||
if cpu.Value == config.Proctype {
|
||||
config.ProctypeSelect.Options[i].Selected = true
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "html/config.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "config",
|
||||
"config": config,
|
||||
})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigVolumesFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain")
|
||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-volumes.frag", gin.H{
|
||||
"config": config,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigNetsFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain")
|
||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-nets.frag", gin.H{
|
||||
"config": config,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigDevicesFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain")
|
||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-devices.frag", gin.H{
|
||||
"config": config,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGETConfigBootFragment(c *gin.Context) {
|
||||
auth, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
vm_path, err := ExtractVMPath(c)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
config, err := GetInstanceConfig(vm_path, auth)
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain")
|
||||
common.TMPL.ExecuteTemplate(c.Writer, "html/config-boot.frag", gin.H{
|
||||
"config": config,
|
||||
})
|
||||
c.Status(http.StatusOK)
|
||||
} else {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func ExtractVMPath(c *gin.Context) (VMPath, error) {
|
||||
req_node := c.Query("node")
|
||||
req_type := c.Query("type")
|
||||
req_vmid := c.Query("vmid")
|
||||
if req_node == "" || req_type == "" || req_vmid == "" {
|
||||
return VMPath{}, fmt.Errorf("request missing required values: (node: %s, type: %s, vmid: %s)", req_node, req_type, req_vmid)
|
||||
}
|
||||
vm_path := VMPath{
|
||||
Node: req_node,
|
||||
Type: req_type,
|
||||
VMID: req_vmid,
|
||||
}
|
||||
return vm_path, nil
|
||||
}
|
||||
|
||||
type VMPath struct {
|
||||
Node string
|
||||
Type string
|
||||
VMID string
|
||||
}
|
||||
|
||||
// imported types from fabric
|
||||
|
||||
type InstanceConfig struct {
|
||||
Type fabric.InstanceType `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Proctype string `json:"cpu"`
|
||||
Cores uint64 `json:"cores"`
|
||||
Memory uint64 `json:"memory"`
|
||||
Swap uint64 `json:"swap"`
|
||||
Volumes map[string]*fabric.Volume `json:"volumes"`
|
||||
Nets map[string]*fabric.Net `json:"nets"`
|
||||
Devices map[string]*fabric.Device `json:"devices"`
|
||||
Boot fabric.BootOrder `json:"boot"`
|
||||
// overrides
|
||||
ProctypeSelect common.Select
|
||||
}
|
||||
|
||||
func GetInstanceConfig(vm VMPath, auth common.Auth) (InstanceConfig, error) {
|
||||
config := InstanceConfig{}
|
||||
path := fmt.Sprintf("/cluster/%s/%s/%s", vm.Node, vm.Type, vm.VMID)
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
if code != 200 {
|
||||
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
|
||||
err = mapstructure.Decode(ctx.Body, &config)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
|
||||
config.Memory = config.Memory / (1024 * 1024) // memory in MiB
|
||||
config.Swap = config.Swap / (1024 * 1024) // swap in MiB
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
type GlobalConfig struct {
|
||||
CPU struct {
|
||||
Whitelist bool
|
||||
}
|
||||
}
|
||||
|
||||
type UserConfig struct {
|
||||
CPU struct {
|
||||
Global []CPUConfig
|
||||
Nodes map[string][]CPUConfig
|
||||
}
|
||||
}
|
||||
|
||||
type CPUConfig struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func GetCPUTypes(vm VMPath, auth common.Auth) (common.Select, error) {
|
||||
cputypes := common.Select{
|
||||
ID: "proctype",
|
||||
Required: true,
|
||||
}
|
||||
|
||||
// get global resource config
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"username": auth.Username,
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
path := "/global/config/resources"
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
global := GlobalConfig{}
|
||||
err = mapstructure.Decode(ctx.Body["resources"], &global)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// get user resource config
|
||||
ctx.Body = map[string]any{}
|
||||
path = "/user/config/resources"
|
||||
res, code, err = common.RequestGetAPI(path, ctx)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
user := UserConfig{}
|
||||
err = mapstructure.Decode(ctx.Body, &user)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// use node specific rules if present, otherwise use global rules
|
||||
var userCPU []CPUConfig
|
||||
if _, ok := user.CPU.Nodes[vm.Node]; ok {
|
||||
userCPU = user.CPU.Nodes[vm.Node]
|
||||
} else {
|
||||
userCPU = user.CPU.Global
|
||||
}
|
||||
|
||||
if global.CPU.Whitelist { // cpu is a whitelist
|
||||
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
|
||||
cputypes.Options = append(cputypes.Options, common.Option{
|
||||
Display: cpu.Name,
|
||||
Value: cpu.Name,
|
||||
})
|
||||
}
|
||||
} else { // cpu is a blacklist
|
||||
// get the supported cpu types from the node
|
||||
ctx.Body = map[string]any{}
|
||||
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.Node)
|
||||
res, code, err = common.RequestGetAPI(path, ctx)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
if code != 200 {
|
||||
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||
}
|
||||
supported := struct {
|
||||
data []CPUConfig
|
||||
}{}
|
||||
err = mapstructure.Decode(ctx.Body, supported)
|
||||
if err != nil {
|
||||
return cputypes, err
|
||||
}
|
||||
|
||||
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
|
||||
for _, cpu := range supported.data {
|
||||
contains := slices.ContainsFunc(userCPU, func(c CPUConfig) bool {
|
||||
return c.Name == cpu.Name
|
||||
})
|
||||
if !contains {
|
||||
cputypes.Options = append(cputypes.Options, common.Option{
|
||||
Display: cpu.Name,
|
||||
Value: cpu.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sort the options by lexicographical order
|
||||
sort.Slice(cputypes.Options, func(i, j int) bool {
|
||||
return cputypes.Options[i].Display < cputypes.Options[j].Display
|
||||
})
|
||||
|
||||
return cputypes, nil
|
||||
}
|
175
app/routes/index.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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
|
||||
type Node struct {
|
||||
Node string `json:"node"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// used in constructing instance cards in index
|
||||
type InstanceCard struct {
|
||||
VMID uint
|
||||
Name string
|
||||
Type string
|
||||
Status string
|
||||
Node string
|
||||
NodeStatus string
|
||||
}
|
||||
|
||||
// used in retriving cluster tasks
|
||||
type Task struct {
|
||||
Type string
|
||||
Node string
|
||||
User string
|
||||
ID string
|
||||
VMID uint
|
||||
Status string
|
||||
}
|
||||
|
||||
type InstanceStatus struct {
|
||||
Status string
|
||||
}
|
||||
|
||||
func GetClusterResources(auth common.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
||||
ctx := common.RequestContext{
|
||||
Cookies: map[string]string{
|
||||
"PVEAuthCookie": auth.Token,
|
||||
"CSRFPreventionToken": auth.CSRF,
|
||||
},
|
||||
Body: map[string]any{},
|
||||
}
|
||||
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if code != 200 { // if we did not successfully retrieve resources, then return 500 because auth was 1 but was invalid somehow
|
||||
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
|
||||
}
|
||||
|
||||
instances := map[uint]InstanceCard{}
|
||||
nodes := map[string]Node{}
|
||||
|
||||
// if we successfully retrieved the resources, then process it and return index
|
||||
for _, v := range ctx.Body["data"].([]any) {
|
||||
m := v.(map[string]any)
|
||||
if m["type"] == "node" {
|
||||
node := Node{}
|
||||
err := mapstructure.Decode(v, &node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
nodes[node.Node] = node
|
||||
} else if m["type"] == "lxc" || m["type"] == "qemu" {
|
||||
instance := InstanceCard{}
|
||||
err := mapstructure.Decode(v, &instance)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
instances[instance.VMID] = instance
|
||||
}
|
||||
}
|
||||
for vmid, instance := range instances {
|
||||
nodestatus := nodes[instance.Node].Status
|
||||
instance.NodeStatus = nodestatus
|
||||
instances[vmid] = instance
|
||||
}
|
||||
|
||||
ctx.Body = map[string]any{}
|
||||
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
for _, v := range ctx.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"
|
||||
// get /status/current which is updated faster than /cluster/resources
|
||||
instance := instances[task.VMID]
|
||||
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
|
||||
ctx.Body = map[string]any{}
|
||||
res, code, err := common.RequestGetAPI(path, ctx)
|
||||
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(ctx.Body["data"], &status)
|
||||
|
||||
instance.Status = status.Status
|
||||
instances[task.VMID] = instance
|
||||
}
|
||||
}
|
||||
|
||||
return instances, nodes, nil
|
||||
}
|
76
app/routes/login.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
)
|
||||
|
||||
func GetLoginRealms() ([]Realm, error) {
|
||||
realms := []Realm{}
|
||||
|
||||
ctx := common.RequestContext{
|
||||
Cookies: nil,
|
||||
Body: map[string]any{},
|
||||
}
|
||||
res, code, err := common.RequestGetAPI("/proxmox/access/domains", ctx)
|
||||
if err != nil {
|
||||
return realms, err
|
||||
}
|
||||
if code != 200 { // we expect /access/domains to always be avaliable
|
||||
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
|
||||
}
|
||||
|
||||
for _, v := range ctx.Body["data"].([]any) {
|
||||
v = v.(map[string]any)
|
||||
realm := Realm{}
|
||||
err := mapstructure.Decode(v, &realm)
|
||||
if err != nil {
|
||||
return realms, err
|
||||
}
|
||||
realms = append(realms, realm)
|
||||
}
|
||||
|
||||
return realms, nil
|
||||
}
|
||||
|
||||
// used when requesting GET /access/domains
|
||||
type GetRealmsBody struct {
|
||||
Data []Realm `json:"data"`
|
||||
}
|
||||
|
||||
// stores each realm's data
|
||||
type Realm struct {
|
||||
Default int `json:"default"`
|
||||
Realm string `json:"realm"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
func HandleGETLogin(c *gin.Context) {
|
||||
realms, err := GetLoginRealms()
|
||||
if err != nil {
|
||||
common.HandleNonFatalError(c, err)
|
||||
}
|
||||
|
||||
sel := common.Select{
|
||||
ID: "realm",
|
||||
Required: true,
|
||||
}
|
||||
|
||||
for _, realm := range realms {
|
||||
sel.Options = append(sel.Options, common.Option{
|
||||
Selected: realm.Default != 0,
|
||||
Value: realm.Realm,
|
||||
Display: realm.Comment,
|
||||
})
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "html/login.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "login",
|
||||
"realms": sel,
|
||||
})
|
||||
}
|
20
app/routes/settings.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"proxmoxaas-dashboard/app/common"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func HandleGETSettings(c *gin.Context) {
|
||||
_, err := common.GetAuth(c)
|
||||
if err == nil {
|
||||
c.HTML(http.StatusOK, "html/settings.html", gin.H{
|
||||
"global": common.Global,
|
||||
"page": "settings",
|
||||
})
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
}
|
71
config.html
@@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>proxmox - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="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/nav.css">
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
<script src="scripts/config.js" type="module"></script>
|
||||
<script src="scripts/draggable.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>proxmox</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav>
|
||||
<a href="index.html" aria-current="page">Instances</a>
|
||||
<a href="account.html">Account</a>
|
||||
<a href="settings.html">Settings</a>
|
||||
<a href="login.html">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2 id="name"><a href="index.html">Instances</a> / %{vmname}</h2>
|
||||
<form>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Resources</legend>
|
||||
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;"></div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Disks</legend>
|
||||
<div class="input-grid" id="disks" style="grid-template-columns: auto auto 1fr auto;"></div>
|
||||
<div class="w3-container w3-center">
|
||||
<img id="disk-add" src="images/actions/disk/add-disk.svg" class="clickable" alt="Add New Disk" title="Add New Disk">
|
||||
<img id="cd-add" src="images/actions/disk/add-cd.svg" class="clickable none" alt="Add New CDROM" title="Add New CDROM">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Network Interfaces</legend>
|
||||
<div class="input-grid" id="networks" style="grid-template-columns: auto auto 1fr auto;"></div>
|
||||
<div class="w3-container w3-center">
|
||||
<img id="network-add" src="images/actions/network/add.svg" class="clickable" alt="Add New Network Interface" title="Add New Network Interface">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding none" id="devices-card">
|
||||
<legend>PCIe Devices</legend>
|
||||
<div class="input-grid" id="devices" style="grid-template-columns: auto 1fr auto;"></div>
|
||||
<div class="w3-container w3-center">
|
||||
<img id="device-add" src="images/actions/device/add.svg" class="clickable" alt="Add New PCIe Device" title="Add New PCIe Device">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding none" id="boot-card">
|
||||
<legend>Boot Order</legend>
|
||||
<draggable-container id="enabled"></draggable-container>
|
||||
<hr style="padding: 0; margin: 0;">
|
||||
<draggable-container id="disabled"></draggable-container>
|
||||
</fieldset>
|
||||
<div class="w3-container w3-center" id="form-actions">
|
||||
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": "standard",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": "standard",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"rules": {
|
||||
"no-tabs": [
|
||||
"error",
|
||||
{
|
||||
@@ -38,5 +38,5 @@
|
||||
"allowSingleLine": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
16
configs/.htmlvalidate.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": [
|
||||
"html-validate:recommended"
|
||||
],
|
||||
"rules": {
|
||||
"no-inline-style": "off"
|
||||
},
|
||||
"elements": [
|
||||
"html5",
|
||||
{
|
||||
"head": {
|
||||
"requiredContent": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
7
configs/template.config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"listenPort": 8080,
|
||||
"organization": "myorg",
|
||||
"dashurl": "https://paas.mydomain.example",
|
||||
"apiurl": "https://paas.mydomain.example/api",
|
||||
"pveurl": "https://pve.mydomain.example"
|
||||
}
|
126
css/style.css
@@ -1,126 +0,0 @@
|
||||
:root {
|
||||
--negative-color: #f00;
|
||||
--positive-color: #0f0;
|
||||
--highlight-color: yellow;
|
||||
--lightbg-text-color: black;
|
||||
--main-bg-color: #404040;
|
||||
--main-text-color: white;
|
||||
--main-card-bg-color: #202020;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
||||
--main-table-header-bg-color: black;
|
||||
--main-input-bg-color: #404040;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--main-bg-color: white;
|
||||
--main-text-color: black;
|
||||
--main-card-bg-color: white;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||
--main-table-header-bg-color: #808080;
|
||||
--main-input-bg-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, a, label, button, input, select, td {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
main, dialog {
|
||||
max-width: 100vw;
|
||||
background-color: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.w3-card {
|
||||
background-color: var(--main-card-bg-color);
|
||||
box-shadow: var(--main-card-box-shadow);
|
||||
}
|
||||
|
||||
.w3-card + .w3-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--main-table-header-bg-color);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: var(--main-card-bg-color);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
background-color: var(--main-input-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
img.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
hr, * {
|
||||
border-color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: row;
|
||||
column-gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.w3-select, select {
|
||||
padding: 8px;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E:root%7Bcolor:%23fff%7D@media (prefers-color-scheme:light)%7B:root%7Bcolor:%23000%7D%7D%3C/style%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M12.707 14.707a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L12 12.586l4.293-4.293a1 1 0 111.414 1.414l-5 5z' fill='currentColor'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px top 50%;
|
||||
background-size: 1em auto;
|
||||
}
|
51
go.mod
Normal file
@@ -0,0 +1,51 @@
|
||||
module proxmoxaas-dashboard
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1
|
||||
github.com/tdewolff/minify v2.3.6+incompatible
|
||||
proxmoxaas-fabric v0.0.0
|
||||
)
|
||||
|
||||
replace proxmoxaas-fabric => ../ProxmoxAAS-Fabric
|
||||
|
||||
require (
|
||||
github.com/buger/goterm v1.0.4 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/diskfs/go-diskfs v1.5.2 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jinzhu/copier v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/luthermonson/go-proxmox v0.2.2 // indirect
|
||||
github.com/magefile/mage v1.15.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/tdewolff/minify/v2 v2.23.1 // indirect
|
||||
github.com/tdewolff/parse v2.3.4+incompatible // indirect
|
||||
github.com/tdewolff/parse/v2 v2.7.23 // indirect
|
||||
github.com/tdewolff/test v1.0.11 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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>
|
Before Width: | Height: | Size: 292 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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>
|
Before Width: | Height: | Size: 295 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 296 B |
@@ -1,16 +0,0 @@
|
||||
<svg width="16" height="16" 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 |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" viewBox="2.3 2.3 19.4 19.4" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g stroke="currentColor" stroke-width="1.25"><path d="M7 12h5m0 0h5m-5 0V7m0 5v5"/><circle cx="12" cy="12" r="9"/></g></svg>
|
Before Width: | Height: | Size: 313 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M25 0H7a7 7 0 00-7 7v18a7 7 0 007 7h18a7 7 0 007-7V7a7 7 0 00-7-7zm5 25a5 5 0 01-5 5H7a5 5 0 01-5-5V7a5 5 0 015-5h18a5 5 0 015 5z"/><path d="M17 6h-2v9H6v2h9v9h2v-9h9v-2h-9V6z"/></g></svg>
|
Before Width: | Height: | Size: 389 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><g fill="#0f0"><path d="M17 12h-2.85a6.25 6.25 0 00-6.21 5H2v2h5.93a6.22 6.22 0 006.22 5H17z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-1"/><path d="M28.23 17A6.25 6.25 0 0022 12h-3v12h3a6.22 6.22 0 006.22-5H34v-2z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-2"/><path fill="none" d="M0 0h36v36H0z"/></g></svg>
|
Before Width: | Height: | Size: 446 B |
@@ -1 +0,0 @@
|
||||
<svg height="800" width="800" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="red" d="M76.987 235.517H0v40.973h76.987c9.04 33.686 39.694 58.522 76.238 58.522h57.062V176.988h-57.062c-36.543 0-67.206 24.836-76.238 58.529zm435.013 0h-76.995c-9.032-33.693-39.686-58.53-76.23-58.53h-57.062v158.024h57.062c36.537 0 67.19-24.836 76.23-58.522H512v-40.972z"/></svg>
|
Before Width: | Height: | Size: 377 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>
|
Before Width: | Height: | Size: 64 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 299 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 205 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 296 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="gray"/></svg>
|
Before Width: | Height: | Size: 202 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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 d="M5 10h14m-5 9l-2 2-2-2m4-14l-2-2-2 2m-5 9h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 329 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 296 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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 |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>
|
Before Width: | Height: | Size: 1018 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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>
|
Before Width: | Height: | Size: 537 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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>
|
Before Width: | Height: | Size: 446 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" fill="currentColor" 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>
|
Before Width: | Height: | Size: 232 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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>
|
Before Width: | Height: | Size: 285 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 296 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" 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 |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><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: 208 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M14.25 8h-4.5A1.752 1.752 0 008 9.75v4.5A1.752 1.752 0 009.75 16h4.5A1.752 1.752 0 0016 14.25v-4.5A1.752 1.752 0 0014.25 8zM14 14h-4v-4h4zm8-5a1 1 0 000-2h-2v-.25A2.752 2.752 0 0017.25 4H17V2a1 1 0 00-2 0v2h-2V2a1 1 0 00-2 0v2H9V2a1 1 0 00-2 0v2h-.25A2.752 2.752 0 004 6.75V7H2a1 1 0 000 2h2v2H2a1 1 0 000 2h2v2H2a1 1 0 000 2h2v.25A2.752 2.752 0 006.75 20H7v2a1 1 0 002 0v-2h2v2a1 1 0 002 0v-2h2v2a1 1 0 002 0v-2h.25A2.752 2.752 0 0020 17.25V17h2a1 1 0 000-2h-2v-2h2a1 1 0 000-2h-2V9zm-4 8.25a.751.751 0 01-.75.75H6.75a.751.751 0 01-.75-.75V6.75A.751.751 0 016.75 6h10.5a.751.751 0 01.75.75z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 798 B |
@@ -1 +0,0 @@
|
||||
<svg height="800" width="800" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g stroke="currentColor" fill="currentColor"><path d="M480.003 128H48c0-22.056-17.944-40-40-40a8 8 0 000 16c13.234 0 24 10.766 24 24v288a8 8 0 0016 0v-8h16.01C77.238 408 88 397.238 88 384.01V384h392.003C497.646 384 512 369.646 512 352.003V159.997C512 142.354 497.646 128 480.003 128zM496 352.003c0 8.821-7.176 15.997-15.997 15.997H80a8 8 0 00-8 8v8.01c0 4.406-3.584 7.99-7.99 7.99H48V144h432.003c8.821 0 15.997 7.176 15.997 15.997v192.006z"/><path d="M240 192c-22.922 0-43.057 12.12-54.363 30.28a8.026 8.026 0 00-1.737 2.954A63.601 63.601 0 00176 256a63.583 63.583 0 008.264 31.399c.187.398.407.778.656 1.14C196.078 307.354 216.586 320 240 320c35.29 0 64-28.71 64-64a63.583 63.583 0 00-8.264-31.399 8.057 8.057 0 00-.656-1.14C283.922 204.646 263.414 192 240 192zm-48 64c0-4.395.605-8.648 1.717-12.695 3.596 3.178 8.453 6.73 15.035 10.53 6.376 3.681 11.742 6.078 16.208 7.612-2.622 2.061-5.987 4.385-10.208 6.821-8.449 4.878-14.816 7.039-18.36 7.752A47.681 47.681 0 01192 256zm96 0c0 4.103-.52 8.087-1.493 11.891-3.617-3.227-8.542-6.848-15.259-10.726-5.96-3.441-11.036-5.758-15.321-7.298 2.483-1.885 5.564-3.966 9.321-6.135 8.447-4.876 14.816-7.039 18.36-7.752A47.681 47.681 0 01288 256zm-14.052-33.901c-4.562 1.524-10.087 3.96-16.699 7.777-6.252 3.61-10.952 6.997-14.49 10.051-.449-3.245-.759-7.21-.759-11.927 0-9.763 1.314-16.361 2.469-19.785 11.465 1.064 21.775 6.169 29.479 13.884zm-46.329-12.472C226.655 214.344 226 220.354 226 228c0 7.056.557 12.721 1.401 17.26-3.022-1.232-6.59-2.938-10.65-5.282-8.302-4.793-13.33-9.159-15.769-11.883 6.394-8.915 15.757-15.56 26.637-18.468zm-21.57 80.271c4.564-1.524 10.086-3.954 16.702-7.774 6.252-3.61 10.952-6.997 14.49-10.051.449 3.245.759 7.21.759 11.927 0 9.763-1.314 16.361-2.469 19.785-11.466-1.064-21.778-6.17-29.482-13.887zm46.332 12.475C253.345 297.656 254 291.646 254 284c0-7.633-.653-13.635-1.614-18.347 3.066 1.237 6.708 2.97 10.863 5.368 8.764 5.06 13.892 9.652 16.163 12.33-6.4 9.195-15.926 16.054-27.031 19.022z"/><path d="M440 168a8 8 0 000 16c8.822 0 16 7.178 16 16v112c0 8.822-7.178 16-16 16H240c-39.701 0-72-32.299-72-72s32.299-72 72-72h168a8 8 0 000-16H240c-48.523 0-88 39.477-88 88s39.477 88 88 88h200c17.645 0 32-14.355 32-32V200c0-17.645-14.355-32-32-32zm-328 64H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88zm24-88H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88zm24 80H88c-8.822 0-16 7.178-16 16v16c0 8.822 7.178 16 16 16h24c8.822 0 16-7.178 16-16v-16c0-8.822-7.178-16-16-16zm-24 32v-16h24l.001 16H88z"/><path d="M432 200h-24a8 8 0 000 16h24a8 8 0 000-16zm0 96h-24a8 8 0 000 16h24a8 8 0 000-16zm0-72h-24a8 8 0 000 16h24a8 8 0 000-16zm0 24h-24a8 8 0 000 16h24a8 8 0 000-16zm0 24h-24a8 8 0 000 16h24a8 8 0 000-16z"/></g></svg>
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M12 0a12 12 0 1012 12A12 12 0 0012 0zm0 22a10 10 0 1110-10 10 10 0 01-10 10z"/><path d="M12 8a4 4 0 104 4 4 4 0 00-4-4zm0 6a2 2 0 111.73-3A2 2 0 0112 14z"/><path d="M12 18a6 6 0 01-6-6 1 1 0 00-2 0 8 8 0 008 8 1 1 0 000-2zm0-14a1 1 0 000 2 6 6 0 016 6 1 1 0 001 1c2.57 0 .27-9-7-9z"/></g></svg>
|
Before Width: | Height: | Size: 495 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M22 12H2m3.45-6.89L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11zM6 16h0m4 0h0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 405 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M9 7V5H7v2H5v4h6V7H9zm-9 9h16V0H0v16zm2-2V2h12v12H2z" fill="currentColor" fill-rule="evenodd"/></svg>
|
Before Width: | Height: | Size: 281 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M23 9a1 1 0 001-1V5a1 1 0 00-1-1H1a1 1 0 00-1 1v3a1 1 0 001 1 1 1 0 010 2 1 1 0 00-1 1v7a1 1 0 001 1h13a1 1 0 001-1v-3h2v3a1 1 0 001 1h5a1 1 0 001-1v-7a1 1 0 00-1-1 1 1 0 010-2zM2 16h3v2H2zm7 0v2H7v-2zm4 2h-2v-2h2zm6 0v-2h3v2zm3-10.83a3 3 0 000 5.66V14H2v-1.17a3 3 0 000-5.66V6h20z"/><path d="M9 11V9a1 1 0 00-2 0v2a1 1 0 002 0zm4 0V9a1 1 0 00-2 0v2a1 1 0 002 0zm4 0V9a1 1 0 00-2 0v2a1 1 0 002 0z"/></g></svg>
|
Before Width: | Height: | Size: 610 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="21.986"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M19.841 3.24A10.988 10.988 0 008.54.573l1.266 3.8a7.033 7.033 0 018.809 9.158L17 11.891v7.092h7l-2.407-2.439A11.049 11.049 0 0019.841 3.24zM1 10.942a11.05 11.05 0 0011.013 11.044 11.114 11.114 0 003.521-.575l-1.266-3.8a7.035 7.035 0 01-8.788-9.22L7 9.891V6.034c.021-.02.038-.044.06-.065L7 5.909V2.982H0l2.482 2.449A10.951 10.951 0 001 10.942z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 533 B |
@@ -1 +0,0 @@
|
||||
<svg width="800" height="800" 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 d="M15.796 15.811L21 21m-3-10.5a7.5 7.5 0 11-15 0 7.5 7.5 0 0115 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
Before Width: | Height: | Size: 351 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" fill="#0f0" stroke="#none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>
|
Before Width: | Height: | Size: 125 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" fill="#f00" stroke="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>
|
Before Width: | Height: | Size: 124 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 204.481 204.481"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M162.116 38.31a7.43 7.43 0 00.454-.67c.033-.055.068-.109.1-.164a7.72 7.72 0 00.419-.857c.014-.034.024-.069.038-.104a7.492 7.492 0 00.314-1.008c.068-.288.124-.581.157-.881l.008-.052a7.48 7.48 0 00.043-.796V7.5a7.5 7.5 0 00-7.5-7.5H48.332a7.5 7.5 0 00-7.5 7.5v26.279c0 .269.016.534.043.796l.008.052c.034.3.089.593.157.881.016.069.035.138.053.207.073.273.159.541.261.801.013.034.024.069.038.104.121.296.262.581.419.857.032.056.067.109.1.164.14.232.291.455.454.67.027.035.047.074.074.109l50.255 63.821-50.255 63.821c-.028.035-.047.074-.074.109a7.43 7.43 0 00-.454.67c-.033.055-.068.109-.1.164a7.72 7.72 0 00-.419.857c-.014.034-.024.069-.038.104a7.492 7.492 0 00-.314 1.008 7.308 7.308 0 00-.157.881l-.008.052a7.48 7.48 0 00-.043.796v26.279a7.5 7.5 0 007.5 7.5h107.817a7.5 7.5 0 007.5-7.5v-26.279c0-.269-.016-.534-.043-.796l-.008-.052a7.51 7.51 0 00-.157-.881c-.016-.069-.035-.138-.053-.207a7.492 7.492 0 00-.261-.801c-.013-.034-.024-.069-.038-.104a7.383 7.383 0 00-.419-.857c-.032-.056-.067-.109-.1-.164a7.646 7.646 0 00-.454-.67c-.027-.035-.047-.074-.074-.109l-50.255-63.821 50.255-63.821c.028-.035.047-.074.074-.11zM148.649 15v11.279H55.832V15h92.817zM55.832 189.481v-11.279h92.817v11.279H55.832zm84.866-26.279H63.784l38.457-48.838 38.457 48.838zm-38.457-73.084L63.784 41.279h76.914l-38.457 48.839z" fill="currentColor"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
103
index.html
@@ -1,103 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>proxmox - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="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/nav.css">
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
<script src="scripts/index.js" type="module"></script>
|
||||
<script src="scripts/instance.js" type="module"></script>
|
||||
<script src="modules/wfa.js" type="module"></script>
|
||||
<style>
|
||||
#instance-container > div {
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
#instance-container > div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@media screen and (width >= 440px) {
|
||||
#vm-search {
|
||||
max-width: calc(100% - 10px - 152px);
|
||||
}
|
||||
button .large {
|
||||
display: block;
|
||||
}
|
||||
button .small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 440px) {
|
||||
#vm-search {
|
||||
max-width: calc(100% - 10px - 47px);
|
||||
}
|
||||
button .large {
|
||||
display: none;
|
||||
}
|
||||
button .small {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>proxmox</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav>
|
||||
<a href="index.html" aria-current="page">Instances</a>
|
||||
<a href="account.html">Account</a>
|
||||
<a href="settings.html">Settings</a>
|
||||
<a href="login.html">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Instances</h2>
|
||||
<div class="w3-card w3-padding">
|
||||
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
||||
<form id="vm-search" role="search" class="flex row nowrap">
|
||||
<img src="images/static/search.svg" alt="Search VMs">
|
||||
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
|
||||
</form>
|
||||
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
||||
<span class="large" style="margin: 0;">Create Instance</span>
|
||||
<img class="small" style="height: 1lh; width: 1lh;" src="images/actions/instance/add.svg" alt="Create New Instance">
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;">
|
||||
<div class="w3-col l1 m2">
|
||||
<p>ID</p>
|
||||
</div>
|
||||
<div class="w3-col l2 m3">
|
||||
<p>Name</p>
|
||||
</div>
|
||||
<div class="w3-col l1 m2">
|
||||
<p>Type</p>
|
||||
</div>
|
||||
<div class="w3-col l2 m3">
|
||||
<p>Status</p>
|
||||
</div>
|
||||
<div class="w3-col l2 w3-hide-medium">
|
||||
<p>Host Name</p>
|
||||
</div>
|
||||
<div class="w3-col l2 w3-hide-medium">
|
||||
<p>Host Status</p>
|
||||
</div>
|
||||
<div class="w3-col l2 m2">
|
||||
<p>Actions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="instance-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
11
init/proxmoxaas-dashboard.service
Normal file
@@ -0,0 +1,11 @@
|
||||
[Unit]
|
||||
Description=proxmoxaas-dashboard
|
||||
After=network.target
|
||||
[Service]
|
||||
WorkingDirectory=/<path to dir>
|
||||
ExecStart=/<path to dir>/promoxaas-dashboard
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Type=simple
|
||||
[Install]
|
||||
WantedBy=default.target
|
40
login.html
@@ -1,40 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>proxmox - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="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/nav.css">
|
||||
<script src="scripts/login.js" type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>proxmox</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav>
|
||||
<a href="login.html" aria-current="page">Login</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="flex" style="justify-content: center; align-items: center;">
|
||||
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||
<h2 class="w3-center">Proxmox VE Login</h2>
|
||||
<form>
|
||||
<label for="username"><b>Username</b></label>
|
||||
<input class="w3-input w3-border" id="username" name="username" type="text" autocomplete="username">
|
||||
<label for="password"><b>Password</b></label>
|
||||
<input class="w3-input w3-border" id="password" name="password" type="password" autocomplete="current-password">
|
||||
<label for="realm">Realm</label>
|
||||
<select class="w3-select w3-border" id="realm" name="realm"></select>
|
||||
<div class="w3-center">
|
||||
<button class="w3-button w3-margin" id="submit">LOGIN</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
328
modules/wfa.js
@@ -1,328 +0,0 @@
|
||||
class WavefrontComponent {
|
||||
constructor () {
|
||||
this.lo = [0]; // lo for each wavefront
|
||||
this.hi = [0]; // hi for each wavefront
|
||||
this.W = []; // wavefront diag distance for each wavefront
|
||||
this.A = []; // compact CIGAR for backtrace
|
||||
}
|
||||
|
||||
// get value for wavefront=score, diag=k
|
||||
get_val (score, k) {
|
||||
if (this.W[score] !== undefined && this.W[score][k] !== undefined) {
|
||||
return this.W[score][k];
|
||||
}
|
||||
else {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
|
||||
// set value for wavefront=score, diag=k
|
||||
set_val (score, k, val) {
|
||||
if (this.W[score]) {
|
||||
this.W[score][k] = val;
|
||||
}
|
||||
else {
|
||||
this.W[score] = [];
|
||||
this.W[score][k] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// get alignment traceback
|
||||
get_traceback (score, k) {
|
||||
if (this.A[score] !== undefined && this.A[score][k] !== undefined) {
|
||||
return this.A[score][k];
|
||||
}
|
||||
else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// set alignment traceback
|
||||
set_traceback (score, k, traceback) {
|
||||
if (this.A[score]) {
|
||||
this.A[score][k] = traceback;
|
||||
}
|
||||
else {
|
||||
this.A[score] = [];
|
||||
this.A[score][k] = traceback;
|
||||
}
|
||||
}
|
||||
|
||||
// get hi for wavefront=score
|
||||
get_hi (score) {
|
||||
const hi = this.hi[score];
|
||||
return isNaN(hi) ? 0 : hi;
|
||||
}
|
||||
|
||||
// set hi for wavefront=score
|
||||
set_hi (score, hi) {
|
||||
this.hi[score] = hi;
|
||||
}
|
||||
|
||||
// get lo for wavefront=score
|
||||
get_lo (score) {
|
||||
const lo = this.lo[score];
|
||||
return isNaN(lo) ? 0 : lo;
|
||||
}
|
||||
|
||||
// set lo for wavefront=score
|
||||
set_lo (score, lo) {
|
||||
this.lo[score] = lo;
|
||||
}
|
||||
|
||||
// string representation of all wavefronts
|
||||
toString () {
|
||||
const traceback_str = ["OI", "EI", "OD", "ED", "SB", "IN", "DL", "EN"];
|
||||
let s = "<";
|
||||
let min_lo = Infinity;
|
||||
let max_hi = -Infinity;
|
||||
// get the min lo and max hi values across all wavefronts
|
||||
for (let i = 0; i < this.W.length; i++) {
|
||||
const lo = this.lo[i];
|
||||
const hi = this.hi[i];
|
||||
if (lo < min_lo) {
|
||||
min_lo = lo;
|
||||
}
|
||||
if (hi > max_hi) {
|
||||
max_hi = hi;
|
||||
}
|
||||
}
|
||||
// print out two headers, one for wavefront and one for traceback
|
||||
for (let k = min_lo; k <= max_hi; k++) {
|
||||
s += FormatNumberLength(k, 2);
|
||||
if (k < max_hi) {
|
||||
s += "|";
|
||||
}
|
||||
}
|
||||
s += ">\t<";
|
||||
for (let k = min_lo; k <= max_hi; k++) {
|
||||
s += FormatNumberLength(k, 2);
|
||||
if (k < max_hi) {
|
||||
s += "|";
|
||||
}
|
||||
}
|
||||
s += ">\n";
|
||||
// for each wavefront
|
||||
for (let i = 0; i < this.W.length; i++) {
|
||||
s += "[";
|
||||
const lo = this.lo[i];
|
||||
const hi = this.hi[i];
|
||||
// print out the wavefront matrix
|
||||
for (let k = min_lo; k <= max_hi; k++) {
|
||||
if (this.W[i] !== undefined && this.W[i][k] !== undefined && !isNaN(this.W[i][k])) {
|
||||
s += FormatNumberLength(this.W[i][k], 2);
|
||||
}
|
||||
else if (k < lo || k > hi) {
|
||||
s += "--";
|
||||
}
|
||||
else {
|
||||
s += " ";
|
||||
}
|
||||
if (k < max_hi) {
|
||||
s += "|";
|
||||
}
|
||||
}
|
||||
s += "]\t[";
|
||||
// print out the traceback matrix
|
||||
for (let k = min_lo; k <= max_hi; k++) {
|
||||
if (this.A[i] !== undefined && this.A[i][k] !== undefined) {
|
||||
s += traceback_str[this.A[i][k].toString()];
|
||||
}
|
||||
else if (k < lo || k > hi) {
|
||||
s += "--";
|
||||
}
|
||||
else {
|
||||
s += " ";
|
||||
}
|
||||
if (k < max_hi) {
|
||||
s += "|";
|
||||
}
|
||||
}
|
||||
s += "]\n";
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
const traceback = {
|
||||
OpenIns: 0,
|
||||
ExtdIns: 1,
|
||||
OpenDel: 2,
|
||||
ExtdDel: 3,
|
||||
Sub: 4,
|
||||
Ins: 5,
|
||||
Del: 6,
|
||||
End: 7
|
||||
};
|
||||
|
||||
function FormatNumberLength (num, length) {
|
||||
let r = "" + num;
|
||||
while (r.length < length) {
|
||||
r = " " + r;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function min (args) {
|
||||
args.forEach((el, idx, arr) => {
|
||||
arr[idx] = isNaN(el) ? Infinity : el;
|
||||
});
|
||||
const min = Math.min.apply(Math, args);
|
||||
return min === Infinity ? NaN : min;
|
||||
}
|
||||
|
||||
function max (args) {
|
||||
args.forEach((el, idx, arr) => {
|
||||
arr[idx] = isNaN(el) ? -Infinity : el;
|
||||
});
|
||||
const max = Math.max.apply(Math, args);
|
||||
return max === -Infinity ? NaN : max;
|
||||
}
|
||||
|
||||
function argmax (args) {
|
||||
const val = max(args);
|
||||
return args.indexOf(val);
|
||||
}
|
||||
|
||||
export default function wf_align (s1, s2, penalties) {
|
||||
const n = s1.length;
|
||||
const m = s2.length;
|
||||
const A_k = m - n;
|
||||
const A_offset = m;
|
||||
let score = 0;
|
||||
const M = new WavefrontComponent();
|
||||
M.set_val(0, 0, 0);
|
||||
M.set_hi(0, 0);
|
||||
M.set_lo(0, 0);
|
||||
M.set_traceback(0, 0, traceback.End);
|
||||
const I = new WavefrontComponent();
|
||||
const D = new WavefrontComponent();
|
||||
while (true) {
|
||||
wf_extend(M, s1, n, s2, m, score);
|
||||
if (M.get_val(score, A_k) >= A_offset) {
|
||||
break;
|
||||
}
|
||||
score++;
|
||||
wf_next(M, I, D, score, penalties);
|
||||
}
|
||||
return wf_backtrace(M, I, D, score, penalties, A_k, A_offset);
|
||||
}
|
||||
|
||||
function wf_extend (M, s1, n, s2, m, score) {
|
||||
const lo = M.get_lo(score);
|
||||
const hi = M.get_hi(score);
|
||||
for (let k = lo; k <= hi; k++) {
|
||||
let v = M.get_val(score, k) - k;
|
||||
let h = M.get_val(score, k);
|
||||
if (isNaN(v) || isNaN(h)) {
|
||||
continue;
|
||||
}
|
||||
while (s1[v] === s2[h]) {
|
||||
M.set_val(score, k, M.get_val(score, k) + 1);
|
||||
v++;
|
||||
h++;
|
||||
if (v > n || h > m) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wf_next (M, I, D, score, penalties) {
|
||||
const x = penalties.x;
|
||||
const o = penalties.o;
|
||||
const e = penalties.e;
|
||||
const lo = min([M.get_lo(score - x), M.get_lo(score - o - e), I.get_lo(score - e), D.get_lo(score - e)]) - 1;
|
||||
const hi = max([M.get_hi(score - x), M.get_hi(score - o - e), I.get_hi(score - e), D.get_hi(score - e)]) + 1;
|
||||
M.set_hi(score, hi);
|
||||
I.set_hi(score, hi);
|
||||
D.set_hi(score, hi);
|
||||
M.set_lo(score, lo);
|
||||
I.set_lo(score, lo);
|
||||
D.set_lo(score, lo);
|
||||
for (let k = lo; k <= hi; k++) {
|
||||
I.set_val(score, k, max([
|
||||
M.get_val(score - o - e, k - 1),
|
||||
I.get_val(score - e, k - 1)
|
||||
]) + 1);
|
||||
I.set_traceback(score, k, [traceback.OpenIns, traceback.ExtdIns][argmax([
|
||||
M.get_val(score - o - e, k - 1),
|
||||
I.get_val(score - e, k - 1)
|
||||
])]);
|
||||
D.set_val(score, k, max([
|
||||
M.get_val(score - o - e, k + 1),
|
||||
D.get_val(score - e, k + 1)
|
||||
]));
|
||||
D.set_traceback(score, k, [traceback.OpenDel, traceback.ExtdDel][argmax([
|
||||
M.get_val(score - o - e, k + 1),
|
||||
D.get_val(score - e, k + 1)
|
||||
])]);
|
||||
M.set_val(score, k, max([
|
||||
M.get_val(score - x, k) + 1,
|
||||
I.get_val(score, k),
|
||||
D.get_val(score, k)
|
||||
]));
|
||||
M.set_traceback(score, k, [traceback.Sub, traceback.Ins, traceback.Del][argmax([
|
||||
M.get_val(score - x, k) + 1,
|
||||
I.get_val(score, k),
|
||||
D.get_val(score, k)
|
||||
])]);
|
||||
}
|
||||
}
|
||||
|
||||
function wf_backtrace (M, I, D, score, penalties, A_k) {
|
||||
const traceback_CIGAR = ["I", "I", "D", "D", "X", "", "", ""];
|
||||
const x = penalties.x;
|
||||
const o = penalties.o;
|
||||
const e = penalties.e;
|
||||
let CIGAR_rev = ""; // reversed CIGAR
|
||||
let tb_s = score; // traceback score
|
||||
let tb_k = A_k; // traceback diag k
|
||||
let current_traceback = M.get_traceback(tb_s, tb_k);
|
||||
let done = false;
|
||||
while (!done) {
|
||||
CIGAR_rev += traceback_CIGAR[current_traceback];
|
||||
switch (current_traceback) {
|
||||
case traceback.OpenIns:
|
||||
tb_s = tb_s - o - e;
|
||||
tb_k = tb_k - 1;
|
||||
current_traceback = M.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.ExtdIns:
|
||||
tb_s = tb_s - e;
|
||||
tb_k = tb_k - 1;
|
||||
current_traceback = I.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.OpenDel:
|
||||
tb_s = tb_s - o - e;
|
||||
tb_k = tb_k + 1;
|
||||
current_traceback = M.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.ExtdDel:
|
||||
tb_s = tb_s - e;
|
||||
tb_k = tb_k + 1;
|
||||
current_traceback = D.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.Sub:
|
||||
tb_s = tb_s - x;
|
||||
// tb_k = tb_k;
|
||||
current_traceback = M.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.Ins:
|
||||
// tb_s = tb_s;
|
||||
// tb_k = tb_k;
|
||||
current_traceback = I.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.Del:
|
||||
// tb_s = tb_s;
|
||||
// tb_k = tb_k;
|
||||
current_traceback = D.get_traceback(tb_s, tb_k);
|
||||
break;
|
||||
case traceback.End:
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const CIGAR = Array.from(CIGAR_rev).reverse().join("");
|
||||
return { CIGAR, score };
|
||||
}
|
@@ -4,7 +4,8 @@
|
||||
"description": "Front-end for ProxmoxAAS",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "html-validator --continue; stylelint --formatter verbose --fix **/*.css; DEBUG=eslint:cli-engine eslint --fix scripts/"
|
||||
"lint": "html-validate --config configs/.htmlvalidate.json web/html/*; stylelint --config configs/.stylelintrc.json --formatter verbose --fix web/css/*.css; DEBUG=eslint:cli-engine eslint --config configs/.eslintrc.json --fix web/scripts/",
|
||||
"update-modules": "rm -rf web/modules/wfa.js web/modules/wfa.wasm; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.js -o web/modules/wfa.js; curl https://git.tronnet.net/alu/WFA-JS/releases/download/latest/wfa.wasm -o web/modules/wfa.wasm"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.43.0",
|
||||
@@ -14,6 +15,6 @@
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"stylelint": "^15.9.0",
|
||||
"stylelint-config-standard": "^33.0.0",
|
||||
"w3c-html-validator": "^1.4.0"
|
||||
"html-validate": "^9.4.0"
|
||||
}
|
||||
}
|
||||
|
9
proxmoxaas-dashboard.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
app "proxmoxaas-dashboard/app"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app.Run()
|
||||
}
|
@@ -1,149 +0,0 @@
|
||||
import { dialog } from "./dialog.js";
|
||||
import { requestAPI, goToPage, getCookie, setTitleAndHeader } from "./utils.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
const prefixes = {
|
||||
1024: [
|
||||
"",
|
||||
"Ki",
|
||||
"Mi",
|
||||
"Gi",
|
||||
"Ti"
|
||||
],
|
||||
1000: [
|
||||
"",
|
||||
"K",
|
||||
"M",
|
||||
"G",
|
||||
"T"
|
||||
]
|
||||
};
|
||||
|
||||
async function init () {
|
||||
setTitleAndHeader();
|
||||
const cookie = document.cookie;
|
||||
if (cookie === "") {
|
||||
goToPage("login.html");
|
||||
}
|
||||
|
||||
let resources = requestAPI("/user/dynamic/resources");
|
||||
let meta = requestAPI("/global/config/resources");
|
||||
let userCluster = requestAPI("/user/config/cluster");
|
||||
|
||||
resources = await resources;
|
||||
meta = await meta;
|
||||
userCluster = await userCluster;
|
||||
|
||||
document.querySelector("#username").innerText = `Username: ${getCookie("username")}`;
|
||||
document.querySelector("#pool").innerText = `Pools: ${Object.keys(userCluster.pools).toString()}`;
|
||||
document.querySelector("#vmid").innerText = `VMID Range: ${userCluster.vmid.min} - ${userCluster.vmid.max}`;
|
||||
document.querySelector("#nodes").innerText = `Nodes: ${Object.keys(userCluster.nodes).toString()}`;
|
||||
|
||||
populateResources("#resource-container", meta, resources);
|
||||
|
||||
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm);
|
||||
}
|
||||
|
||||
function populateResources (containerID, meta, resources) {
|
||||
if (resources instanceof Object) {
|
||||
const container = document.querySelector(containerID);
|
||||
Object.keys(meta).forEach((resourceType) => {
|
||||
if (meta[resourceType].display) {
|
||||
if (meta[resourceType].type === "list") {
|
||||
resources[resourceType].total.forEach((listResource) => {
|
||||
createResourceUsageChart(container, listResource.name, listResource.avail, listResource.used, listResource.max, null);
|
||||
});
|
||||
}
|
||||
else {
|
||||
createResourceUsageChart(container, meta[resourceType].name, resources[resourceType].total.avail, resources[resourceType].total.used, resources[resourceType].total.max, meta[resourceType]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createResourceUsageChart (container, resourceName, resourceAvail, resourceUsed, resourceMax, resourceUnitData) {
|
||||
const chart = document.createElement("resource-chart");
|
||||
container.append(chart);
|
||||
const maxStr = parseNumber(resourceMax, resourceUnitData);
|
||||
const usedStr = parseNumber(resourceUsed, resourceUnitData);
|
||||
const usedRatio = resourceUsed / resourceMax;
|
||||
const R = Math.min(usedRatio * 510, 255);
|
||||
const G = Math.min((1 - usedRatio) * 510, 255);
|
||||
const usedColor = `rgb(${R}, ${G}, 0)`;
|
||||
chart.data = {
|
||||
title: [resourceName, `Used ${usedStr} of ${maxStr}`],
|
||||
ariaLabel: `${resourceName} used ${usedStr} of ${maxStr}`,
|
||||
data: {
|
||||
labels: [
|
||||
"Used",
|
||||
"Available"
|
||||
],
|
||||
datasets: [{
|
||||
label: resourceName,
|
||||
data: [resourceUsed, resourceAvail],
|
||||
backgroundColor: [
|
||||
usedColor,
|
||||
"rgb(140, 140, 140)"
|
||||
],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
breakpoint: 680
|
||||
};
|
||||
chart.style = "margin: 10px;";
|
||||
}
|
||||
|
||||
function parseNumber (value, unitData) {
|
||||
if (!unitData) {
|
||||
return `${value}`;
|
||||
}
|
||||
const compact = unitData.compact;
|
||||
const multiplier = unitData.multiplier;
|
||||
const base = unitData.base;
|
||||
const unit = unitData.unit;
|
||||
value = multiplier * value;
|
||||
if (value <= 0) {
|
||||
return `0 ${unit}`;
|
||||
}
|
||||
else if (compact) {
|
||||
const exponent = Math.floor(Math.log(value) / Math.log(base));
|
||||
value = value / base ** exponent;
|
||||
const unitPrefix = prefixes[base][exponent];
|
||||
return `${value} ${unitPrefix}${unit}`;
|
||||
}
|
||||
else {
|
||||
return `${value} ${unit}`;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordChangeForm () {
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="new-password">New Password</label>
|
||||
<input class="w3-input w3-border" id="new-password" name="new-password" type="password"required>
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
|
||||
</form>
|
||||
`;
|
||||
const d = dialog("Change Password", body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const result = await requestAPI("/auth/password", "POST", { password: form.get("new-password") });
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const password = d.querySelector("#new-password");
|
||||
const confirmPassword = d.querySelector("#confirm-password");
|
||||
|
||||
function validatePassword () {
|
||||
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||
}
|
||||
|
||||
password.addEventListener("change", validatePassword);
|
||||
confirmPassword.addEventListener("keyup", validatePassword);
|
||||
}
|
120
scripts/chart.js
@@ -1,120 +0,0 @@
|
||||
class ResourceChart extends HTMLElement {
|
||||
constructor () {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
div {
|
||||
max-width: 400px;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
figcaption {
|
||||
text-align: center;
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
<style id="responsive-style" media="not all">
|
||||
figure {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
div {
|
||||
max-height: 1lh;
|
||||
}
|
||||
figcaption {
|
||||
margin: 0;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1ch;
|
||||
font-size: small;
|
||||
}
|
||||
</style>
|
||||
<figure>
|
||||
<div>
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
<figcaption></figcaption>
|
||||
</figure>
|
||||
`;
|
||||
this.responsiveStyle = this.shadowRoot.querySelector("#responsive-style");
|
||||
this.canvas = this.shadowRoot.querySelector("canvas");
|
||||
this.caption = this.shadowRoot.querySelector("figcaption");
|
||||
}
|
||||
|
||||
set data (data) {
|
||||
for (const line of data.title) {
|
||||
this.caption.innerHTML += `<span>${line}</span>`;
|
||||
}
|
||||
|
||||
this.canvas.role = "img";
|
||||
this.canvas.ariaLabel = data.ariaLabel;
|
||||
|
||||
const chartData = {
|
||||
type: "pie",
|
||||
data: data.data,
|
||||
options: {
|
||||
plugins: {
|
||||
title: {
|
||||
display: false
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: "nearest"
|
||||
},
|
||||
onHover: function (e, activeElements) {
|
||||
if (window.innerWidth <= data.breakpoint) {
|
||||
updateTooltipShow(e.chart, false);
|
||||
}
|
||||
else {
|
||||
updateTooltipShow(e.chart, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.chart = createChart(this.canvas, chartData);
|
||||
|
||||
if (data.breakpoint) {
|
||||
this.responsiveStyle.media = `screen and (width <= ${data.breakpoint}px)`;
|
||||
}
|
||||
else {
|
||||
this.responsiveStyle.media = "not all";
|
||||
}
|
||||
}
|
||||
|
||||
get data () {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createChart (ctx, data) {
|
||||
return new window.Chart(ctx, data);
|
||||
}
|
||||
|
||||
// this is a really bad way to do this, but chartjs api does not expose many ways to dynamically set hover and tooltip options
|
||||
function updateTooltipShow (chart, enabled) {
|
||||
chart.options.plugins.tooltip.enabled = enabled;
|
||||
chart.options.interaction.mode = enabled ? "nearest" : null;
|
||||
chart.update();
|
||||
}
|
||||
|
||||
customElements.define("resource-chart", ResourceChart);
|
@@ -1,861 +0,0 @@
|
||||
import { requestPVE, requestAPI, goToPage, getURIData, resourcesConfig, setTitleAndHeader, bootConfig } from "./utils.js";
|
||||
import { alert, dialog } from "./dialog.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init); // do the dumb thing where the disk config refreshes every second
|
||||
|
||||
const diskMetaData = resourcesConfig.disk;
|
||||
const networkMetaData = resourcesConfig.network;
|
||||
const pcieMetaData = resourcesConfig.pcie;
|
||||
const bootMetaData = bootConfig;
|
||||
|
||||
let node;
|
||||
let type;
|
||||
let vmid;
|
||||
let config;
|
||||
|
||||
async function init () {
|
||||
setTitleAndHeader();
|
||||
const cookie = document.cookie;
|
||||
if (cookie === "") {
|
||||
goToPage("login.html");
|
||||
}
|
||||
|
||||
const uriData = getURIData();
|
||||
node = uriData.node;
|
||||
type = uriData.type;
|
||||
vmid = uriData.vmid;
|
||||
|
||||
await getConfig();
|
||||
|
||||
populateResources();
|
||||
populateDisk();
|
||||
populateNetworks();
|
||||
populateDevices();
|
||||
populateBoot();
|
||||
|
||||
document.querySelector("#exit").addEventListener("click", handleFormExit);
|
||||
}
|
||||
|
||||
function getOrdered (keys) {
|
||||
const orderedKeys = Object.keys(keys).sort((a, b) => {
|
||||
return parseInt(a) - parseInt(b);
|
||||
}); // ordered integer list
|
||||
return orderedKeys;
|
||||
}
|
||||
|
||||
async function getConfig () {
|
||||
config = await requestPVE(`/nodes/${node}/${type}/${vmid}/config`, "GET");
|
||||
}
|
||||
|
||||
async function populateResources () {
|
||||
const name = type === "qemu" ? "name" : "hostname";
|
||||
document.querySelector("#name").innerHTML = document.querySelector("#name").innerHTML.replace("%{vmname}", config.data[name]);
|
||||
if (type === "qemu") {
|
||||
const global = await requestAPI("/global/config/resources");
|
||||
const user = await requestAPI("/user/config/resources");
|
||||
let options = [];
|
||||
const globalCPU = global.cpu;
|
||||
const userCPU = node in user.cpu.nodes ? user.cpu.nodes[node] : user.cpu.global;
|
||||
if (globalCPU.whitelist) {
|
||||
userCPU.forEach((userType) => {
|
||||
options.push(userType.name);
|
||||
});
|
||||
options = options.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
else {
|
||||
const supported = await requestPVE(`/nodes/${node}/capabilities/qemu/cpu`);
|
||||
supported.data.forEach((supportedType) => {
|
||||
if (!userCPU.some((userType) => supportedType.name === userType.name)) {
|
||||
options.push(supportedType.name);
|
||||
}
|
||||
});
|
||||
options = options.sort((a, b) => {
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
addResourceLine("resources", "images/resources/cpu.svg", "select", "CPU Type", "proctype", { value: config.data.cpu, options });
|
||||
}
|
||||
addResourceLine("resources", "images/resources/cpu.svg", "input", "CPU Amount", "cores", { type: "number", value: config.data.cores, min: 1, max: 8192 }, "Cores");
|
||||
addResourceLine("resources", "images/resources/ram.svg", "input", "Memory", "ram", { type: "number", value: config.data.memory, min: 16, step: 1 }, "MiB");
|
||||
if (type === "lxc") {
|
||||
addResourceLine("resources", "images/resources/swap.svg", "input", "Swap", "swap", { type: "number", value: config.data.swap, min: 0, step: 1 }, "MiB");
|
||||
}
|
||||
}
|
||||
|
||||
function addResourceLine (fieldset, iconHref, type, labelText, id, attributes, unitText = null) {
|
||||
const field = document.querySelector(`#${fieldset}`);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.src = iconHref;
|
||||
icon.alt = labelText;
|
||||
field.append(icon);
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.innerText = labelText;
|
||||
label.htmlFor = id;
|
||||
field.append(label);
|
||||
|
||||
if (type === "input") {
|
||||
const input = document.createElement("input");
|
||||
for (const k in attributes) {
|
||||
input.setAttribute(k, attributes[k]);
|
||||
}
|
||||
input.id = id;
|
||||
input.name = id;
|
||||
input.required = true;
|
||||
input.classList.add("w3-input");
|
||||
input.classList.add("w3-border");
|
||||
field.append(input);
|
||||
}
|
||||
else if (type === "select") {
|
||||
const select = document.createElement("select");
|
||||
for (const option of attributes.options) {
|
||||
select.append(new Option(option));
|
||||
}
|
||||
select.value = attributes.value;
|
||||
select.id = id;
|
||||
select.name = id;
|
||||
select.required = true;
|
||||
select.classList.add("w3-select");
|
||||
select.classList.add("w3-border");
|
||||
field.append(select);
|
||||
}
|
||||
|
||||
if (unitText) {
|
||||
const unit = document.createElement("p");
|
||||
unit.innerText = unitText;
|
||||
field.append(unit);
|
||||
}
|
||||
else {
|
||||
const unit = document.createElement("div");
|
||||
unit.classList.add("hidden");
|
||||
field.append(unit);
|
||||
}
|
||||
}
|
||||
|
||||
async function populateDisk () {
|
||||
document.querySelector("#disks").innerHTML = "";
|
||||
for (let i = 0; i < diskMetaData[type].prefixOrder.length; i++) {
|
||||
const prefix = diskMetaData[type].prefixOrder[i];
|
||||
const busName = diskMetaData[type][prefix].name;
|
||||
const disks = {};
|
||||
Object.keys(config.data).forEach((element) => {
|
||||
if (element.startsWith(prefix)) {
|
||||
disks[element.replace(prefix, "")] = config.data[element];
|
||||
}
|
||||
});
|
||||
const orderedKeys = getOrdered(disks);
|
||||
orderedKeys.forEach((element) => {
|
||||
const disk = disks[element];
|
||||
addDiskLine("disks", prefix, busName, element, disk);
|
||||
});
|
||||
}
|
||||
document.querySelector("#disk-add").addEventListener("click", handleDiskAdd);
|
||||
|
||||
if (type === "qemu") {
|
||||
document.querySelector("#cd-add").classList.remove("none");
|
||||
document.querySelector("#cd-add").addEventListener("click", handleCDAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function addDiskLine (fieldset, busPrefix, busName, device, diskDetails) {
|
||||
const field = document.querySelector(`#${fieldset}`);
|
||||
|
||||
const diskName = `${busName} ${device}`;
|
||||
const diskID = `${busPrefix}${device}`;
|
||||
|
||||
// Set the disk icon, either drive.svg or disk.svg
|
||||
const icon = document.createElement("img");
|
||||
icon.src = diskMetaData[type][busPrefix].icon;
|
||||
icon.alt = diskName;
|
||||
icon.dataset.disk = diskID;
|
||||
field.append(icon);
|
||||
|
||||
// Add a label for the disk bus and device number
|
||||
const diskLabel = document.createElement("p");
|
||||
diskLabel.innerText = diskName;
|
||||
diskLabel.dataset.disk = diskID;
|
||||
field.append(diskLabel);
|
||||
|
||||
// Add text of the disk configuration
|
||||
const diskDesc = document.createElement("p");
|
||||
diskDesc.innerText = diskDetails;
|
||||
diskDesc.dataset.disk = diskID;
|
||||
diskDesc.style.overflowX = "hidden";
|
||||
diskDesc.style.whiteSpace = "nowrap";
|
||||
field.append(diskDesc);
|
||||
|
||||
const actionDiv = document.createElement("div");
|
||||
diskMetaData.actionBarOrder.forEach((element) => {
|
||||
const action = document.createElement("img");
|
||||
if (element === "detach_attach" && diskMetaData[type][busPrefix].actions.includes("attach")) { // attach
|
||||
action.src = "images/actions/disk/attach.svg";
|
||||
action.title = "Attach Disk";
|
||||
action.addEventListener("click", handleDiskAttach);
|
||||
action.classList.add("clickable");
|
||||
}
|
||||
else if (element === "detach_attach" && diskMetaData[type][busPrefix].actions.includes("detach")) { // detach
|
||||
action.src = "images/actions/disk/detach.svg";
|
||||
action.title = "Detach Disk";
|
||||
action.addEventListener("click", handleDiskDetach);
|
||||
action.classList.add("clickable");
|
||||
}
|
||||
else if (element === "delete") {
|
||||
const active = diskMetaData[type][busPrefix].actions.includes(element) ? "active" : "inactive"; // resize
|
||||
action.src = `images/actions/delete-${active}.svg`;
|
||||
action.title = "Delete Disk";
|
||||
if (active === "active") {
|
||||
action.addEventListener("click", handleDiskDelete);
|
||||
action.classList.add("clickable");
|
||||
}
|
||||
}
|
||||
else {
|
||||
const active = diskMetaData[type][busPrefix].actions.includes(element) ? "active" : "inactive"; // resize
|
||||
action.src = `images/actions/disk/${element}-${active}.svg`;
|
||||
if (active === "active") {
|
||||
action.title = `${element.charAt(0).toUpperCase()}${element.slice(1)} Disk`;
|
||||
if (element === "move") {
|
||||
action.addEventListener("click", handleDiskMove);
|
||||
}
|
||||
else if (element === "resize") {
|
||||
action.addEventListener("click", handleDiskResize);
|
||||
}
|
||||
action.classList.add("clickable");
|
||||
}
|
||||
}
|
||||
action.dataset.disk = diskID;
|
||||
action.alt = action.title;
|
||||
actionDiv.append(action);
|
||||
});
|
||||
field.append(actionDiv);
|
||||
}
|
||||
|
||||
async function handleDiskDetach () {
|
||||
const disk = this.dataset.disk;
|
||||
const header = `Detach ${disk}`;
|
||||
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
deleteBootLine(`boot-${disk}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiskAttach () {
|
||||
const header = `Attach ${this.dataset.disk}`;
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="device">${type === "qemu" ? "SATA" : "MP"}</label>
|
||||
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" required></input>
|
||||
</form>
|
||||
`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const device = form.get("device");
|
||||
document.querySelector(`img[data-disk="${this.dataset.disk}"]`).src = "images/status/loading.svg";
|
||||
const body = {
|
||||
source: this.dataset.disk.replace("unused", "")
|
||||
};
|
||||
const prefix = type === "qemu" ? "sata" : "mp";
|
||||
const disk = `${prefix}${device}`;
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
addBootLine("disabled", { id: disk, prefix, value: disk, detail: config.data[disk] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiskResize () {
|
||||
const header = `Resize ${this.dataset.disk}`;
|
||||
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"></input>
|
||||
</form>
|
||||
`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const disk = this.dataset.disk;
|
||||
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
|
||||
const body = {
|
||||
size: form.get("size-increment")
|
||||
};
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
const prefix = bootMetaData.eligiblePrefixes.find((pref) => disk.startsWith(pref));
|
||||
updateBootLine(`boot-${disk}`, { id: disk, prefix, value: disk, detail: config.data[disk] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiskMove () {
|
||||
const content = type === "qemu" ? "images" : "rootdir";
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
|
||||
const header = `Move ${this.dataset.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"><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") {
|
||||
const disk = this.dataset.disk;
|
||||
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
|
||||
const body = {
|
||||
storage: form.get("storage-select"),
|
||||
delete: form.get("delete-check") === "on" ? "1" : "0"
|
||||
};
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/move`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
const prefix = bootMetaData.eligiblePrefixes.find((pref) => disk.startsWith(pref));
|
||||
updateBootLine(`boot-${disk}`, { id: disk, prefix, value: config.data[disk] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiskDelete () {
|
||||
const disk = this.dataset.disk;
|
||||
const header = `Delete ${disk}`;
|
||||
const body = `<p>Are you sure you want to <strong>delete</strong> disk${disk}</p>`;
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
deleteBootLine(`boot-${disk}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDiskAdd () {
|
||||
const content = type === "qemu" ? "images" : "rootdir";
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
|
||||
const header = "Create New Disk";
|
||||
|
||||
let options = "";
|
||||
storage.data.forEach((element) => {
|
||||
if (element.content.includes(content)) {
|
||||
options += `<option value="${element.storage}">${element.storage}</option>"`;
|
||||
}
|
||||
});
|
||||
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${options}</select>`;
|
||||
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="device">${type === "qemu" ? "SATA" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" value="0" required></input>
|
||||
${select}
|
||||
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required></input>
|
||||
</form>
|
||||
`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
storage: form.get("storage-select"),
|
||||
size: form.get("size")
|
||||
};
|
||||
const id = form.get("device");
|
||||
const prefix = type === "qemu" ? "sata" : "mp";
|
||||
const disk = `${prefix}${id}`;
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
addBootLine("disabled", { id: disk, prefix, value: disk, detail: config.data[disk] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCDAdd () {
|
||||
const isos = await requestAPI("/user/vm-isos", "GET");
|
||||
|
||||
const header = "Add a CDROM";
|
||||
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required></input>
|
||||
<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") {
|
||||
const body = {
|
||||
iso: form.get("iso-select")
|
||||
};
|
||||
const disk = `ide${form.get("device")}`;
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
addBootLine("disabled", { id: disk, prefix: "ide", value: disk, detail: config.data[disk] });
|
||||
}
|
||||
});
|
||||
|
||||
const isoSelect = d.querySelector("#iso-select");
|
||||
|
||||
for (const iso of isos) {
|
||||
isoSelect.append(new Option(iso.name, iso.volid));
|
||||
}
|
||||
isoSelect.selectedIndex = -1;
|
||||
}
|
||||
|
||||
async function populateNetworks () {
|
||||
document.querySelector("#networks").innerHTML = "";
|
||||
const networks = {};
|
||||
const prefix = networkMetaData.prefix;
|
||||
Object.keys(config.data).forEach((element) => {
|
||||
if (element.startsWith(prefix)) {
|
||||
networks[element.replace(prefix, "")] = config.data[element];
|
||||
}
|
||||
});
|
||||
const orderedKeys = getOrdered(networks);
|
||||
orderedKeys.forEach((element) => {
|
||||
addNetworkLine("networks", prefix, element, networks[element]);
|
||||
});
|
||||
|
||||
document.querySelector("#network-add").addEventListener("click", handleNetworkAdd);
|
||||
}
|
||||
|
||||
function addNetworkLine (fieldset, prefix, netID, netDetails) {
|
||||
const field = document.querySelector(`#${fieldset}`);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.src = "images/resources/network.svg";
|
||||
icon.alt = `${prefix}${netID}`;
|
||||
icon.dataset.network = netID;
|
||||
icon.dataset.values = netDetails;
|
||||
field.appendChild(icon);
|
||||
|
||||
const netLabel = document.createElement("p");
|
||||
netLabel.innerText = `${prefix}${netID}`;
|
||||
netLabel.dataset.network = netID;
|
||||
netLabel.dataset.values = netDetails;
|
||||
field.append(netLabel);
|
||||
|
||||
const netDesc = document.createElement("p");
|
||||
netDesc.innerText = netDetails;
|
||||
netDesc.dataset.network = netID;
|
||||
netDesc.dataset.values = netDetails;
|
||||
netDesc.style.overflowX = "hidden";
|
||||
netDesc.style.whiteSpace = "nowrap";
|
||||
field.append(netDesc);
|
||||
|
||||
const actionDiv = document.createElement("div");
|
||||
|
||||
const configBtn = document.createElement("img");
|
||||
configBtn.classList.add("clickable");
|
||||
configBtn.src = "images/actions/network/config.svg";
|
||||
configBtn.title = "Config Interface";
|
||||
configBtn.addEventListener("click", handleNetworkConfig);
|
||||
configBtn.dataset.network = netID;
|
||||
configBtn.dataset.values = netDetails;
|
||||
actionDiv.appendChild(configBtn);
|
||||
|
||||
const deleteBtn = document.createElement("img");
|
||||
deleteBtn.classList.add("clickable");
|
||||
deleteBtn.src = "images/actions/delete-active.svg";
|
||||
deleteBtn.title = "Delete Interface";
|
||||
deleteBtn.addEventListener("click", handleNetworkDelete);
|
||||
deleteBtn.dataset.network = netID;
|
||||
deleteBtn.dataset.values = netDetails;
|
||||
actionDiv.appendChild(deleteBtn);
|
||||
|
||||
field.append(actionDiv);
|
||||
}
|
||||
|
||||
async function handleNetworkConfig () {
|
||||
const netID = this.dataset.network;
|
||||
const netDetails = this.dataset.values;
|
||||
const header = `Edit net${netID}`;
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
||||
</form>
|
||||
`;
|
||||
|
||||
const d = dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-network="${netID}"]`).src = "images/status/loading.svg";
|
||||
const body = {
|
||||
rate: form.get("rate")
|
||||
};
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/net${netID}/modify`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateNetworks();
|
||||
const id = `net${netID}`;
|
||||
updateBootLine(`boot-net${netID}`, { id, prefix: "net", value: id, detail: config.data[`net${netID}`] });
|
||||
}
|
||||
});
|
||||
|
||||
d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0];
|
||||
}
|
||||
|
||||
async function handleNetworkDelete () {
|
||||
const netID = this.dataset.network;
|
||||
const header = `Delete net${netID}`;
|
||||
const body = "";
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-network="${netID}"]`).src = "images/status/loading.svg";
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/net${netID}/delete`, "DELETE");
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateNetworks();
|
||||
deleteBootLine(`boot-net${netID}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleNetworkAdd () {
|
||||
const header = "Create Network Interface";
|
||||
let body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
|
||||
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
|
||||
`;
|
||||
if (type === "lxc") {
|
||||
body += "<label for=\"name\">Interface Name</label><input type=\"text\" id=\"name\" name=\"name\" class=\"w3-input w3-border\"></input>";
|
||||
}
|
||||
body += "</form>";
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
rate: form.get("rate")
|
||||
};
|
||||
if (type === "lxc") {
|
||||
body.name = form.get("name");
|
||||
}
|
||||
const netID = form.get("netid");
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/net${netID}/create`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateNetworks();
|
||||
const id = `net${netID}`;
|
||||
addBootLine("disabled", { id, prefix: "net", value: id, detail: config.data[`net${netID}`] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function populateDevices () {
|
||||
if (type === "qemu") {
|
||||
document.querySelector("#devices-card").classList.remove("none");
|
||||
document.querySelector("#devices").innerHTML = "";
|
||||
const devices = {};
|
||||
const prefix = pcieMetaData.prefix;
|
||||
Object.keys(config.data).forEach((element) => {
|
||||
if (element.startsWith(prefix)) {
|
||||
devices[element.replace(prefix, "")] = config.data[element];
|
||||
}
|
||||
});
|
||||
const orderedKeys = getOrdered(devices);
|
||||
orderedKeys.forEach(async (element) => {
|
||||
const deviceData = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/hostpci${element}`, "GET");
|
||||
addDeviceLine("devices", prefix, element, devices[element], deviceData.device_name);
|
||||
});
|
||||
|
||||
document.querySelector("#device-add").addEventListener("click", handleDeviceAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function addDeviceLine (fieldset, prefix, deviceID, deviceDetails, deviceName) {
|
||||
const field = document.querySelector(`#${fieldset}`);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.src = "images/resources/device.svg";
|
||||
icon.alt = `${prefix}${deviceID}`;
|
||||
icon.dataset.device = deviceID;
|
||||
icon.dataset.values = deviceDetails;
|
||||
icon.dataset.name = deviceName;
|
||||
field.appendChild(icon);
|
||||
|
||||
const deviceLabel = document.createElement("p");
|
||||
deviceLabel.innerText = deviceName;
|
||||
deviceLabel.dataset.device = deviceID;
|
||||
deviceLabel.dataset.values = deviceDetails;
|
||||
deviceLabel.dataset.name = deviceName;
|
||||
deviceLabel.style.overflowX = "hidden";
|
||||
deviceLabel.style.whiteSpace = "nowrap";
|
||||
field.append(deviceLabel);
|
||||
|
||||
const actionDiv = document.createElement("div");
|
||||
|
||||
const configBtn = document.createElement("img");
|
||||
configBtn.classList.add("clickable");
|
||||
configBtn.src = "images/actions/device/config.svg";
|
||||
configBtn.title = "Config Device";
|
||||
configBtn.addEventListener("click", handleDeviceConfig);
|
||||
configBtn.dataset.device = deviceID;
|
||||
configBtn.dataset.values = deviceDetails;
|
||||
configBtn.dataset.name = deviceName;
|
||||
actionDiv.appendChild(configBtn);
|
||||
|
||||
const deleteBtn = document.createElement("img");
|
||||
deleteBtn.classList.add("clickable");
|
||||
deleteBtn.src = "images/actions/delete-active.svg";
|
||||
deleteBtn.title = "Delete Device";
|
||||
deleteBtn.addEventListener("click", handleDeviceDelete);
|
||||
deleteBtn.dataset.device = deviceID;
|
||||
deleteBtn.dataset.values = deviceDetails;
|
||||
deleteBtn.dataset.name = deviceName;
|
||||
actionDiv.appendChild(deleteBtn);
|
||||
|
||||
field.append(actionDiv);
|
||||
}
|
||||
|
||||
async function handleDeviceConfig () {
|
||||
const deviceID = this.dataset.device;
|
||||
const deviceDetails = this.dataset.values;
|
||||
const deviceName = this.dataset.name;
|
||||
const header = `Edit Expansion Card ${deviceID}`;
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
|
||||
</form>
|
||||
`;
|
||||
|
||||
const d = dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-device="${deviceID}"]`).src = "images/status/loading.svg";
|
||||
const body = {
|
||||
device: form.get("device"),
|
||||
pcie: form.get("pcie") ? 1 : 0
|
||||
};
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/hostpci${deviceID}/modify`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDevices();
|
||||
}
|
||||
});
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
|
||||
for (const availDevice of availDevices) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.id));
|
||||
}
|
||||
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
||||
}
|
||||
|
||||
async function handleDeviceDelete () {
|
||||
const deviceID = this.dataset.device;
|
||||
const header = `Remove Expansion Card ${deviceID}`;
|
||||
const body = "";
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
document.querySelector(`img[data-device="${deviceID}"]`).src = "images/status/loading.svg";
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/hostpci${deviceID}/delete`, "DELETE");
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDevices();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeviceAdd () {
|
||||
const header = "Add Expansion Card";
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="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") {
|
||||
const body = {
|
||||
device: form.get("device"),
|
||||
pcie: form.get("pcie") ? 1 : 0
|
||||
};
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/create`, "POST", body);
|
||||
if (result.status !== 200) {
|
||||
alert(result.error);
|
||||
}
|
||||
await getConfig();
|
||||
populateDevices();
|
||||
}
|
||||
});
|
||||
|
||||
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||
for (const availDevice of availDevices) {
|
||||
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.id));
|
||||
}
|
||||
d.querySelector("#pcie").checked = true;
|
||||
}
|
||||
|
||||
async function populateBoot () {
|
||||
if (type === "qemu") {
|
||||
document.querySelector("#boot-card").classList.remove("none");
|
||||
document.querySelector("#enabled").title = "Enabled";
|
||||
document.querySelector("#disabled").title = "Disabled";
|
||||
let order = [];
|
||||
if (config.data.boot.startsWith("order=")) {
|
||||
order = config.data.boot.replace("order=", "").split(";");
|
||||
}
|
||||
const bootable = { disabled: [] };
|
||||
const eligible = bootMetaData.eligiblePrefixes;
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const element = order[i];
|
||||
const prefix = eligible.find((pref) => order[i].startsWith(pref));
|
||||
const detail = config.data[element];
|
||||
bootable[i] = { id: element, value: element, prefix, detail };
|
||||
}
|
||||
Object.keys(config.data).forEach((element) => {
|
||||
const prefix = eligible.find((pref) => element.startsWith(pref));
|
||||
const detail = config.data[element];
|
||||
if (prefix && !order.includes(element)) {
|
||||
bootable.disabled.push({ id: element, value: element, prefix, detail });
|
||||
}
|
||||
});
|
||||
Object.keys(bootable).sort();
|
||||
Object.keys(bootable).forEach((element) => {
|
||||
if (element !== "disabled") {
|
||||
addBootLine("enabled", bootable[element], document.querySelector("#enabled-spacer"));
|
||||
}
|
||||
else {
|
||||
bootable.disabled.forEach((item) => {
|
||||
addBootLine("disabled", item, document.querySelector("#disabled-spacer"));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addBootLine (container, data, before = null) {
|
||||
const item = document.createElement("draggable-item");
|
||||
item.data = data;
|
||||
item.innerHTML = `
|
||||
<div style="display: grid; grid-template-columns: auto auto 8ch 1fr; column-gap: 10px; align-items: center;">
|
||||
<img src="images/actions/drag.svg" id="drag" alt="drag icon">
|
||||
<img src="${bootMetaData[data.prefix].icon}" alt="${bootMetaData[data.prefix].alt}">
|
||||
<p style="margin: 0px;">${data.id}</p>
|
||||
<p style="margin: 0px; overflow-x: hidden; white-space: nowrap;">${data.detail}</p>
|
||||
</div>
|
||||
`;
|
||||
item.draggable = true;
|
||||
item.classList.add("drop-target");
|
||||
item.id = `boot-${data.id}`;
|
||||
if (before) {
|
||||
document.querySelector(`#${container}`).insertBefore(item, before);
|
||||
}
|
||||
else {
|
||||
document.querySelector(`#${container}`).append(item);
|
||||
}
|
||||
item.container = container;
|
||||
item.value = data.value;
|
||||
}
|
||||
|
||||
function deleteBootLine (id) {
|
||||
const query = `#${id}`;
|
||||
const enabled = document.querySelector("#enabled");
|
||||
const disabled = document.querySelector("#disabled");
|
||||
const inEnabled = enabled.querySelector(query);
|
||||
const inDisabled = disabled.querySelector(query);
|
||||
if (inEnabled) {
|
||||
enabled.removeChild(inEnabled);
|
||||
}
|
||||
if (inDisabled) {
|
||||
disabled.removeChild(inDisabled);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBootLine (id, newData) {
|
||||
const enabled = document.querySelector("#enabled");
|
||||
const disabled = document.querySelector("#disabled");
|
||||
let element = null;
|
||||
if (enabled.querySelector(`#${id}`)) {
|
||||
element = enabled.querySelector(`#${id}`);
|
||||
}
|
||||
if (disabled.querySelector(`#${id}`)) {
|
||||
element = disabled.querySelector(`#${id}`);
|
||||
}
|
||||
if (element) {
|
||||
const container = element.container;
|
||||
const before = element.nextSibling;
|
||||
deleteBootLine(id);
|
||||
addBootLine(container, newData, before);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormExit () {
|
||||
const body = {
|
||||
cores: document.querySelector("#cores").value,
|
||||
memory: document.querySelector("#ram").value
|
||||
};
|
||||
if (type === "lxc") {
|
||||
body.swap = document.querySelector("#swap").value;
|
||||
}
|
||||
else if (type === "qemu") {
|
||||
body.proctype = document.querySelector("#proctype").value;
|
||||
body.boot = document.querySelector("#enabled").value;
|
||||
}
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/resources`, "POST", body);
|
||||
if (result.status === 200) {
|
||||
await getConfig();
|
||||
populateDisk();
|
||||
goToPage("index.html");
|
||||
}
|
||||
else {
|
||||
alert(result.error);
|
||||
}
|
||||
}
|
@@ -1,211 +0,0 @@
|
||||
// Map valid UUIDs used by draggable-item elements in order to better validate data transfers to ignore random data transfers.
|
||||
const draggableItemUUIDs = {};
|
||||
|
||||
/**
|
||||
* Get the data transfer source object by parsing its types. Valid draggable-item events have one type of the format `application/json/${uuid}`.
|
||||
* The function takes the entire type list from event.dataTransfer.types and returns the source object if valid, or null if invalid.
|
||||
* @param {*} typesList from event.dataTransfer.types
|
||||
* @returns {Object} Object containing the type, uuid, and element of the dataTransfer source or null
|
||||
*/
|
||||
function getDragSource (typesList) {
|
||||
if (typesList.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const typeString = typesList[0];
|
||||
const type = typeString.split("/");
|
||||
if (type.length === 3 && type[0] === "application" && type[1] === "json" && draggableItemUUIDs[type[2]]) {
|
||||
return { type: typeString, uuid: type[2], element: draggableItemUUIDs[type[2]] };
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableContainer extends HTMLElement {
|
||||
constructor () {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<label id="title"></label>
|
||||
<div id="wrapper">
|
||||
<draggable-item id="bottom" class="drop-target"></draggable-item>
|
||||
</div>
|
||||
`;
|
||||
this.content = this.shadowRoot.querySelector("#wrapper");
|
||||
this.bottom = this.shadowRoot.querySelector("#bottom");
|
||||
this.titleElem = this.shadowRoot.querySelector("#title");
|
||||
}
|
||||
|
||||
get title () {
|
||||
return this.titleElem.innerText;
|
||||
}
|
||||
|
||||
set title (title) {
|
||||
this.titleElem.innerText = title;
|
||||
}
|
||||
|
||||
append (newNode) {
|
||||
newNode.uuid = window.crypto.randomUUID();
|
||||
draggableItemUUIDs[newNode.uuid] = newNode;
|
||||
this.content.insertBefore(newNode, this.bottom);
|
||||
}
|
||||
|
||||
insertBefore (newNode, referenceNode) {
|
||||
newNode.uuid = window.crypto.randomUUID();
|
||||
draggableItemUUIDs[newNode.uuid] = newNode;
|
||||
this.content.insertBefore(newNode, referenceNode);
|
||||
}
|
||||
|
||||
querySelector (query) {
|
||||
return this.content.querySelector(query);
|
||||
}
|
||||
|
||||
removeChild (node) {
|
||||
if (node && this.content.contains(node)) {
|
||||
draggableItemUUIDs[node.uuid] = null;
|
||||
this.content.removeChild(node);
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
set value (value) {}
|
||||
|
||||
get value () {
|
||||
const value = [];
|
||||
this.content.childNodes.forEach((element) => {
|
||||
if (element.value) {
|
||||
value.push(element.value);
|
||||
}
|
||||
});
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableItem extends HTMLElement {
|
||||
uuid = null;
|
||||
constructor () {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
// for whatever reason, only grid layout seems to respect the parent's content bounds
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
#drag-over {
|
||||
height: 1.5em;
|
||||
border: 1px dotted var(--main-text-color);
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0,0,0,0.25);
|
||||
}
|
||||
img {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
</style>
|
||||
<div id="drag-over" style="display: none;"></div>
|
||||
<div id="wrapper">
|
||||
<div style="min-height: 1.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
this.content = this.shadowRoot.querySelector("#wrapper");
|
||||
// add drag and drop listeners
|
||||
this.addEventListener("dragstart", (event) => {
|
||||
this.content.style.opacity = "0.5";
|
||||
const data = { id: this.id, data: this.data, content: this.content.innerHTML, value: this.value };
|
||||
event.dataTransfer.setData(`application/json/${this.uuid}`, JSON.stringify(data));
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
const blank = document.createElement("img");
|
||||
event.dataTransfer.setDragImage(blank, 0, 0);
|
||||
setTimeout(() => {
|
||||
this.content.style.visibility = "hidden";
|
||||
this.content.style.height = "0";
|
||||
}, 0);
|
||||
});
|
||||
this.addEventListener("dragend", (event) => {
|
||||
if (event.dataTransfer.dropEffect === "move") {
|
||||
this.parentElement.removeChild(this);
|
||||
}
|
||||
else {
|
||||
this.content.attributeStyleMap.clear();
|
||||
}
|
||||
});
|
||||
this.addEventListener("dragenter", (event) => {
|
||||
const sourceElement = getDragSource(event.dataTransfer.types);
|
||||
if (event.target.dropTarget && sourceElement) {
|
||||
event.target.dragOver = sourceElement.element.innerHTML;
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
this.addEventListener("dragleave", (event) => {
|
||||
if (event.target.dragOver && getDragSource(event.dataTransfer.types)) {
|
||||
event.target.dragOver = false;
|
||||
}
|
||||
event.preventDefault();
|
||||
});
|
||||
this.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
this.addEventListener("drop", (event) => {
|
||||
if (event.target.dragOver) {
|
||||
event.target.dragOver = false;
|
||||
}
|
||||
const sourceElement = getDragSource(event.dataTransfer.types);
|
||||
if (event.target.dropTarget && sourceElement) {
|
||||
const transfer = JSON.parse(event.dataTransfer.getData(sourceElement.type));
|
||||
const item = document.createElement("draggable-item");
|
||||
item.data = transfer.data;
|
||||
item.innerHTML = transfer.content;
|
||||
item.draggable = true;
|
||||
item.dropTarget = true;
|
||||
item.id = transfer.id;
|
||||
item.value = transfer.data.value;
|
||||
item.uuid = sourceElement.uuid;
|
||||
event.target.parentElement.insertBefore(item, event.target);
|
||||
}
|
||||
this.content.attributeStyleMap.clear();
|
||||
event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
get innerHTML () {
|
||||
return this.content.innerHTML;
|
||||
}
|
||||
|
||||
set innerHTML (innerHTML) {
|
||||
this.content.innerHTML = innerHTML;
|
||||
}
|
||||
|
||||
get dropTarget () {
|
||||
return this.classList.contains("drop-target");
|
||||
}
|
||||
|
||||
set dropTarget (dropTarget) {
|
||||
if (dropTarget) {
|
||||
this.classList.add("drop-target");
|
||||
}
|
||||
else {
|
||||
this.classList.remove("drop-target");
|
||||
}
|
||||
}
|
||||
|
||||
get dragOver () {
|
||||
return this.classList.contains("drag-over");
|
||||
}
|
||||
|
||||
set dragOver (dragOver) {
|
||||
if (dragOver) {
|
||||
this.classList.add("drag-over");
|
||||
this.shadowRoot.querySelector("#drag-over").style.display = "block";
|
||||
this.shadowRoot.querySelector("#drag-over").innerHTML = dragOver;
|
||||
}
|
||||
else {
|
||||
this.classList.remove("drag-over");
|
||||
this.shadowRoot.querySelector("#drag-over").style.display = "none";
|
||||
this.shadowRoot.querySelector("#drag-over").innerHTML = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("draggable-container", DraggableContainer);
|
||||
customElements.define("draggable-item", DraggableItem);
|
248
scripts/index.js
@@ -1,248 +0,0 @@
|
||||
import { requestPVE, requestAPI, goToPage, setTitleAndHeader } from "./utils.js";
|
||||
import { alert, dialog } from "./dialog.js";
|
||||
import { setupClientSync } from "./clientsync.js";
|
||||
import wf_align from "../modules/wfa.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
let instances = [];
|
||||
|
||||
async function init () {
|
||||
setTitleAndHeader();
|
||||
const cookie = document.cookie;
|
||||
if (cookie === "") {
|
||||
goToPage("login.html");
|
||||
}
|
||||
|
||||
document.querySelector("#instance-add").addEventListener("click", handleInstanceAdd);
|
||||
document.querySelector("#vm-search").addEventListener("input", populateInstances);
|
||||
|
||||
setupClientSync(refreshInstances);
|
||||
}
|
||||
|
||||
async function refreshInstances () {
|
||||
await getInstances();
|
||||
await populateInstances();
|
||||
}
|
||||
|
||||
async function getInstances () {
|
||||
const resources = await requestPVE("/cluster/resources", "GET");
|
||||
instances = [];
|
||||
resources.data.forEach((element) => {
|
||||
if (element.type === "lxc" || element.type === "qemu") {
|
||||
const nodeName = element.node;
|
||||
const nodeStatus = resources.data.find(item => item.node === nodeName && item.type === "node").status;
|
||||
element.node = { name: nodeName, status: nodeStatus };
|
||||
instances.push(element);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function populateInstances () {
|
||||
let searchCriteria = localStorage.getItem("search-criteria");
|
||||
if (!searchCriteria) {
|
||||
searchCriteria = "fuzzy";
|
||||
localStorage.setItem("search-criteria", "fuzzy");
|
||||
}
|
||||
const searchQuery = document.querySelector("#search").value || null;
|
||||
let criteria;
|
||||
if (!searchQuery) {
|
||||
criteria = (a, b) => {
|
||||
return (a.vmid > b.vmid) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
else if (searchCriteria === "exact") {
|
||||
criteria = (a, b) => {
|
||||
const aInc = a.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const bInc = b.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
if (aInc && bInc) {
|
||||
return a.vmid > b.vmid ? 1 : -1;
|
||||
}
|
||||
else if (aInc && !bInc) {
|
||||
return -1;
|
||||
}
|
||||
else if (!aInc && bInc) {
|
||||
return 1;
|
||||
}
|
||||
else {
|
||||
return a.vmid > b.vmid ? 1 : -1;
|
||||
}
|
||||
};
|
||||
}
|
||||
else if (searchCriteria === "fuzzy") {
|
||||
const penalties = {
|
||||
m: 0,
|
||||
x: 1,
|
||||
o: 1,
|
||||
e: 1
|
||||
};
|
||||
criteria = (a, b) => {
|
||||
// lower is better
|
||||
const aAlign = wf_align(a.name.toLowerCase(), searchQuery.toLowerCase(), penalties);
|
||||
const aScore = aAlign.score / a.name.length;
|
||||
const bAlign = wf_align(b.name.toLowerCase(), searchQuery.toLowerCase(), penalties);
|
||||
const bScore = bAlign.score / b.name.length;
|
||||
if (aScore === bScore) {
|
||||
return a.vmid > b.vmid ? 1 : -1;
|
||||
}
|
||||
else {
|
||||
return aScore - bScore;
|
||||
}
|
||||
};
|
||||
}
|
||||
instances.sort(criteria);
|
||||
const instanceContainer = document.querySelector("#instance-container");
|
||||
instanceContainer.innerHTML = "";
|
||||
for (let i = 0; i < instances.length; i++) {
|
||||
const newInstance = document.createElement("instance-card");
|
||||
instances[i].searchQuery = searchQuery;
|
||||
newInstance.data = instances[i];
|
||||
instanceContainer.append(newInstance);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstanceAdd () {
|
||||
const header = "Create New Instance";
|
||||
|
||||
const body = `
|
||||
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||
<label for="type">Instance Type</label>
|
||||
<select class="w3-select w3-border" name="type" id="type" required>
|
||||
<option value="lxc">Container</option>
|
||||
<option value="qemu">Virtual Machine</option>
|
||||
</select>
|
||||
<label for="node">Node</label>
|
||||
<select class="w3-select w3-border" name="node" id="node" required></select>
|
||||
<label for="name">Name</label>
|
||||
<input class="w3-input w3-border" name="name" id="name" required></input>
|
||||
<label for="vmid">ID</label>
|
||||
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required></input>
|
||||
<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></input>
|
||||
<label for="memory">Memory (MiB)</label>
|
||||
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required></input>
|
||||
<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></input>
|
||||
<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></input>
|
||||
<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></input>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const templates = await requestAPI("/user/ct-templates", "GET");
|
||||
|
||||
const d = dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
const body = {
|
||||
name: form.get("name"),
|
||||
cores: form.get("cores"),
|
||||
memory: form.get("memory"),
|
||||
pool: form.get("pool")
|
||||
};
|
||||
if (form.get("type") === "lxc") {
|
||||
body.swap = form.get("swap");
|
||||
body.password = form.get("password");
|
||||
body.ostemplate = form.get("template-image");
|
||||
body.rootfslocation = form.get("rootfs-storage");
|
||||
body.rootfssize = form.get("rootfs-size");
|
||||
}
|
||||
const node = form.get("node");
|
||||
const type = form.get("type");
|
||||
const vmid = form.get("vmid");
|
||||
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/create`, "POST", body);
|
||||
if (result.status === 200) {
|
||||
populateInstances();
|
||||
}
|
||||
else {
|
||||
alert(result.error);
|
||||
populateInstances();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const typeSelect = d.querySelector("#type");
|
||||
typeSelect.selectedIndex = -1;
|
||||
typeSelect.addEventListener("change", () => {
|
||||
if (typeSelect.value === "qemu") {
|
||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||
element.classList.add("none");
|
||||
element.disabled = true;
|
||||
});
|
||||
}
|
||||
else {
|
||||
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||
element.classList.remove("none");
|
||||
element.disabled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const rootfsContent = "rootdir";
|
||||
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
const userResources = await requestAPI("/user/dynamic/resources", "GET");
|
||||
const userCluster = await requestAPI("/user/config/cluster", "GET");
|
||||
|
||||
const nodeSelect = d.querySelector("#node");
|
||||
const clusterNodes = await requestPVE("/nodes", "GET");
|
||||
const allowedNodes = Object.keys(userCluster.nodes);
|
||||
clusterNodes.data.forEach((element) => {
|
||||
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||
nodeSelect.add(new Option(element.node));
|
||||
}
|
||||
});
|
||||
nodeSelect.selectedIndex = -1;
|
||||
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
|
||||
const node = nodeSelect.value;
|
||||
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||
storage.data.forEach((element) => {
|
||||
if (element.content.includes(rootfsContent)) {
|
||||
rootfsStorage.add(new Option(element.storage));
|
||||
}
|
||||
});
|
||||
rootfsStorage.selectedIndex = -1;
|
||||
|
||||
// set core and memory min/max depending on node selected
|
||||
if (node in userResources.cores.nodes) {
|
||||
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#cores").max = userResources.cores.global.avail;
|
||||
}
|
||||
|
||||
if (node in userResources.memory.nodes) {
|
||||
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
|
||||
}
|
||||
else {
|
||||
d.querySelector("#memory").max = userResources.memory.global.avail;
|
||||
}
|
||||
});
|
||||
|
||||
// set vmid min/max
|
||||
d.querySelector("#vmid").min = userCluster.vmid.min;
|
||||
d.querySelector("#vmid").max = userCluster.vmid.max;
|
||||
|
||||
// add user pools to selector
|
||||
const poolSelect = d.querySelector("#pool");
|
||||
const userPools = Object.keys(userCluster.pools);
|
||||
userPools.forEach((element) => {
|
||||
poolSelect.add(new Option(element));
|
||||
});
|
||||
poolSelect.selectedIndex = -1;
|
||||
|
||||
// add template images to selector
|
||||
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
|
||||
for (const template of templates) {
|
||||
templateImage.append(new Option(template.name, template.volid));
|
||||
}
|
||||
templateImage.selectedIndex = -1;
|
||||
}
|
@@ -1,252 +0,0 @@
|
||||
import { requestPVE, requestAPI, goToPage, goToURL, instancesConfig, nodesConfig } from "./utils.js";
|
||||
import { PVE } from "../vars.js";
|
||||
import { dialog } from "./dialog.js";
|
||||
|
||||
class InstanceCard extends HTMLElement {
|
||||
constructor () {
|
||||
super();
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot.innerHTML = `
|
||||
<link rel="stylesheet" href="w3.css">
|
||||
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
<div class="w3-row" style="margin-top: 1em; margin-bottom: 1em;">
|
||||
<hr class="w3-show-small w3-hide-medium w3-hide-large" style="margin: 0; margin-bottom: 1em;">
|
||||
<div class="w3-col l1 m2 s6">
|
||||
<p id="instance-id"></p>
|
||||
</div>
|
||||
<div class="w3-col l2 m3 s6">
|
||||
<p id="instance-name"></p>
|
||||
</div>
|
||||
<div class="w3-col l1 m2 w3-hide-small">
|
||||
<p id="instance-type"></p>
|
||||
</div>
|
||||
<div class="w3-col l2 m3 s6 flex row nowrap">
|
||||
<img id="instance-status-icon">
|
||||
<p id="instance-status"></p>
|
||||
</div>
|
||||
<div class="w3-col l2 w3-hide-medium w3-hide-small">
|
||||
<p id="node-name"></p>
|
||||
</div>
|
||||
<div class="w3-col l2 w3-hide-medium w3-hide-small flex row nowrap">
|
||||
<img id="node-status-icon">
|
||||
<p id="node-status"></p>
|
||||
</div>
|
||||
<div class="w3-col l2 m2 s6 flex row nowrap" style="height: 1lh;">
|
||||
<img id="power-btn">
|
||||
<img id="console-btn">
|
||||
<img id="configure-btn">
|
||||
<img id="delete-btn">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.actionLock = false;
|
||||
}
|
||||
|
||||
get data () {
|
||||
return {
|
||||
type: this.type,
|
||||
status: this.status,
|
||||
vmid: this.status,
|
||||
name: this.name,
|
||||
node: this.node,
|
||||
searchQuery: this.searchQuery
|
||||
};
|
||||
}
|
||||
|
||||
set data (data) {
|
||||
if (data.status === "unknown") {
|
||||
data.status = "stopped";
|
||||
}
|
||||
this.type = data.type;
|
||||
this.status = data.status;
|
||||
this.vmid = data.vmid;
|
||||
this.name = data.name;
|
||||
this.node = data.node;
|
||||
this.searchQuery = data.searchQuery;
|
||||
this.update();
|
||||
}
|
||||
|
||||
update () {
|
||||
const vmidParagraph = this.shadowRoot.querySelector("#instance-id");
|
||||
vmidParagraph.innerText = this.vmid;
|
||||
|
||||
const nameParagraph = this.shadowRoot.querySelector("#instance-name");
|
||||
if (this.searchQuery) {
|
||||
const regExpEscape = v => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const escapedQuery = regExpEscape(this.searchQuery);
|
||||
const searchRegExp = new RegExp(`(${escapedQuery})`, "gi");
|
||||
const nameParts = this.name.split(searchRegExp);
|
||||
for (let i = 0; i < nameParts.length; i++) {
|
||||
const part = document.createElement("span");
|
||||
part.innerText = nameParts[i];
|
||||
if (nameParts[i].toLowerCase() === this.searchQuery.toLowerCase()) {
|
||||
part.style = "color: var(--lightbg-text-color); background-color: var(--highlight-color);";
|
||||
}
|
||||
nameParagraph.append(part);
|
||||
}
|
||||
}
|
||||
else {
|
||||
nameParagraph.innerHTML = this.name ? this.name : " ";
|
||||
}
|
||||
|
||||
const typeParagraph = this.shadowRoot.querySelector("#instance-type");
|
||||
typeParagraph.innerText = this.type;
|
||||
|
||||
const statusParagraph = this.shadowRoot.querySelector("#instance-status");
|
||||
statusParagraph.innerText = this.status;
|
||||
|
||||
const statusIcon = this.shadowRoot.querySelector("#instance-status-icon");
|
||||
statusIcon.src = instancesConfig[this.status].status.src;
|
||||
statusIcon.alt = instancesConfig[this.status].status.alt;
|
||||
|
||||
const nodeNameParagraph = this.shadowRoot.querySelector("#node-name");
|
||||
nodeNameParagraph.innerText = this.node.name;
|
||||
|
||||
const nodeStatusParagraph = this.shadowRoot.querySelector("#node-status");
|
||||
nodeStatusParagraph.innerText = this.node.status;
|
||||
|
||||
const nodeStatusIcon = this.shadowRoot.querySelector("#node-status-icon");
|
||||
nodeStatusIcon.src = nodesConfig[this.node.status].status.src;
|
||||
nodeStatusIcon.alt = nodesConfig[this.node.status].status.src;
|
||||
|
||||
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
||||
powerButton.src = instancesConfig[this.status].power.src;
|
||||
powerButton.alt = instancesConfig[this.status].power.alt;
|
||||
powerButton.title = instancesConfig[this.status].power.alt;
|
||||
if (instancesConfig[this.status].power.clickable) {
|
||||
powerButton.classList.add("clickable");
|
||||
powerButton.onclick = this.handlePowerButton.bind(this);
|
||||
}
|
||||
|
||||
const configButton = this.shadowRoot.querySelector("#configure-btn");
|
||||
configButton.src = instancesConfig[this.status].config.src;
|
||||
configButton.alt = instancesConfig[this.status].config.alt;
|
||||
configButton.title = instancesConfig[this.status].config.alt;
|
||||
if (instancesConfig[this.status].config.clickable) {
|
||||
configButton.classList.add("clickable");
|
||||
configButton.onclick = this.handleConfigButton.bind(this);
|
||||
}
|
||||
|
||||
const consoleButton = this.shadowRoot.querySelector("#console-btn");
|
||||
consoleButton.src = instancesConfig[this.status].console.src;
|
||||
consoleButton.alt = instancesConfig[this.status].console.alt;
|
||||
consoleButton.title = instancesConfig[this.status].console.alt;
|
||||
if (instancesConfig[this.status].console.clickable) {
|
||||
consoleButton.classList.add("clickable");
|
||||
consoleButton.onclick = this.handleConsoleButton.bind(this);
|
||||
}
|
||||
|
||||
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||
deleteButton.src = instancesConfig[this.status].delete.src;
|
||||
deleteButton.alt = instancesConfig[this.status].delete.alt;
|
||||
deleteButton.title = instancesConfig[this.status].delete.alt;
|
||||
if (instancesConfig[this.status].delete.clickable) {
|
||||
deleteButton.classList.add("clickable");
|
||||
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||
}
|
||||
|
||||
if (this.node.status !== "online") {
|
||||
powerButton.classList.add("hidden");
|
||||
configButton.classList.add("hidden");
|
||||
consoleButton.classList.add("hidden");
|
||||
deleteButton.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
async handlePowerButton () {
|
||||
if (!this.actionLock) {
|
||||
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`;
|
||||
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
const targetAction = this.status === "running" ? "stop" : "start";
|
||||
const targetStatus = this.status === "running" ? "stopped" : "running";
|
||||
const prevStatus = this.status;
|
||||
this.status = "loading";
|
||||
|
||||
this.update();
|
||||
|
||||
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
|
||||
|
||||
const waitFor = delay => new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
while (true) {
|
||||
const taskStatus = await requestPVE(`/nodes/${this.node.name}/tasks/${result.data}/status`, "GET");
|
||||
if (taskStatus.data.status === "stopped" && taskStatus.data.exitstatus === "OK") { // task stopped and was successful
|
||||
this.status = targetStatus;
|
||||
this.update();
|
||||
this.actionLock = false;
|
||||
break;
|
||||
}
|
||||
else if (taskStatus.data.status === "stopped") { // task stopped but was not successful
|
||||
this.status = prevStatus;
|
||||
alert(`attempted to ${targetAction} ${this.vmid} but process returned stopped:${result.data.exitstatus}`);
|
||||
this.update();
|
||||
this.actionLock = false;
|
||||
break;
|
||||
}
|
||||
else { // task has not stopped
|
||||
await waitFor(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleConfigButton () {
|
||||
if (!this.actionLock && this.status === "stopped") { // if the action lock is false, and the node is stopped, then navigate to the conig page with the node infor in the search query
|
||||
goToPage("config.html", { 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(PVE, data, true);
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteButton () {
|
||||
if (!this.actionLock && this.status === "stopped") {
|
||||
const header = `Delete VM ${this.vmid}`;
|
||||
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`;
|
||||
|
||||
dialog(header, body, async (result, form) => {
|
||||
if (result === "confirm") {
|
||||
this.actionLock = true;
|
||||
this.status = "loading";
|
||||
this.update();
|
||||
|
||||
const action = {};
|
||||
action.purge = 1;
|
||||
action["destroy-unreferenced-disks"] = 1;
|
||||
|
||||
const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE");
|
||||
if (result.status === 200) {
|
||||
if (this.parentElement) {
|
||||
this.parentElement.removeChild(this);
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert(result.error);
|
||||
this.status = this.prevStatus;
|
||||
this.update();
|
||||
this.actionLock = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("instance-card", InstanceCard);
|
@@ -1,29 +0,0 @@
|
||||
import { setTitleAndHeader } from "./utils.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
function init () {
|
||||
setTitleAndHeader();
|
||||
const scheme = localStorage.getItem("sync-scheme");
|
||||
if (scheme) {
|
||||
document.querySelector(`#sync-${scheme}`).checked = true;
|
||||
}
|
||||
const rate = localStorage.getItem("sync-rate");
|
||||
if (rate) {
|
||||
document.querySelector("#sync-rate").value = rate;
|
||||
}
|
||||
const search = localStorage.getItem("search-criteria");
|
||||
if (search) {
|
||||
document.querySelector(`#search-${search}`).checked = true;
|
||||
}
|
||||
document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false);
|
||||
}
|
||||
|
||||
function handleSaveSettings (event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(document.querySelector("#settings"));
|
||||
localStorage.setItem("sync-scheme", form.get("sync-scheme"));
|
||||
localStorage.setItem("sync-rate", form.get("sync-rate"));
|
||||
localStorage.setItem("search-criteria", form.get("search-criteria"));
|
||||
window.location.reload();
|
||||
}
|
269
scripts/utils.js
@@ -1,269 +0,0 @@
|
||||
import { API, organization } from "../vars.js";
|
||||
|
||||
export const resourcesConfig = {
|
||||
disk: {
|
||||
actionBarOrder: ["move", "resize", "detach_attach", "delete"],
|
||||
lxc: {
|
||||
prefixOrder: ["rootfs", "mp", "unused"],
|
||||
rootfs: { name: "ROOTFS", icon: "images/resources/drive.svg", actions: ["move", "resize"] },
|
||||
mp: { name: "MP", icon: "images/resources/drive.svg", actions: ["detach", "move", "reassign", "resize"] },
|
||||
unused: { name: "UNUSED", icon: "images/resources/drive.svg", actions: ["attach", "delete", "reassign"] }
|
||||
},
|
||||
qemu: {
|
||||
prefixOrder: ["ide", "sata", "unused"],
|
||||
ide: { name: "IDE", icon: "images/resources/disk.svg", actions: ["delete"] },
|
||||
sata: { name: "SATA", icon: "images/resources/drive.svg", actions: ["detach", "move", "reassign", "resize"] },
|
||||
unused: { name: "UNUSED", icon: "images/resources/drive.svg", actions: ["attach", "delete", "reassign"] }
|
||||
}
|
||||
},
|
||||
network: {
|
||||
prefix: "net"
|
||||
},
|
||||
pcie: {
|
||||
prefix: "hostpci"
|
||||
}
|
||||
};
|
||||
|
||||
export const instancesConfig = {
|
||||
running: {
|
||||
status: {
|
||||
src: "images/status/active.svg",
|
||||
alt: "Instance is running",
|
||||
clickable: false
|
||||
},
|
||||
power: {
|
||||
src: "images/actions/instance/stop.svg",
|
||||
alt: "Shutdown Instance",
|
||||
clickable: true
|
||||
},
|
||||
config: {
|
||||
src: "images/actions/instance/config-inactive.svg",
|
||||
alt: "Change Configuration (Inactive)",
|
||||
clickable: false
|
||||
},
|
||||
console: {
|
||||
src: "images/actions/instance/console-active.svg",
|
||||
alt: "Open Console",
|
||||
clickable: true
|
||||
},
|
||||
delete: {
|
||||
src: "images/actions/delete-inactive.svg",
|
||||
alt: "Delete Instance (Inactive)",
|
||||
clickable: false
|
||||
}
|
||||
},
|
||||
stopped: {
|
||||
status: {
|
||||
src: "images/status/inactive.svg",
|
||||
alt: "Instance is stopped",
|
||||
clickable: false
|
||||
},
|
||||
power: {
|
||||
src: "images/actions/instance/start.svg",
|
||||
alt: "Start Instance",
|
||||
clickable: true
|
||||
},
|
||||
config: {
|
||||
src: "images/actions/instance/config-active.svg",
|
||||
alt: "Change Configuration",
|
||||
clickable: true
|
||||
},
|
||||
console: {
|
||||
src: "images/actions/instance/console-inactive.svg",
|
||||
alt: "Open Console (Inactive)",
|
||||
clickable: false
|
||||
},
|
||||
delete: {
|
||||
src: "images/actions/delete-active.svg",
|
||||
alt: "Delete Instance",
|
||||
clickable: true
|
||||
}
|
||||
},
|
||||
loading: {
|
||||
status: {
|
||||
src: "images/status/loading.svg",
|
||||
alt: "Instance is loading",
|
||||
clickable: false
|
||||
},
|
||||
power: {
|
||||
src: "images/status/loading.svg",
|
||||
alt: "Loading Instance",
|
||||
clickable: false
|
||||
},
|
||||
config: {
|
||||
src: "images/actions/instance/config-inactive.svg",
|
||||
alt: "Change Configuration (Inactive)",
|
||||
clickable: false
|
||||
},
|
||||
console: {
|
||||
src: "images/actions/instance/console-inactive.svg",
|
||||
alt: "Open Console (Inactive)",
|
||||
clickable: false
|
||||
},
|
||||
delete: {
|
||||
src: "images/actions/delete-inactive.svg",
|
||||
alt: "Delete Instance (Inactive)",
|
||||
clickable: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const nodesConfig = {
|
||||
online: {
|
||||
status: {
|
||||
src: "images/status/active.svg",
|
||||
alt: "Node is online"
|
||||
}
|
||||
},
|
||||
offline: {
|
||||
status: {
|
||||
src: "images/status/inactive.svg",
|
||||
alt: "Node is offline"
|
||||
}
|
||||
},
|
||||
uknown: {
|
||||
status: {
|
||||
src: "images/status/inactive.svg",
|
||||
alt: "Node is offline"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const bootConfig = {
|
||||
eligiblePrefixes: ["ide", "sata", "net"],
|
||||
ide: {
|
||||
icon: "images/resources/disk.svg",
|
||||
alt: "IDE Bootable Icon"
|
||||
},
|
||||
sata: {
|
||||
icon: "images/resources/drive.svg",
|
||||
alt: "SATA Bootable Icon"
|
||||
},
|
||||
net: {
|
||||
icon: "images/resources/network.svg",
|
||||
alt: "NET Bootable Icon"
|
||||
}
|
||||
};
|
||||
|
||||
export function getCookie (cname) {
|
||||
const name = cname + "=";
|
||||
const decodedCookie = decodeURIComponent(document.cookie);
|
||||
const ca = decodedCookie.split(";");
|
||||
for (let i = 0; i < ca.length; i++) {
|
||||
let c = ca[i];
|
||||
while (c.charAt(0) === " ") {
|
||||
c = c.substring(1);
|
||||
}
|
||||
if (c.indexOf(name) === 0) {
|
||||
return c.substring(name.length, c.length);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export async function requestTicket (username, password, realm) {
|
||||
const response = await requestAPI("/auth/ticket", "POST", { username: `${username}@${realm}`, password }, false);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function requestPVE (path, method, body = null) {
|
||||
const prms = new URLSearchParams(body);
|
||||
const content = {
|
||||
method,
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
};
|
||||
if (method === "POST") {
|
||||
content.body = prms.toString();
|
||||
content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken");
|
||||
}
|
||||
|
||||
const response = await request(`${API}/proxmox${path}`, content);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function requestAPI (path, method, body = null) {
|
||||
const prms = new URLSearchParams(body);
|
||||
const content = {
|
||||
method,
|
||||
mode: "cors",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
};
|
||||
if (method === "POST" || method === "DELETE") {
|
||||
content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken");
|
||||
}
|
||||
if (body) {
|
||||
content.body = prms.toString();
|
||||
}
|
||||
|
||||
const response = await request(`${API}${path}`, content);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function request (url, content) {
|
||||
try {
|
||||
const response = await fetch(url, content);
|
||||
const contentType = response.headers.get("Content-Type");
|
||||
let data = null;
|
||||
if (contentType.includes("application/json")) {
|
||||
data = await response.json();
|
||||
data.status = response.status;
|
||||
}
|
||||
else if (contentType.includes("text/html")) {
|
||||
data = { data: await response.text() };
|
||||
data.status = response.status;
|
||||
}
|
||||
else {
|
||||
data = response;
|
||||
}
|
||||
if (!response.ok) {
|
||||
return { status: response.status, error: data ? data.error : response.status };
|
||||
}
|
||||
else {
|
||||
data.status = response.status;
|
||||
return data || response;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
return { status: 400, error };
|
||||
}
|
||||
}
|
||||
|
||||
export function goToPage (page, data = null) {
|
||||
const params = data ? (new URLSearchParams(data)).toString() : "";
|
||||
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, `${organization} - dashboard`, "height=480,width=848");
|
||||
}
|
||||
else {
|
||||
window.location.assign(url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
export function getURIData () {
|
||||
const url = new URL(window.location.href);
|
||||
return Object.fromEntries(url.searchParams);
|
||||
}
|
||||
|
||||
export async function deleteAllCookies () {
|
||||
await requestAPI("/auth/ticket", "DELETE");
|
||||
}
|
||||
|
||||
export function setTitleAndHeader () {
|
||||
document.title = `${organization} - dashboard`;
|
||||
document.querySelector("h1").innerText = organization;
|
||||
}
|
@@ -1,85 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>proxmox - dashboard</title>
|
||||
<link rel="icon" href="images/favicon.svg" sizes="any" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="w3.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="css/nav.css">
|
||||
<link rel="stylesheet" href="css/form.css">
|
||||
<script src="scripts/settings.js" type="module"></script>
|
||||
<style>
|
||||
legend {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
column-gap: 10px;
|
||||
}
|
||||
label + p {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>proxmox</h1>
|
||||
<label for="navtoggle">☰</label>
|
||||
<input type="checkbox" id="navtoggle">
|
||||
<nav>
|
||||
<a href="index.html">Instances</a>
|
||||
<a href="account.html">Account</a>
|
||||
<a href="settings.html" aria-current="page">Settings</a>
|
||||
<a href="login.html">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
<form id = "settings">
|
||||
<div class="w3-card w3-padding">
|
||||
<h3>Synchronization Settings</h3>
|
||||
<fieldset>
|
||||
<legend>App Sync Method</legend>
|
||||
<label><input class="w3-radio" type="radio" id="sync-always" name="sync-scheme" value="always" required>Always Sync</label>
|
||||
<p>App will always periodically synchronize with Proxmox. High resource usage.</p>
|
||||
<label><input class="w3-radio" type="radio" id="sync-hash" name="sync-scheme" value="hash" required>Check For Sync</label>
|
||||
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
|
||||
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
|
||||
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>App Sync Frequency</legend>
|
||||
<div class="input-grid" style="grid-template-columns: auto auto 1fr;">
|
||||
<p>Sync every</p><input aria-label="sync rate in seconds" class="w3-input w3-border" type="number" id="sync-rate" name="sync-rate" min="1" required><p>Second(s)</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="w3-card w3-padding">
|
||||
<h3>Search Settings</h3>
|
||||
<fieldset>
|
||||
<legend>Instance Search Criteria</legend>
|
||||
<label><input class="w3-radio" type="radio" id="search-exact" name="search-criteria" value="exact" required>Exact Match</label>
|
||||
<p>Sorts by exact query match in instance name.</p>
|
||||
<label><input class="w3-radio" type="radio" id="search-fuzzy" name="search-criteria" value="fuzzy" required>Fuzzy Match</label>
|
||||
<p>Sorts by best matching to worst matching.</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="w3-container w3-center" id="form-actions">
|
||||
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
@@ -1,3 +0,0 @@
|
||||
export const API = "https://paas.mydomain.example/api"; // the proxmox-aas api
|
||||
export const PVE = "https://pve.mydomain.example"; // the proxmox api
|
||||
export const organization = "mydomain"; // org name used in page title and nav bar
|
@@ -8,6 +8,16 @@
|
||||
.input-grid * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-grid input {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.input-grid svg {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input-grid .last-item {
|
||||
@@ -50,6 +60,23 @@ input[type="radio"] {
|
||||
position: inherit;
|
||||
}
|
||||
|
||||
div[draggable="true"] {
|
||||
cursor: grab;
|
||||
.w3-select, select {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.w3-check {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* sibling of input-grid that is not inside another input grid */
|
||||
:not(.input-grid) .input-grid + * {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
max-width: calc(min(50%, 80ch));
|
||||
background-color: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
@@ -1,15 +1,19 @@
|
||||
:root {
|
||||
--nav-bg-color: black;
|
||||
--nav-text-color: white;
|
||||
--nav-header-bg-color: #0f0;
|
||||
--nav-header-text-color: black;
|
||||
--nav-link-active-text-color: white;
|
||||
--nav-link-active-bg-color: var(--main-bg-color, #404040);
|
||||
--nav-transition-speed: 250ms;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: light) {
|
||||
:root {
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
:root, :root.dark-theme {
|
||||
--nav-bg-color: black;
|
||||
--nav-text-color: white;
|
||||
--nav-header-bg-color: #0f0;
|
||||
--nav-header-text-color: black;
|
||||
--nav-link-active-text-color: white;
|
||||
--nav-link-active-bg-color: var(--main-bg-color, #404040);
|
||||
|
||||
}
|
||||
|
||||
:root.light-theme {
|
||||
--nav-bg-color: black;
|
||||
--nav-text-color: white;
|
||||
--nav-header-bg-color: #0f0;
|
||||
@@ -19,6 +23,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: light) {
|
||||
:root, :root.light-theme {
|
||||
--nav-bg-color: black;
|
||||
--nav-text-color: white;
|
||||
--nav-header-bg-color: #0f0;
|
||||
--nav-header-text-color: black;
|
||||
--nav-link-active-text-color: black;
|
||||
--nav-link-active-bg-color: var(--main-bg-color, white);
|
||||
}
|
||||
|
||||
:root.dark-theme {
|
||||
--nav-bg-color: black;
|
||||
--nav-text-color: white;
|
||||
--nav-header-bg-color: #0f0;
|
||||
--nav-header-text-color: black;
|
||||
--nav-link-active-text-color: white;
|
||||
--nav-link-active-bg-color: var(--main-bg-color, #404040);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
header {
|
||||
display: grid;
|
||||
align-items: center;
|
173
web/css/style.css
Normal file
@@ -0,0 +1,173 @@
|
||||
:root {
|
||||
--negative-color: #f00;
|
||||
--positive-color: #0f0;
|
||||
--highlight-color: yellow;
|
||||
--lightbg-text-color: black;
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
:root, :root.dark-theme {
|
||||
--main-bg-color: #404040;
|
||||
--main-text-color: white;
|
||||
--main-card-bg-color: #202020;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
||||
--main-table-header-bg-color: black;
|
||||
--main-input-bg-color: #404040;
|
||||
}
|
||||
|
||||
:root.light-theme {
|
||||
--main-bg-color: white;
|
||||
--main-text-color: black;
|
||||
--main-card-bg-color: white;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||
--main-table-header-bg-color: #808080;
|
||||
--main-input-bg-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (prefers-color-scheme: light) {
|
||||
:root, :root.light-theme {
|
||||
--main-bg-color: white;
|
||||
--main-text-color: black;
|
||||
--main-card-bg-color: white;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 20%), 0 2px 10px 0 rgb(0 0 0 / 20%);
|
||||
--main-table-header-bg-color: #808080;
|
||||
--main-input-bg-color: white;
|
||||
}
|
||||
|
||||
:root.dark-theme {
|
||||
--main-bg-color: #404040;
|
||||
--main-text-color: white;
|
||||
--main-card-bg-color: #202020;
|
||||
--main-card-box-shadow: 0 2px 5px 0 rgb(0 0 0 / 80%), 0 2px 10px 0 rgb(0 0 0 / 80%);
|
||||
--main-table-header-bg-color: black;
|
||||
--main-input-bg-color: #404040;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
background-color: var(--main-bg-color);
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 100vw;
|
||||
background-color: var(--main-bg-color);
|
||||
color: var(--main-text-color);
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.w3-card {
|
||||
background-color: var(--main-card-bg-color);
|
||||
box-shadow: var(--main-card-box-shadow);
|
||||
}
|
||||
|
||||
.w3-card + .w3-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--main-table-header-bg-color);
|
||||
}
|
||||
|
||||
td {
|
||||
background-color: var(--main-card-bg-color);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
background-color: var(--main-input-bg-color);
|
||||
color: var(--main-text-color);
|
||||
}
|
||||
|
||||
img.clickable, svg.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
img, svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
color: var(--main-text-color)
|
||||
}
|
||||
|
||||
hr, * {
|
||||
border-color: var(--main-text-color);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: row;
|
||||
column-gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 10px;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
@media screen and (width >= 440px) {
|
||||
button .large {
|
||||
display: block;
|
||||
}
|
||||
|
||||
button .small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 440px) {
|
||||
button .large {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button .small {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* add hide large class similar to w3-hide-medium and w3-hide-small */
|
||||
@media (width >=993px) {
|
||||
.w3-hide-large {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* fix edge case in w3-hide-medium where width between 992 and 993 */
|
||||
@media (width <=993px) and (width >=601px){
|
||||
.w3-hide-medium{display:none!important}
|
||||
}
|
||||
|
||||
/* fix edge case in w3-hide-small when width between 600 and 601 */
|
||||
@media (width <=601px) {
|
||||
.w3-hide-small{display:none!important}
|
||||
}
|
21
web/embed.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed css/*
|
||||
var CSS_fs embed.FS
|
||||
|
||||
//go:embed images/*
|
||||
var Images_fs embed.FS
|
||||
|
||||
//go:embed modules/*
|
||||
var Modules_fs embed.FS
|
||||
|
||||
//go:embed scripts/*
|
||||
var Scripts_fs embed.FS
|
||||
|
||||
//go:embed html/*
|
||||
//go:embed templates/*
|
||||
var Templates embed.FS
|
77
web/html/account.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="scripts/account.js" type="module"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
<style>
|
||||
@media screen and (width >= 1264px){
|
||||
#resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, calc(100% / 6));
|
||||
grid-gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 1264px) and (width >= 680px) {
|
||||
#resource-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 200px);
|
||||
grid-gap: 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 680px) {
|
||||
#resource-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<h2>Account</h2>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Account Details</h3>
|
||||
<p id="username">Username: {{.account.Username}}</p>
|
||||
<p id="pool">Pools: {{MapKeys .account.Pools ", "}}</p>
|
||||
<p id="vmid">VMID Range: {{.account.VMID.Min}} - {{.account.VMID.Max}}</p>
|
||||
<p id="nodes">Nodes: {{MapKeys .account.Nodes ", "}}</p>
|
||||
</section>
|
||||
<section class="w3-card w3-padding">
|
||||
<div class="flex row nowrap">
|
||||
<h3>Password</h3>
|
||||
<button class="w3-button w3-margin" id="change-password" type="button">Change Password</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Cluster Resources</h3>
|
||||
<div id="resource-container">
|
||||
{{range .account.Resources}}
|
||||
{{if .Display}}
|
||||
{{if eq .Type "numeric"}}
|
||||
{{template "resource-chart" .}}
|
||||
{{end}}
|
||||
{{if eq .Type "storage"}}
|
||||
{{template "resource-chart" .}}
|
||||
{{end}}
|
||||
{{if eq .Type "list"}}
|
||||
{{range .Resources}}
|
||||
{{template "resource-chart" .}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
1
web/html/config-boot.frag
Normal file
@@ -0,0 +1 @@
|
||||
{{template "boot" .config.Boot}}
|
1
web/html/config-devices.frag
Normal file
@@ -0,0 +1 @@
|
||||
{{template "devices" .config.Devices}}
|
1
web/html/config-nets.frag
Normal file
@@ -0,0 +1 @@
|
||||
{{template "nets" .config.Nets}}
|
1
web/html/config-volumes.frag
Normal file
@@ -0,0 +1 @@
|
||||
{{template "volumes" .config.Volumes}}
|
100
web/html/config.html
Normal file
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="scripts/config.js" type="module"></script>
|
||||
<script src="scripts/draggable.js" type="module"></script>
|
||||
<script src="modules/Sortable.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
<style>
|
||||
.input-grid p, .input-grid div {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2><a href="index">Instances</a> / {{.config.Name}}</h2>
|
||||
<form>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Resources</legend>
|
||||
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
|
||||
{{if eq .config.Type "VM"}}
|
||||
{{template "proctype-input" .config.ProctypeSelect}}
|
||||
{{end}}
|
||||
{{template "cores-input" .config.Cores}}
|
||||
{{template "memory-input" .config.Memory}}
|
||||
{{if eq .config.Type "CT"}}
|
||||
{{template "swap-input" .config.Swap}}
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Volumes</legend>
|
||||
<div class="input-grid" id="volumes" style="grid-template-columns: auto auto 1fr auto;">
|
||||
{{template "volumes" .config.Volumes}}
|
||||
</div>
|
||||
<div class="w3-container w3-center">
|
||||
<button type="button" id="disk-add" class="w3-button" aria-label="Add New Disk">
|
||||
<span class="large" style="margin: 0;">Add Disk</span>
|
||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Disk"><use href="images/actions/disk/add-disk.svg#symb"></use></svg>
|
||||
</button>
|
||||
{{if eq .config.Type "VM"}}
|
||||
<button type="button" id="cd-add" class="w3-button" aria-label="Add New CD">
|
||||
<span class="large" style="margin: 0;">Mount CD</span>
|
||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New CDROM"><use href="images/actions/disk/add-cd.svg#symb"></use></svg>
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Network Interfaces</legend>
|
||||
<div class="input-grid" id="networks" style="grid-template-columns: auto auto 1fr auto;">
|
||||
{{template "nets" .config.Nets}}
|
||||
</div>
|
||||
<div class="w3-container w3-center">
|
||||
<button type="button" id="network-add" class="w3-button" aria-label="Add New Network Interface">
|
||||
<span class="large" style="margin: 0;">Add Network</span>
|
||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New Network Interface"><use href="images/actions/network/add.svg#symb"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
{{if eq .config.Type "VM"}}
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>PCIe Devices</legend>
|
||||
<div class="input-grid" id="devices" style="grid-template-columns: auto auto 1fr auto;">
|
||||
{{template "devices" .config.Devices}}
|
||||
</div>
|
||||
<div class="w3-container w3-center">
|
||||
<button type="button" id="device-add" class="w3-button" aria-label="Add New PCIe Device">
|
||||
<span class="large" style="margin: 0;">Add Device</span>
|
||||
<svg class="small" role="img" style="height: 1lh; width: 1lh;" aria-label="Add New PCIe Device"><use href="images/actions/device/add.svg#symb"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="w3-card w3-padding">
|
||||
<legend>Boot Order</legend>
|
||||
<div id="boot-order">
|
||||
{{template "boot" .config.Boot}}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{end}}
|
||||
<div class="w3-container w3-center" id="form-actions">
|
||||
<button class="w3-button w3-margin" id="exit" type="button">EXIT</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
3
web/html/index-instances.frag
Normal file
@@ -0,0 +1,3 @@
|
||||
{{range .instances}}
|
||||
{{template "instance-card" .}}
|
||||
{{end}}
|
67
web/html/index.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="scripts/index.js" type="module"></script>
|
||||
<script src="modules/wfa.js" type="module"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
<link rel="modulepreload" href="scripts/clientsync.js">
|
||||
<style>
|
||||
#instance-container > div {
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
#instance-container > div:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@media screen and (width >= 440px) {
|
||||
#vm-search {
|
||||
max-width: calc(100% - 10px - 152px);
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 440px) {
|
||||
#vm-search {
|
||||
max-width: calc(100% - 10px - 47px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Instances</h2>
|
||||
<div class="w3-card w3-padding">
|
||||
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
||||
<form id="vm-search" role="search" class="flex row nowrap">
|
||||
<svg role="img" aria-label="Search Instances"><use href="images/common/search.svg#symb"></use></svg>
|
||||
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
|
||||
</form>
|
||||
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
||||
<span class="large" style="margin: 0;">Create Instance</span>
|
||||
<svg class="small" style="height: 1lh; width: 1lh;" role="img" aria-label="Create New Instance"><use href="images/actions/instance/add.svg#symb"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w3-row w3-hide-small" style="border-bottom: 1px solid;">
|
||||
<p class="w3-col l1 m2 w3-hide-small">ID</p>
|
||||
<p class="w3-col l2 m3 w3-hide-small">Name</p>
|
||||
<p class="w3-col l1 m2 w3-hide-small">Type</p>
|
||||
<p class="w3-col l2 m3 w3-hide-small">Status</p>
|
||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Name</p>
|
||||
<p class="w3-col l2 w3-hide-medium w3-hide-small">Host Status</p>
|
||||
<p class="w3-col l2 m2 w3-hide-small">Actions</p>
|
||||
</div>
|
||||
<div id="instance-container">
|
||||
{{range .instances}}
|
||||
{{template "instance-card" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
30
web/html/login.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="scripts/login.js" type="module"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<link rel="modulepreload" href="scripts/dialog.js">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main class="flex" style="justify-content: center; align-items: center;">
|
||||
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
||||
<form>
|
||||
<label for="username"><b>Username</b></label>
|
||||
<input class="w3-input w3-border" id="username" name="username" type="text" autocomplete="username">
|
||||
<label for="password"><b>Password</b></label>
|
||||
<input class="w3-input w3-border" id="password" name="password" type="password" autocomplete="current-password">
|
||||
<label for="realm">Realm</label>
|
||||
{{template "select" .realms}}
|
||||
<div class="w3-center">
|
||||
<button class="w3-button w3-margin" id="submit" type="submit">LOGIN</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
81
web/html/settings.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{{template "head" .}}
|
||||
<script src="scripts/settings.js" type="module"></script>
|
||||
<link rel="modulepreload" href="scripts/utils.js">
|
||||
<style>
|
||||
legend {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
column-gap: 10px;
|
||||
}
|
||||
label + p {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{{template "header" .}}
|
||||
</header>
|
||||
<main>
|
||||
<h2>Settings</h2>
|
||||
<form id="settings">
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Synchronization Settings</h3>
|
||||
<fieldset>
|
||||
<legend>App Sync Method</legend>
|
||||
<label><input class="w3-radio" type="radio" id="sync-always" name="sync-scheme" value="always" required>Always Sync</label>
|
||||
<p>App will always periodically synchronize with Proxmox. High resource usage.</p>
|
||||
<label><input class="w3-radio" type="radio" id="sync-hash" name="sync-scheme" value="hash" required>Check For Sync</label>
|
||||
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
|
||||
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
|
||||
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>App Sync Frequency</legend>
|
||||
<div class="input-grid" style="grid-template-columns: auto auto 1fr;">
|
||||
<p>Sync every</p><input aria-label="sync rate in seconds" class="w3-input w3-border" type="number" id="sync-rate" name="sync-rate" min="1" required><p>Second(s)</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Search Settings</h3>
|
||||
<fieldset>
|
||||
<legend>Instance Search Criteria</legend>
|
||||
<label><input class="w3-radio" type="radio" id="search-exact" name="search-criteria" value="exact" required>Exact Match</label>
|
||||
<p>Sorts by exact query match in instance name.</p>
|
||||
<label><input class="w3-radio" type="radio" id="search-fuzzy" name="search-criteria" value="fuzzy" required>Fuzzy Match</label>
|
||||
<p>Sorts by best matching to worst matching.</p>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section class="w3-card w3-padding">
|
||||
<h3>Appearance</h3>
|
||||
<fieldset>
|
||||
<legend>Default Theme</legend>
|
||||
<label>Theme<select class="w3-select w3-border" id="appearance-theme" name="appearance-theme" style="width: fit-content; padding-right: 24px;">
|
||||
<option value="auto">Auto</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
</section>
|
||||
<div class="w3-container w3-center" id="form-actions">
|
||||
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
1
web/images/actions/device/add.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/add.svg
|
1
web/images/actions/device/config.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/config.svg
|
1
web/images/actions/device/delete-active.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/delete-active.svg
|
1
web/images/actions/device/delete-inactive.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/delete-inactive.svg
|
1
web/images/actions/disk/add-cd.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="add cd" viewBox="2.3 2.3 19.4 19.4" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g stroke="currentColor" stroke-width="1.25"><path d="M7 12h5m0 0h5m-5 0V7m0 5v5"/><circle cx="12" cy="12" r="9"/></g></svg>
|
After Width: | Height: | Size: 331 B |
1
web/images/actions/disk/add-disk.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="create disk" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><g fill="currentColor"><path d="M25 0H7a7 7 0 00-7 7v18a7 7 0 007 7h18a7 7 0 007-7V7a7 7 0 00-7-7zm5 25a5 5 0 01-5 5H7a5 5 0 01-5-5V7a5 5 0 015-5h18a5 5 0 015 5z"/><path d="M17 6h-2v9H6v2h9v9h2v-9h9v-2h-9V6z"/></g></svg>
|
After Width: | Height: | Size: 412 B |
1
web/images/actions/disk/attach.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="attach disk" viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg"><g fill="#0f0"><path d="M17 12h-2.85a6.25 6.25 0 00-6.21 5H2v2h5.93a6.22 6.22 0 006.22 5H17z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-1"/><path d="M28.23 17A6.25 6.25 0 0022 12h-3v12h3a6.22 6.22 0 006.22-5H34v-2z" class="prefix__prefix__clr-i-solid prefix__prefix__clr-i-solid-path-2"/><path fill="none" d="M0 0h36v36H0z"/></g></svg>
|
After Width: | Height: | Size: 467 B |
1
web/images/actions/disk/delete-active.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/delete-active.svg
|
1
web/images/actions/disk/delete-inactive.svg
Symbolic link
@@ -0,0 +1 @@
|
||||
../../common/delete-inactive.svg
|
1
web/images/actions/disk/detach.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="detach disk" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="red" d="M76.987 235.517H0v40.973h76.987c9.04 33.686 39.694 58.522 76.238 58.522h57.062V176.988h-57.062c-36.543 0-67.206 24.836-76.238 58.529zm435.013 0h-76.995c-9.032-33.693-39.686-58.53-76.23-58.53h-57.062v158.024h57.062c36.537 0 67.19-24.836 76.23-58.522H512v-40.972z"/></svg>
|
After Width: | Height: | Size: 398 B |
1
web/images/actions/disk/detach_attach-inactive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg"/>
|
After Width: | Height: | Size: 76 B |
1
web/images/actions/disk/move-active.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="move disk" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
After Width: | Height: | Size: 318 B |
1
web/images/actions/disk/move-inactive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M16 7l5 5m0 0l-5 5m5-5H3" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
After Width: | Height: | Size: 215 B |
1
web/images/actions/disk/resize-active.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="resize disk" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="currentColor"/></svg>
|
After Width: | Height: | Size: 317 B |
1
web/images/actions/disk/resize-inactive.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg id="symb" role="img" aria-label="" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 20a1 1 0 01-1-1v-6H5a1 1 0 010-2h6V5a1 1 0 012 0v6h6a1 1 0 010 2h-6v6a1 1 0 01-1 1z" fill="gray"/></svg>
|
After Width: | Height: | Size: 212 B |