Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6b49f0c85 | ||
|
|
b38c381cf0 | ||
|
|
33766d1205 | ||
|
|
a13331e5a5 | ||
|
|
40dc935eea | ||
|
|
382d207e0e | ||
|
|
3eca3da157 | ||
|
|
4743bcf756 | ||
|
|
73b58a843d | ||
|
|
e0ef1b598b | ||
|
|
f6164b3096 | ||
|
|
5c658db34e | ||
|
|
98790dbe3b | ||
|
|
db8ed2e830 | ||
|
|
f3aa769fb7 | ||
|
|
b92aabb052 | ||
|
|
77f26a3e2c | ||
|
|
9458840292 | ||
|
|
ed4cf064e3 | ||
|
|
c816d15b1a | ||
|
|
2fced6bd11 | ||
|
|
b2bb8ae69e | ||
|
|
c5067f1696 | ||
|
|
de8f5368a5 | ||
|
|
2a6641f03c | ||
|
|
a5b20519d4 | ||
|
|
e000a90456 | ||
|
|
b5c8576ab5 | ||
|
|
e2039a15b1 | ||
|
|
492ae56d48 | ||
|
|
033017864e | ||
|
|
cbed40d904 | ||
|
|
075ea07766 | ||
|
|
91be26e320 | ||
|
|
ff63d8dc15 | ||
|
|
ffa58e4218 | ||
|
|
613f01456f | ||
|
|
e8fd120ead | ||
|
|
ce1576fbd6 | ||
|
|
0caf27eb7d | ||
|
|
7f5c016e31 | ||
|
|
4582d9726d | ||
|
|
1f672e16e6 | ||
|
|
5786b7e1be | ||
|
|
ca386adc26 | ||
|
|
7809e6adb1 | ||
|
|
159b08a7ca | ||
|
|
33db3c15b8 | ||
|
|
526b2a0ff0 | ||
|
|
ff1f618ad7 | ||
|
|
47415fbdd9 | ||
|
|
3646bc1272 | ||
|
|
31d097bc19 | ||
|
|
0822b9f26c | ||
|
|
8710da338b | ||
|
|
415d60d228 | ||
|
|
cede3b4b7f | ||
|
|
3a7c4cd2ae | ||
|
|
428d5d15a5 | ||
|
|
3f2964db55 | ||
|
|
666fd92b30 | ||
|
|
2ef8837a03 | ||
|
|
b58e28ebb5 | ||
|
|
8f72aaf255 | ||
|
|
10ef24e76b | ||
|
|
00fa5f3152 | ||
|
|
94233000df | ||
|
|
d88a208da5 | ||
|
|
3b1b20b506 | ||
|
|
66747fa657 | ||
|
|
f40638598d | ||
|
|
0082f2f3e5 | ||
|
|
549316ab73 | ||
|
|
fc58861046 | ||
|
|
9b7404c8d6 | ||
|
|
08cd4dfaaa | ||
|
|
26a21e6cc7 | ||
|
|
c3fe936e05 | ||
|
|
eb201de26b | ||
|
|
545061b2eb | ||
|
|
0692c1b96c | ||
|
|
4f9d19eb39 | ||
|
|
c92c4c4e6d | ||
|
|
f44df0446e | ||
|
|
7475ae30b7 | ||
|
|
b2820a3051 | ||
|
|
7aadc03fc9 | ||
|
|
9606d62e78 | ||
|
|
3f754c65b9 | ||
|
|
5788e1b493 | ||
|
|
3636878bb7 | ||
|
|
6b094690f9 | ||
|
|
3d8e4b416d | ||
|
|
7a5888be82 | ||
|
|
d06f62f5e5 | ||
|
|
72b1179fc9 | ||
|
|
32c23daf9d | ||
|
|
b86be4c749 | ||
|
|
338ea3342e | ||
|
|
3b7d3ba01f | ||
|
|
9e1fca8597 | ||
|
|
c8ca49b85c | ||
|
|
2877f7709a | ||
|
|
7db0bea35c | ||
|
|
ff98eb318e | ||
|
|
05ced39598 | ||
|
|
4e2b6278d8 | ||
|
|
75e098b7b4 | ||
|
|
8c378a3b49 | ||
|
|
3d5989a946 | ||
|
|
06afdcec37 | ||
|
|
2f21b23535 | ||
|
|
e8dd28b519 | ||
|
|
e0c7a53d85 | ||
|
|
118b7dac53 | ||
|
|
db32f318b9 | ||
|
|
c13a4c8539 | ||
|
|
8d490cd336 | ||
|
|
343c149330 | ||
|
|
d95a82f248 | ||
|
|
fc42de2c49 | ||
|
|
3f723394c4 | ||
|
|
e7627b5787 | ||
|
|
7732da0642 | ||
|
|
87c42495ad | ||
|
|
89065254d2 | ||
|
|
08e5f8b392 | ||
|
|
8be935a421 | ||
|
|
f94dca7e0c | ||
|
|
8905886065 | ||
|
|
df6772c72b | ||
|
|
f3b6c0abf4 | ||
|
|
69fae92313 | ||
|
|
a79dd96d2a | ||
|
|
ee397c48e1 | ||
|
|
33b0a4b5ff | ||
|
|
65c8fbdca8 | ||
|
|
e932165a98 | ||
|
|
8c339794b3 | ||
|
|
a62fc83386 | ||
|
|
756aef587d | ||
|
|
ca555a7116 | ||
|
|
85c3ab49fc | ||
|
|
9ec277ce65 | ||
|
|
e41c8d2a07 | ||
|
|
308d133e6e | ||
|
|
99d58eb250 | ||
|
|
acd6eba520 | ||
|
|
478ca20451 | ||
|
|
28c60aecc9 | ||
|
|
3d677a46ee | ||
|
|
e170d7f93d | ||
|
|
85bd81ef30 | ||
|
|
53832b67a2 | ||
|
|
e6cd1fbb3d | ||
|
|
3f21f3c4a4 | ||
|
|
1bcbed6828 | ||
|
|
31bfa79e66 | ||
|
|
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 |
No files matched your search
@@ -1,42 +0,0 @@
|
|||||||
{
|
|
||||||
"env": {
|
|
||||||
"browser": true,
|
|
||||||
"es2021": true
|
|
||||||
},
|
|
||||||
"extends": "standard",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"no-tabs": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"allowIndentationTabs": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indent": [
|
|
||||||
"error",
|
|
||||||
"tab"
|
|
||||||
],
|
|
||||||
"linebreak-style": [
|
|
||||||
"error",
|
|
||||||
"unix"
|
|
||||||
],
|
|
||||||
"quotes": [
|
|
||||||
"error",
|
|
||||||
"double"
|
|
||||||
],
|
|
||||||
"semi": [
|
|
||||||
"error",
|
|
||||||
"always"
|
|
||||||
],
|
|
||||||
"brace-style": [
|
|
||||||
"error",
|
|
||||||
"stroustrup",
|
|
||||||
{
|
|
||||||
"allowSingleLine": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
vars.js
|
**/config.json
|
||||||
**/package-lock.json
|
dist/*
|
||||||
**/node_modules
|
go.sum
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[submodule "proxmoxaas-common-lib"]
|
||||||
|
path = proxmoxaas-common-lib
|
||||||
|
url = https://git.tronnet.net/tronnet/proxmoxaas-common-lib
|
||||||
|
[submodule "WFA-JS"]
|
||||||
|
path = WFA-JS
|
||||||
|
url = https://git.tronnet.net/alu/WFA-JS
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
.PHONY: test build build-wfa clean clean-wfa clean-web
|
||||||
|
|
||||||
|
build: clean ensure-dist build-wfa
|
||||||
|
@echo "======================== Building Binary ======================="
|
||||||
|
# resolve symbolic links in web by copying it into dist/web/
|
||||||
|
GODEBUG=embedfollowsymlinks=1 CGO_ENABLED=0 go build -tags release -ldflags="-s -w" -v -o dist/ .
|
||||||
|
|
||||||
|
test:
|
||||||
|
GODEBUG=embedfollowsymlinks=1 CGO_ENABLED=0 go run .
|
||||||
|
|
||||||
|
clean: clean-wfa clean-web
|
||||||
|
@echo "======================== Cleaning Project ======================"
|
||||||
|
go clean
|
||||||
|
rm -rf dist/*
|
||||||
|
|
||||||
|
ensure-dist:
|
||||||
|
mkdir -p dist
|
||||||
|
|
||||||
|
clean-web:
|
||||||
|
|
||||||
|
build-wfa:
|
||||||
|
$(MAKE) -C WFA-JS
|
||||||
|
cp -f WFA-JS/dist/* app/web/modules
|
||||||
|
|
||||||
|
clean-wfa:
|
||||||
|
$(MAKE) clean -C WFA-JS
|
||||||
@@ -9,32 +9,70 @@ ProxmoxAAS Dashboard provides users of a proxmox based compute on demand service
|
|||||||
- Remote console
|
- Remote console
|
||||||
- Extended account management
|
- Extended account management
|
||||||
|
|
||||||
## Prerequisites
|
# Installation
|
||||||
- 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)
|
|
||||||
|
|
||||||
## Notes
|
## ProxmoxAAS System Installation Overview
|
||||||
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.
|
|
||||||
|
The ProxmoxAAS project is large and is split into multiple components. There are four required components, the Dashboard, API, Fabric, and access-manager-api. The installation 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 | default port |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Proxmox VE API | pve.local | pve.domain.net | 8006 |
|
||||||
|
| ProxmoxAAS-Dashboard | dashboard.local | paas.domain.net | 8080 |
|
||||||
|
| ProxmoxAAS-API | api.local | paas.domain.net/api | 8081 |
|
||||||
|
| ProxmoxAAS-Fabric | fabric.local | N/A | 8082 |
|
||||||
|
| access-manager-api | access.local | N/A| 8083 |
|
||||||
|
|
||||||
|
## Prerequisites - Dashboard
|
||||||
|
- Proxmox VE Cluster (v7.0+)
|
||||||
|
- Reverse proxy server which can proxy the dashboard, API, and Fabric
|
||||||
|
- FQDN
|
||||||
|
|
||||||
## Installation - Dashboard
|
## Installation - Dashboard
|
||||||
1. Install Apache2 or another HTTP server onto a container or vm, which will be `Dashboard Host`
|
1. Initialize any host, which will be the `ProxmoxAAS-Dashboard` component host
|
||||||
2. Clone this repo onto `Dashboard Host`, the default location for web root is `/var/www/html/`
|
2. Download `proxmoxaas-dashboard` binary and `template.config.json` file from [releases](https://git.tronnet.net/tronnet/ProxmoxAAS-LDAP/releases)
|
||||||
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:
|
Rename `template.config.json` to `config.json` and modify:
|
||||||
```
|
- `listenPort`: port for PAAS-Dashboard to bind and listen on
|
||||||
<VirtualHost *:80>
|
- `organization`: name of your org which is displayed on the top left corner
|
||||||
DocumentRoot /var/www/html/ProxmoxAAS-Dashboard/
|
- `dashurl`: url for the dashboard, ie. `https://paas.domain.net`
|
||||||
</VirtualHost>
|
- `apiurl`: url for PAAS-API, ie. `https://paas.domain.net/api`
|
||||||
```
|
- `pveurl`: url for the Proxmox endpoint, ie. `https://pve.domain.net`
|
||||||
4. Enable the site by running `a2ensite dashboard`
|
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`
|
After this step, the Dashboard should be available on the `ProxmoxAAS-Dashboard` host at the configured `listenPort`
|
||||||
|
|
||||||
## 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`
|
|
||||||
|
|
||||||
## Installation - API
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Installation - access-manager-api
|
||||||
|
|
||||||
|
To install the access-manager-api component, go to [access-manager-api](https://git.tronnet.net/tronnet/access-manager-api). This is required for the app to function.
|
||||||
|
|
||||||
|
## Installation - Reverse Proxy
|
||||||
|
1. Configure nginx or preferred reverse proxy to reverse proxy the dashboard. A snippet is show for the minimal configuration assuming the default ports.
|
||||||
|
```
|
||||||
|
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 PAAS suite should be avaliable and fully functional.
|
||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
"proxmoxaas-dashboard/app/routes"
|
||||||
|
"proxmoxaas-dashboard/app/web"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
configPath := flag.String("config", "config.json", "path to config.json file")
|
||||||
|
flag.Parse()
|
||||||
|
common.Global = common.GetConfig(*configPath)
|
||||||
|
log.Printf("[INFO] Read in config from %s\n", *configPath)
|
||||||
|
|
||||||
|
// setup http router
|
||||||
|
router := http.NewServeMux()
|
||||||
|
|
||||||
|
m := common.InitMinify()
|
||||||
|
|
||||||
|
// setup static resources under web (css, images, modules, scripts)
|
||||||
|
static := common.MinifyStatic(m, web.Static)
|
||||||
|
router.HandleFunc("GET /websrc/{path...}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.PathValue("path")
|
||||||
|
data := static[path]
|
||||||
|
w.Header().Set("Content-Encoding", data.MimeType.Encoding)
|
||||||
|
w.Header().Set("Content-Type", data.MimeType.Type)
|
||||||
|
w.Write([]byte(data.Data))
|
||||||
|
})
|
||||||
|
|
||||||
|
// setup template resources
|
||||||
|
html := common.MinifyStatic(m, web.Templates)
|
||||||
|
common.TMPL = common.LoadHTML(html)
|
||||||
|
|
||||||
|
// dynamic routes for pages and page fragments
|
||||||
|
router.HandleFunc("GET /", routes.HandleGETIndex)
|
||||||
|
router.HandleFunc("GET /index", routes.HandleGETIndex)
|
||||||
|
router.HandleFunc("GET /index/instances", routes.HandleGETInstancesFragment)
|
||||||
|
router.HandleFunc("GET /account", routes.HandleGETAccount)
|
||||||
|
router.HandleFunc("GET /config", routes.HandleGETConfig)
|
||||||
|
router.HandleFunc("GET /config/volumes", routes.HandleGETConfigVolumesFragment)
|
||||||
|
router.HandleFunc("GET /config/nets", routes.HandleGETConfigNetsFragment)
|
||||||
|
router.HandleFunc("GET /config/devices", routes.HandleGETConfigDevicesFragment)
|
||||||
|
router.HandleFunc("GET /config/boot", routes.HandleGETConfigBootFragment)
|
||||||
|
router.HandleFunc("GET /backups", routes.HandleGETBackups)
|
||||||
|
router.HandleFunc("GET /backups/backups", routes.HandleGETBackupsFragment)
|
||||||
|
router.HandleFunc("GET /login", routes.HandleGETLogin)
|
||||||
|
router.HandleFunc("GET /settings", routes.HandleGETSettings)
|
||||||
|
|
||||||
|
server := paas.LoggingMiddleware(router)
|
||||||
|
|
||||||
|
log.Printf("[INFO] starting server listening on 0.0.0.0:%d", common.Global.Port)
|
||||||
|
log.Fatalf("[ERR ] starting gin router: %s", http.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", common.Global.Port), server))
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//go:build !release
|
||||||
|
|
||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/tdewolff/minify/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defines mime type and associated minifier
|
||||||
|
type MimeType struct {
|
||||||
|
Type string
|
||||||
|
Encoding string
|
||||||
|
Minifier func(m *minify.M, w io.Writer, r io.Reader, params map[string]string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// debug mime types
|
||||||
|
var MimeTypes = map[string]MimeType{
|
||||||
|
"css": {
|
||||||
|
Type: "text/css",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
Type: "text/html",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"tmpl": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"frag": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"svg": {
|
||||||
|
Type: "image/svg+xml",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"js": {
|
||||||
|
Type: "application/javascript",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"wasm": {
|
||||||
|
Type: "application/wasm",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"gz": {
|
||||||
|
Type: "application/wasm",
|
||||||
|
Encoding: "gzip",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"*": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//go:build release
|
||||||
|
|
||||||
|
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"
|
||||||
|
"github.com/tdewolff/minify/v2/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defines mime type and associated minifier
|
||||||
|
type MimeType struct {
|
||||||
|
Type string
|
||||||
|
Encoding 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: svg.Minify,
|
||||||
|
},
|
||||||
|
"js": {
|
||||||
|
Type: "application/javascript",
|
||||||
|
Minifier: js.Minify,
|
||||||
|
},
|
||||||
|
"wasm": {
|
||||||
|
Type: "application/wasm",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"gz": {
|
||||||
|
Type: "application/wasm",
|
||||||
|
Encoding: "gzip",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
"*": {
|
||||||
|
Type: "text/plain",
|
||||||
|
Minifier: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import "html/template"
|
||||||
|
|
||||||
|
var Global Config
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port int `json:"listenPort"`
|
||||||
|
Organization string `json:"organization"`
|
||||||
|
DASH string `json:"dashurl"`
|
||||||
|
PVE string `json:"pveurl"`
|
||||||
|
API string `json:"apiurl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// variable for html template root
|
||||||
|
// generated from LoadHTMLToGin
|
||||||
|
var TMPL *template.Template
|
||||||
|
|
||||||
|
// static served file type containing data and mimetype
|
||||||
|
type StaticFile struct {
|
||||||
|
Data string
|
||||||
|
MimeType MimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tdewolff/minify/v2"
|
||||||
|
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// get config file from configPath
|
||||||
|
func GetConfig(configPath string) Config {
|
||||||
|
root, err := os.OpenRoot(".")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[ERR ] error opening root dir: ", err)
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
|
||||||
|
content, err := root.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[ERR ] error opening config file: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
err = json.Unmarshal(content, &config)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("[ERR ] error parsing config file: ", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize minifier using the meta types specified
|
||||||
|
func InitMinify() *minify.M {
|
||||||
|
m := minify.New()
|
||||||
|
for _, v := range MimeTypes {
|
||||||
|
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)
|
||||||
|
err := fs.WalkDir(files, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !entry.IsDir() {
|
||||||
|
v, err := files.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("[ERR ] 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("[ERR ] 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
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ERR ] error in MinifyStatic: %s", err)
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
return minified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadHTML(html map[string]StaticFile) *template.Template {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"MapKeys": func(x any, sep string) string {
|
||||||
|
v := reflect.ValueOf(x)
|
||||||
|
keys := v.MapKeys()
|
||||||
|
var s strings.Builder
|
||||||
|
for i := range keys {
|
||||||
|
if i != 0 {
|
||||||
|
s.WriteString(sep)
|
||||||
|
}
|
||||||
|
s.WriteString(keys[i].String())
|
||||||
|
}
|
||||||
|
return s.String()
|
||||||
|
},
|
||||||
|
"Map": func(values ...any) (map[string]any, error) {
|
||||||
|
if len(values)%2 != 0 {
|
||||||
|
return nil, errors.New("invalid dict call")
|
||||||
|
}
|
||||||
|
dict := make(map[string]any, 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
root := template.New("").Funcs(funcMap)
|
||||||
|
if err := LoadAndAddToRoot(funcMap, root, html); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
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(w http.ResponseWriter, err error) {
|
||||||
|
log.Printf("[WARN] encountered a non-fatal error: %s", err.Error())
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequestGetAPI(path string, auth *paas.Auth, body any) (*http.Response, int, error) {
|
||||||
|
req, err := http.NewRequest("GET", Global.API+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
for k, v := range GetRequestContextFromCookies(*auth) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: k, Value: v, Secure: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
switch body.(type) { // write json to body object depending on type, currently supports map[string]any (ie json) or []any (ie array of json)
|
||||||
|
case *map[string]any:
|
||||||
|
err = json.Unmarshal(data, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.StatusCode, err
|
||||||
|
}
|
||||||
|
case *[]any:
|
||||||
|
err = json.Unmarshal(data, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.StatusCode, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, response.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAuthFromRequest(r *http.Request) (paas.Auth, error) {
|
||||||
|
_, errAuth := r.Cookie("auth")
|
||||||
|
username, errUsername := r.Cookie("username")
|
||||||
|
token, errToken := r.Cookie("PVEAuthCookie")
|
||||||
|
csrf, errCSRF := r.Cookie("CSRFPreventionToken")
|
||||||
|
access, errAccess := r.Cookie("PAASAccessManagerTicket")
|
||||||
|
|
||||||
|
if errUsername != nil || errAuth != nil || errToken != nil ||
|
||||||
|
errCSRF != nil || errAccess != nil {
|
||||||
|
return paas.Auth{}, fmt.Errorf(
|
||||||
|
"error occurred getting user cookies: (auth: %v, token: %v, csrf: %v)",
|
||||||
|
errAuth, errToken, errCSRF,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return paas.Auth{
|
||||||
|
Username: username.Value,
|
||||||
|
Token: token.Value,
|
||||||
|
CSRF: csrf.Value,
|
||||||
|
AccessManagerTicket: access.Value,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstancePathFromRequest(r *http.Request) (paas.InstancePath, error) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
|
||||||
|
req_node := query.Get("node")
|
||||||
|
req_type := query.Get("type")
|
||||||
|
req_vmid := query.Get("vmid")
|
||||||
|
|
||||||
|
if req_node == "" || req_type == "" || req_vmid == "" {
|
||||||
|
return paas.InstancePath{}, fmt.Errorf(
|
||||||
|
"request missing required values: (node: %s, type: %s, vmid: %s)",
|
||||||
|
req_node, req_type, req_vmid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
vmid_int, err := strconv.ParseUint(req_vmid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return paas.InstancePath{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
vm_path := paas.InstancePath{
|
||||||
|
NodeName: req_node,
|
||||||
|
InstanceType: paas.InstanceType(req_type),
|
||||||
|
InstanceID: paas.InstanceID(vmid_int),
|
||||||
|
}
|
||||||
|
|
||||||
|
return vm_path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetRequestContextFromCookies(auth paas.Auth) map[string]string {
|
||||||
|
return map[string]string{
|
||||||
|
"username": auth.Username,
|
||||||
|
"PVEAuthCookie": auth.Token,
|
||||||
|
"CSRFPreventionToken": auth.CSRF,
|
||||||
|
"PAASAccessManagerTicket": auth.AccessManagerTicket,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
|
||||||
|
"github.com/gerow/go-color"
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Pool struct {
|
||||||
|
paas.Pool `mapstructure:",squash"`
|
||||||
|
ResourceCharts map[string]map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceChart struct {
|
||||||
|
Type string
|
||||||
|
Display bool
|
||||||
|
Name string
|
||||||
|
Used int64
|
||||||
|
Max int64
|
||||||
|
Avail string
|
||||||
|
Prefix string
|
||||||
|
Unit string
|
||||||
|
ColorHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
var Red = color.RGB{
|
||||||
|
R: 1,
|
||||||
|
G: 0,
|
||||||
|
B: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
var Green = color.RGB{
|
||||||
|
R: 0,
|
||||||
|
G: 1,
|
||||||
|
B: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
user, err := GetUserBasic(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pools, err := GetUserPools(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/account.html", map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "account",
|
||||||
|
"user": user,
|
||||||
|
"pools": pools,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound) // if user is not authed, redirect user to login page
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserBasic(auth paas.Auth) (paas.User, error) {
|
||||||
|
user := paas.User{}
|
||||||
|
body := map[string]any{}
|
||||||
|
res, code, err := common.RequestGetAPI(fmt.Sprintf("/access/users/%s", auth.Username), &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return user, fmt.Errorf("request to /access/pools resulted in %+v", res)
|
||||||
|
}
|
||||||
|
err = mapstructure.Decode(body["user"], &user)
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserPools(auth paas.Auth) (map[string]Pool, error) {
|
||||||
|
pools := map[string]Pool{}
|
||||||
|
|
||||||
|
// get all pools
|
||||||
|
body := map[string]any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/access/pools", &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return pools, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return pools, fmt.Errorf("request to /access/pools resulted in %+v", res)
|
||||||
|
}
|
||||||
|
err = mapstructure.Decode(body["pools"].(map[string]any), &pools)
|
||||||
|
if err != nil {
|
||||||
|
return pools, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get global config for resource type metadata
|
||||||
|
body = map[string]any{}
|
||||||
|
// get resource meta data
|
||||||
|
res, code, err = common.RequestGetAPI("/global/config/resources", &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return pools, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return pools, fmt.Errorf("request to /global/config/resources resulted in %+v", res)
|
||||||
|
}
|
||||||
|
meta := body["resources"].(map[string]any)
|
||||||
|
|
||||||
|
// for each pool
|
||||||
|
for poolname, pool := range pools {
|
||||||
|
// for each resource in pool data
|
||||||
|
|
||||||
|
// create pool charts map
|
||||||
|
pool.ResourceCharts = make(map[string]map[string]any)
|
||||||
|
|
||||||
|
for k, v := range pool.Resources {
|
||||||
|
m := meta[k].(map[string]any)
|
||||||
|
t := m["type"].(string)
|
||||||
|
r := v.(map[string]any)
|
||||||
|
category := m["category"].(string)
|
||||||
|
|
||||||
|
// create a category if it does not already exist
|
||||||
|
if _, ok := pool.ResourceCharts[category]; !ok {
|
||||||
|
pool.ResourceCharts[category] = map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// depending on type, decode the apool data into the corresponding resource type
|
||||||
|
switch t {
|
||||||
|
case "numeric":
|
||||||
|
n := paas.NumericResource{}
|
||||||
|
n.Type = t
|
||||||
|
err_m := mapstructure.Decode(m, &n)
|
||||||
|
err_r := mapstructure.Decode(r, &n)
|
||||||
|
if err_m != nil || err_r != nil {
|
||||||
|
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||||
|
}
|
||||||
|
pool.ResourceCharts[category][k] = n
|
||||||
|
case "storage":
|
||||||
|
n := paas.StorageResource{}
|
||||||
|
n.Type = t
|
||||||
|
err_m := mapstructure.Decode(m, &n)
|
||||||
|
err_r := mapstructure.Decode(r, &n)
|
||||||
|
if err_m != nil || err_r != nil {
|
||||||
|
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||||
|
}
|
||||||
|
pool.ResourceCharts[category][k] = n
|
||||||
|
case "list":
|
||||||
|
n := paas.ListResource{}
|
||||||
|
n.Type = t
|
||||||
|
err_m := mapstructure.Decode(m, &n)
|
||||||
|
err_r := mapstructure.Decode(r, &n)
|
||||||
|
if err_m != nil || err_r != nil {
|
||||||
|
return pools, fmt.Errorf("%s\n%s", err_m.Error(), err_r.Error())
|
||||||
|
}
|
||||||
|
pool.ResourceCharts[category][k] = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the old entry, only categories should be left at the end of the loop
|
||||||
|
//delete(pools[poolname].Resources, k)
|
||||||
|
}
|
||||||
|
pools[poolname] = pool
|
||||||
|
}
|
||||||
|
|
||||||
|
err = FormatPoolResourceCharts(&pools)
|
||||||
|
if err != nil {
|
||||||
|
return pools, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FormatPoolResourceCharts(pools *map[string]Pool) error {
|
||||||
|
for poolname, pool := range *pools {
|
||||||
|
// for each resource category
|
||||||
|
for categoryname, category := range pool.ResourceCharts {
|
||||||
|
// for each resource in each category
|
||||||
|
for resourcename, resource := range category {
|
||||||
|
// create a resource chart for resource depending on resource type
|
||||||
|
switch t := resource.(type) {
|
||||||
|
case paas.NumericResource:
|
||||||
|
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||||
|
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: t.Name,
|
||||||
|
Used: t.Total.Used,
|
||||||
|
Max: t.Total.Max,
|
||||||
|
Avail: avail,
|
||||||
|
Prefix: prefix,
|
||||||
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
|
}
|
||||||
|
case paas.StorageResource:
|
||||||
|
avail, prefix := paas.FormatNumber(paas.SafeUint64(t.Total.Avail*t.Multiplier), t.Base)
|
||||||
|
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: t.Name,
|
||||||
|
Used: t.Total.Used,
|
||||||
|
Max: t.Total.Max,
|
||||||
|
Avail: avail,
|
||||||
|
Prefix: prefix,
|
||||||
|
Unit: t.Unit,
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(t.Total.Used)/float64(t.Total.Max)).ToHTML(),
|
||||||
|
}
|
||||||
|
case paas.ListResource:
|
||||||
|
l := struct {
|
||||||
|
Type string
|
||||||
|
Display bool
|
||||||
|
Resources []ResourceChart
|
||||||
|
}{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Resources: []ResourceChart{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range t.Total {
|
||||||
|
avail := fmt.Sprintf("%d", r.Avail)
|
||||||
|
l.Resources = append(l.Resources, ResourceChart{
|
||||||
|
Type: t.Type,
|
||||||
|
Display: t.Display,
|
||||||
|
Name: r.Name,
|
||||||
|
Used: r.Used,
|
||||||
|
Max: r.Max,
|
||||||
|
Avail: avail, // usually an int
|
||||||
|
Unit: "",
|
||||||
|
ColorHex: InterpolateColorHSV(Green, Red, float64(r.Used)/float64(r.Max)).ToHTML(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(*pools)[poolname].ResourceCharts[categoryname][resourcename] = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// interpolate between min and max by normalized (0 - 1) val
|
||||||
|
func InterpolateColorHSV(min color.RGB, max color.RGB, val float64) color.RGB {
|
||||||
|
minhsl := min.ToHSL()
|
||||||
|
maxhsl := max.ToHSL()
|
||||||
|
interphsl := color.HSL{
|
||||||
|
H: (1-val)*minhsl.H + (val)*maxhsl.H,
|
||||||
|
S: (1-val)*minhsl.S + (val)*maxhsl.S,
|
||||||
|
L: (1-val)*minhsl.L + (val)*maxhsl.L,
|
||||||
|
}
|
||||||
|
return interphsl.ToRGB()
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceBackup struct {
|
||||||
|
Volid string `json:"volid"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CTime int64 `json:"ctime"`
|
||||||
|
SizeFormatted string
|
||||||
|
TimeFormatted string
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, err := GetInstanceBackups(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth) // only used for the VM's name
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/backups.html", map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "backups",
|
||||||
|
"backups": backups,
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETBackupsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil { // user should be authed, try to return index with population
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, err := GetInstanceBackups(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance backups: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/backups-backups.go.tmpl", map[string]any{
|
||||||
|
"backups": backups,
|
||||||
|
})
|
||||||
|
} else { // return 401
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstanceBackups(vm paas.InstancePath, auth paas.Auth) ([]InstanceBackup, error) {
|
||||||
|
backups := []InstanceBackup{}
|
||||||
|
path := fmt.Sprintf("/cluster/%s/%s/%d/backup", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return backups, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return backups, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mapstructure.Decode(body, &backups)
|
||||||
|
if err != nil {
|
||||||
|
return backups, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range backups {
|
||||||
|
size, prefix := paas.FormatNumber(paas.SafeUint64(backups[i].Size), paas.Base1024)
|
||||||
|
backups[i].SizeFormatted = fmt.Sprintf("%s %sB", size, prefix)
|
||||||
|
|
||||||
|
t := time.Unix(backups[i].CTime, 0)
|
||||||
|
backups[i].TimeFormatted = t.Format("02-01-06 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
return backups, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type InstanceConfig struct {
|
||||||
|
paas.Instance `mapstructure:",squash"`
|
||||||
|
// overrides
|
||||||
|
ProctypeSelect common.Select
|
||||||
|
}
|
||||||
|
|
||||||
|
type GlobalConfig struct {
|
||||||
|
CPU struct {
|
||||||
|
Whitelist bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PoolConfig struct {
|
||||||
|
CPU struct {
|
||||||
|
Global []paas.Match
|
||||||
|
Nodes map[string][]paas.Match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Type == "VM" { // if VM, fetch CPU types from node
|
||||||
|
config.ProctypeSelect, err = GetCPUTypes(vm_path, config.Pool, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting proctypes: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, cpu := range config.ProctypeSelect.Options {
|
||||||
|
if cpu.Value == config.Proctype {
|
||||||
|
config.ProctypeSelect.Options[i].Selected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/config.html", map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "config",
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETConfigVolumesFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-volumes.go.tmpl", map[string]any{
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETConfigNetsFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-nets.go.tmpl", map[string]any{
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else { // return 401
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETConfigDevicesFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-devices.go.tmpl", map[string]any{
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else { // return 401
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETConfigBootFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
vm_path, err := common.GetInstancePathFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := GetInstanceConfig(vm_path, auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, fmt.Errorf("error encountered getting instance config: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/config-boot.go.tmpl", map[string]any{
|
||||||
|
"config": config,
|
||||||
|
})
|
||||||
|
} else { // return 401
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetInstanceConfig(vm paas.InstancePath, auth paas.Auth) (InstanceConfig, error) {
|
||||||
|
config := InstanceConfig{}
|
||||||
|
path := fmt.Sprintf("/cluster/%s/%s/%d/", vm.NodeName, string(vm.InstanceType), uint64(vm.InstanceID))
|
||||||
|
body := map[string]any{}
|
||||||
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return config, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return config, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mapstructure.Decode(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
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetCPUTypes(vm paas.InstancePath, pool string, auth paas.Auth) (common.Select, error) {
|
||||||
|
cputypes := common.Select{
|
||||||
|
ID: "proctype",
|
||||||
|
Required: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// get global resource config
|
||||||
|
body := map[string]any{}
|
||||||
|
path := "/global/config/resources"
|
||||||
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
globalConfig := GlobalConfig{}
|
||||||
|
err = mapstructure.Decode(body["resources"], &globalConfig)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get pool resource config
|
||||||
|
body = map[string]any{}
|
||||||
|
path = fmt.Sprintf("/access/pools/%s", pool)
|
||||||
|
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
poolCPUConfig := PoolConfig{}
|
||||||
|
err = mapstructure.Decode(body["pool"].(map[string]any)["resources"], &poolCPUConfig)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// use node specific rules if present, otherwise use global rules
|
||||||
|
var userCPU []paas.Match
|
||||||
|
if _, ok := poolCPUConfig.CPU.Nodes[vm.NodeName]; ok {
|
||||||
|
userCPU = poolCPUConfig.CPU.Nodes[vm.NodeName]
|
||||||
|
} else {
|
||||||
|
userCPU = poolCPUConfig.CPU.Global
|
||||||
|
}
|
||||||
|
|
||||||
|
if globalConfig.CPU.Whitelist { // cpu is a whitelist
|
||||||
|
for _, cpu := range userCPU { // for each cpu type in user config add it to the options
|
||||||
|
cputypes.Options = append(cputypes.Options, common.Option{
|
||||||
|
Display: cpu.Name,
|
||||||
|
Value: cpu.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else { // cpu is a blacklist
|
||||||
|
// get the supported cpu types from the node
|
||||||
|
body = map[string]any{}
|
||||||
|
path = fmt.Sprintf("/proxmox/nodes/%s/capabilities/qemu/cpu", vm.NodeName)
|
||||||
|
res, code, err = common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
if code != 200 {
|
||||||
|
return cputypes, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
supported := struct {
|
||||||
|
data []paas.Match
|
||||||
|
}{}
|
||||||
|
err = mapstructure.Decode(body, supported)
|
||||||
|
if err != nil {
|
||||||
|
return cputypes, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// for each node supported cpu type, if it is NOT in the user's config (aka is not blacklisted) then add it to the options
|
||||||
|
for _, cpu := range supported.data {
|
||||||
|
contains := slices.ContainsFunc(userCPU, func(c paas.Match) 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// primary type used in constructing instance cards in index
|
||||||
|
type InstanceCard struct {
|
||||||
|
VMID uint
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Status string
|
||||||
|
Node string
|
||||||
|
NodeStatus string
|
||||||
|
ConfigPath string
|
||||||
|
ConsolePath string
|
||||||
|
BackupsPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in constructing instance cards in index
|
||||||
|
type Node struct {
|
||||||
|
Node string `json:"node"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// used in retriving cluster tasks
|
||||||
|
type Task struct {
|
||||||
|
Type string
|
||||||
|
Node string
|
||||||
|
User string
|
||||||
|
ID string
|
||||||
|
VMID uint
|
||||||
|
Status string
|
||||||
|
EndTime uint
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstanceStatus struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil { // user should be authed, try to return index with population
|
||||||
|
instances, _, err := GetClusterResources(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page := map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "index",
|
||||||
|
"instances": instances,
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/index.html", page)
|
||||||
|
} else { // return index without populating
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETInstancesFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil { // user should be authed, try to return index with population
|
||||||
|
instances, _, err := GetClusterResources(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLFragmentTemplate(w, common.TMPL, "html/index-instances.go.tmpl", map[string]any{
|
||||||
|
"instances": instances,
|
||||||
|
})
|
||||||
|
} else { // return 401
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetClusterResources(auth paas.Auth) (map[uint]InstanceCard, map[string]Node, error) {
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/proxmox/cluster/resources", &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if code != 200 { // if we did not successfully retrieve resources, then return the error because auth was 1 but was invalid somehow
|
||||||
|
return nil, nil, fmt.Errorf("request to /cluster/resources resulted in %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
instances := map[uint]InstanceCard{}
|
||||||
|
nodes := map[string]Node{}
|
||||||
|
|
||||||
|
// parse /proxmox/cluster/resources to separate instances and nodes
|
||||||
|
for _, v := range body {
|
||||||
|
m := v.(map[string]any)
|
||||||
|
switch m["type"] {
|
||||||
|
case "node": // if type is node -> parse as Node object
|
||||||
|
node := Node{}
|
||||||
|
err := mapstructure.Decode(v, &node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
nodes[node.Node] = node
|
||||||
|
case "lxc", "qemu": // if type is lxc or qemu -> parse as InstanceCard object
|
||||||
|
instance := InstanceCard{}
|
||||||
|
err := mapstructure.Decode(v, &instance)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
instances[instance.VMID] = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// once all basic instance and node stuff is parsed, go back and fill in cross referenced data
|
||||||
|
for vmid, instance := range instances {
|
||||||
|
// set instance's node status
|
||||||
|
instance.NodeStatus = nodes[instance.Node].Status
|
||||||
|
// set instance's config link path
|
||||||
|
instance.ConfigPath = fmt.Sprintf("config?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||||
|
// set the instance's console link path
|
||||||
|
switch instance.Type {
|
||||||
|
case "qemu":
|
||||||
|
instance.ConsolePath = fmt.Sprintf("%s/?console=kvm&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&novnc=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
|
||||||
|
case "lxc":
|
||||||
|
instance.ConsolePath = fmt.Sprintf("%s/?console=lxc&vmid=%d&vmname=%s&node=%s&resize=off&cmd=&xtermjs=1", common.Global.PVE, instance.VMID, instance.Name, instance.Node)
|
||||||
|
}
|
||||||
|
// set the instance's backups link path
|
||||||
|
instance.BackupsPath = fmt.Sprintf("backups?node=%s&type=%s&vmid=%d", instance.Node, instance.Type, instance.VMID)
|
||||||
|
// save back to instances map
|
||||||
|
instances[vmid] = instance
|
||||||
|
}
|
||||||
|
|
||||||
|
body = []any{}
|
||||||
|
res, code, err = common.RequestGetAPI("/proxmox/cluster/tasks", &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
|
||||||
|
return nil, nil, fmt.Errorf("request to /cluster/tasks resulted in %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
most_recent_task := map[uint]uint{}
|
||||||
|
expected_states := map[uint]string{}
|
||||||
|
|
||||||
|
// iterate through recent user accessible tasks to find the task most recently made on an instance
|
||||||
|
for _, v := range body {
|
||||||
|
// parse task as Task object
|
||||||
|
task := Task{}
|
||||||
|
err := mapstructure.Decode(v, &task)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to get task vmid but continue if it would be an invalid vmid
|
||||||
|
x, err := strconv.Atoi(task.ID)
|
||||||
|
// if there was an error converting the task's vmid, skip it
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
task.VMID = uint(x)
|
||||||
|
|
||||||
|
if task.User != auth.Username { // task was not made by user (ie was not a power on/off task)
|
||||||
|
continue
|
||||||
|
} else if _, ok := instances[task.VMID]; !ok { // task does not refer to an instance in user's instances
|
||||||
|
continue
|
||||||
|
} else if instances[task.VMID].Node != task.Node { // task does not have the correct node reference (should not happen)
|
||||||
|
continue
|
||||||
|
} else if !(task.Type == "qmstart" || task.Type == "qmstop" || task.Type == "vzstart" || task.Type == "vzstop") { // task is not start/stop for qemu or lxc
|
||||||
|
continue
|
||||||
|
} else if !(task.Status == "running" || task.Status == "OK") { // task is not running or finished with status OK
|
||||||
|
continue
|
||||||
|
} else { // recent task is a start or stop task for user instance which is running or "OK"
|
||||||
|
if task.EndTime > most_recent_task[task.VMID] { // if the task's end time is later than the most recent one encountered
|
||||||
|
most_recent_task[task.VMID] = task.EndTime // update the most recent task
|
||||||
|
switch task.Type {
|
||||||
|
case "qmstart", "vzstart": // if the task was a start task, update the expected state to running
|
||||||
|
expected_states[task.VMID] = "running"
|
||||||
|
case "qmstop", "vzstop": // if the task was a stop task, update the expected state to stopped
|
||||||
|
expected_states[task.VMID] = "stopped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iterate through the instances with recent tasks, refetch their state from a more reliable source
|
||||||
|
for vmid, expected_state := range expected_states { // for the expected states from recent tasks
|
||||||
|
if instances[vmid].Status != expected_state { // if the current node's state from /cluster/resources differs from expected state
|
||||||
|
// get /status/current which is updated faster than /cluster/resources
|
||||||
|
instance := instances[vmid]
|
||||||
|
path := fmt.Sprintf("/proxmox/nodes/%s/%s/%d/status/current", instance.Node, instance.Type, instance.VMID)
|
||||||
|
body := map[string]any{}
|
||||||
|
res, code, err := common.RequestGetAPI(path, &auth, &body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if code != 200 { // if we did not successfully retrieve tasks, then return 500 because auth was 1 but was invalid somehow
|
||||||
|
return nil, nil, fmt.Errorf("request to %s resulted in %+v", path, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attempt to decode task status as instance status
|
||||||
|
status := InstanceStatus{}
|
||||||
|
err = mapstructure.Decode(body, &status)
|
||||||
|
if err != nil { // did not successfully decode task status, just skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.Status = status.Status
|
||||||
|
instances[vmid] = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return instances, nodes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stores each realm's data
|
||||||
|
type Realm struct {
|
||||||
|
Default int `json:"default"`
|
||||||
|
Realm string `json:"realm"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetLoginRealms() ([]Realm, error) {
|
||||||
|
realms := []Realm{}
|
||||||
|
|
||||||
|
body := []any{}
|
||||||
|
res, code, err := common.RequestGetAPI("/proxmox/access/domains", nil, &body)
|
||||||
|
if err != nil {
|
||||||
|
return realms, err
|
||||||
|
}
|
||||||
|
if code != 200 { // we expect /access/domains to always be avaliable
|
||||||
|
return realms, fmt.Errorf("request to /proxmox/access/domains resulted in %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range body {
|
||||||
|
v = v.(map[string]any)
|
||||||
|
realm := Realm{}
|
||||||
|
err := mapstructure.Decode(v, &realm)
|
||||||
|
if err != nil {
|
||||||
|
return realms, err
|
||||||
|
}
|
||||||
|
realms = append(realms, realm)
|
||||||
|
}
|
||||||
|
|
||||||
|
return realms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleGETLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
realms, err := GetLoginRealms()
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/login.html", map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "login",
|
||||||
|
"realms": sel,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"proxmoxaas-dashboard/app/common"
|
||||||
|
paas "proxmoxaas-dashboard/proxmoxaas-common-lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func HandleGETSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth, err := common.GetAuthFromRequest(r)
|
||||||
|
if err == nil {
|
||||||
|
_, err := GetUserBasic(auth)
|
||||||
|
if err != nil {
|
||||||
|
common.HandleNonFatalError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
paas.HTTPExecuteHTMLTemplate(w, common.TMPL, "html/settings.html", map[string]any{
|
||||||
|
"global": common.Global,
|
||||||
|
"page": "settings",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
http.Redirect(w, r, "/login", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
input, select, textarea {
|
||||||
|
background-color: var(--main-input-bg-color);
|
||||||
|
color: var(--main-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5em 1em;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid * {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
padding-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid input {
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid img {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-grid .last-item {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
float: left;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset > *:last-child {
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset > .input-grid {
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(:-moz-handler-blocked) fieldset {
|
||||||
|
display: table-cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: min-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
position: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w3-select, select {
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
margin: auto;
|
||||||
|
max-width: calc(min(100% - 16px, 80ch));
|
||||||
|
color: var(--main-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog #prompt {
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog button[value="confirm"] {
|
||||||
|
background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog button[value="cancel"] {
|
||||||
|
background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
:root {
|
: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;
|
--nav-transition-speed: 250ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (prefers-color-scheme: light) {
|
@media screen and (prefers-color-scheme: dark) {
|
||||||
:root {
|
: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-bg-color: black;
|
||||||
--nav-text-color: white;
|
--nav-text-color: white;
|
||||||
--nav-header-bg-color: #0f0;
|
--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 {
|
header {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -28,7 +53,7 @@ header {
|
|||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
font-size: 18px;
|
font-size: var(--small-font-size);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background-color: var(--nav-header-bg-color);
|
background-color: var(--nav-header-bg-color);
|
||||||
color: var(--nav-header-text-color);
|
color: var(--nav-header-text-color);
|
||||||
@@ -36,15 +61,15 @@ header h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: larger;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
nav a, header h1, label[for="navtoggle"] {
|
nav a, header h1, label[for="navtoggle"] {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding-left: 8px;
|
padding-left: 0.5em;
|
||||||
padding-right: 8px;
|
padding-right: 0.5em;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
@@ -55,7 +80,7 @@ label[for="navtoggle"], #navtoggle {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width >= 600px){
|
@media screen and (width >= 601px){
|
||||||
header {
|
header {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
@@ -81,7 +106,7 @@ label[for="navtoggle"], #navtoggle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width <= 600px){
|
@media screen and (width <= 601px){
|
||||||
header {
|
header {
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
:root {
|
||||||
|
--negative-color: #f00;
|
||||||
|
--positive-color: #0f0;
|
||||||
|
--highlight-color: yellow;
|
||||||
|
--lightbg-text-color: black;
|
||||||
|
--large-font-size: 32px;
|
||||||
|
--medium-font-size: 24px;
|
||||||
|
--small-font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (prefers-color-scheme: dark) {
|
||||||
|
: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-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-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-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-input-bg-color: #404040;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*, h1, h2, h3, h4, h5, h6, p {
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: monospace;
|
||||||
|
line-height: normal;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--large-font-size);
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: var(--medium-font-size);
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4, legend {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: var(--small-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--main-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, svg {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
color: var(--main-text-color)
|
||||||
|
}
|
||||||
|
|
||||||
|
a img {
|
||||||
|
vertical-align: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-color: var(--main-text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex-direction: row;
|
||||||
|
column-gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-reverse {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
row-gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.column {
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-reverse {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
row-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 screen and (width >= 993px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fixes edge case in w3-hide-medium where width between 992 and 993 */
|
||||||
|
@media screen and (width <= 993px) and (width >= 601px){
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fixes edge case in w3-hide-small when width between 600 and 601 */
|
||||||
|
@media screen and (width <= 601px) and (width >= 440px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (width <= 440px) {
|
||||||
|
.hide-large {display: none !important;}
|
||||||
|
.hide-medium {display:none !important}
|
||||||
|
.hide-small {display:none !important}
|
||||||
|
.hide-tiny { display: none !important;}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed css/*
|
||||||
|
//go:embed images/*
|
||||||
|
//go:embed images/*/*/*
|
||||||
|
//go:embed modules/*
|
||||||
|
//go:embed scripts/*
|
||||||
|
var Static embed.FS
|
||||||
|
|
||||||
|
//go:embed html/*
|
||||||
|
//go:embed templates/*
|
||||||
|
var Templates embed.FS
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/account.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="websrc/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>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main>
|
||||||
|
<h2>Account</h2>
|
||||||
|
<section class="w3-card w3-padding">
|
||||||
|
<h3>Account Details</h3>
|
||||||
|
<p id="username">Username: {{.user.Username.UserID}}@{{.user.Username.Realm}}</p>
|
||||||
|
<p id="email">Email: {{.user.Mail}}</p>
|
||||||
|
<p>Password: <button class="w3-button" id="change-password" type="button" style="padding: 0em; height: 1.5em; line-height: 1.5em;">Change Password</button></p>
|
||||||
|
</section>
|
||||||
|
{{range $poolname, $pool := .pools}}
|
||||||
|
{{template "pool-resources" $pool}}
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
<template id="change-password-dialog">
|
||||||
|
<link rel="stylesheet" href="websrc/modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="websrc/css/style.css">
|
||||||
|
<link rel="stylesheet" href="websrc/css/form.css">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Change Password
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="new-password">New Password</label>
|
||||||
|
<input class="w3-input w3-border" id="new-password" name="new-password" type="password" autocomplete="new-password" required>
|
||||||
|
<label for="confirm-password">Confirm Password</label>
|
||||||
|
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" autocomplete="new-password" required>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin">CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{range $i, $x := .backups}}
|
||||||
|
{{template "backup-card" $x}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/backups.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/dialog.js">
|
||||||
|
<style>
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main>
|
||||||
|
<h2><a href="index">Instances</a> / {{.config.Name}} / Backups</h2>
|
||||||
|
<section class="w3-card w3-padding">
|
||||||
|
<div class="w3-row" style="border-bottom: 1px solid;">
|
||||||
|
<p class="w3-col l2 m4 s8">Time</p>
|
||||||
|
<p class="w3-col l6 m6 hide-small">Notes</p>
|
||||||
|
<p class="w3-col l2 hide-medium">Size</p>
|
||||||
|
<p class="w3-col l2 m2 s4">Actions</p>
|
||||||
|
</div>
|
||||||
|
<div id="backups-container">
|
||||||
|
{{range $i, $x := .backups}}
|
||||||
|
{{template "backup-card" $x}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="w3-container w3-center">
|
||||||
|
{{template "backups-add-backup" .}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="w3-container w3-center">
|
||||||
|
<a class="w3-button w3-margin" id="exit" href="index">EXIT</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{template "boot" .config.Boot}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{template "devices" .config.Devices}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{template "nets" .config.Nets}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{{template "volumes" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/config.js" type="module"></script>
|
||||||
|
<script src="websrc/scripts/draggable.js" type="module"></script>
|
||||||
|
<script src="websrc/modules/Sortable.min.js"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="websrc/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>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h2><a href="index">Instances</a>/{{.config.Name}}/Config</h2>
|
||||||
|
<form id="config-form">
|
||||||
|
<fieldset class="w3-card w3-padding">
|
||||||
|
<legend>Resources</legend>
|
||||||
|
<div class="input-grid" id="resources" style="grid-template-columns: auto auto auto 1fr;">
|
||||||
|
{{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" Map "Volumes" .config.Volumes "InstanceType" .config.Type}}
|
||||||
|
</div>
|
||||||
|
<div class="w3-container w3-center">
|
||||||
|
<!--Add Disk Button & Dialog Template-->
|
||||||
|
{{template "volumes-add-disk" .}}
|
||||||
|
<!--Add CD Button & Dialog Template-->
|
||||||
|
{{if eq .config.Type "VM"}}
|
||||||
|
{{template "volumes-add-cd"}}
|
||||||
|
{{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">
|
||||||
|
<!--Add Net Button & Dialog Template-->
|
||||||
|
{{template "nets-add-net"}}
|
||||||
|
</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">
|
||||||
|
<!--Add Device Button & Dialog Template-->
|
||||||
|
{{template "devices-add-device"}}
|
||||||
|
</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="submit">EXIT</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{range .instances}}
|
||||||
|
{{template "instance-card" .}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/index.js" type="module"></script>
|
||||||
|
<script src="websrc/modules/wfa.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/dialog.js">
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/clientsync.js">
|
||||||
|
<style>
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width >= 993px) {
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 993px) and (width >= 601px){
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 601px) and (width >= 440px){
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and (width <= 440px) {
|
||||||
|
#instance-table {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, auto);
|
||||||
|
grid-column-gap: 1em;
|
||||||
|
grid-row-gap: 0.25em;
|
||||||
|
}
|
||||||
|
#instance-table-header, #instance-container, #instance-table instance-card {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main>
|
||||||
|
<section>
|
||||||
|
<h2>Instances</h2>
|
||||||
|
<div class="w3-card w3-padding">
|
||||||
|
<div class="flex row nowrap" style="margin-top: 1em; justify-content: space-between;">
|
||||||
|
<form id="vm-search" role="search" class="flex row nowrap" tabindex="0">
|
||||||
|
<button type="submit" id="submit" class="w3-button" style="padding: 0; width: 1em; height: 1em; line-height: 1em;"><img alt="Search Instances" aria-label="Search Instances" src="websrc/images/common/search.svg#symb"></button>
|
||||||
|
<input type="search" id="search" class="w3-input w3-border" style="height: 1lh; max-width: fit-content;" aria-label="search instances by name">
|
||||||
|
</form>
|
||||||
|
<!--Add Instance Button & Dialog Template-->
|
||||||
|
<button type="button" id="instance-add" class="w3-button" aria-label="create new instance">
|
||||||
|
<span class="large" style="margin: 0;">Create Instance</span>
|
||||||
|
<img class="small" style="height: 1lh; width: 1lh;" alt="Create Instance" aria-label="Create Instance" src="websrc/images/actions/instance/add.svg#symb">
|
||||||
|
</button>
|
||||||
|
<template id="create-instance-dialog">
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<p class="w3-large" id="prompt">
|
||||||
|
Create New Instance
|
||||||
|
</p>
|
||||||
|
<div id="body">
|
||||||
|
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
|
||||||
|
<label for="type">Instance Type</label>
|
||||||
|
<select class="w3-select w3-border" name="type" id="type" selected-index="-1" required>
|
||||||
|
<option value="lxc">Container</option>
|
||||||
|
<option value="qemu">Virtual Machine</option>
|
||||||
|
</select>
|
||||||
|
<label for="pool">Pool</label>
|
||||||
|
<select class="w3-select w3-border" name="pool" id="pool" required></select>
|
||||||
|
<label for="node">Node</label>
|
||||||
|
<select class="w3-select w3-border" name="node" id="node" required></select>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input class="w3-input w3-border" name="name" id="name" type="text" required>
|
||||||
|
<label for="vmid">ID</label>
|
||||||
|
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required>
|
||||||
|
<label for="cores">Cores (Threads)</label>
|
||||||
|
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required>
|
||||||
|
<label for="memory">Memory (MiB)</label>
|
||||||
|
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16" step="1" required>
|
||||||
|
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
|
||||||
|
<label class="container-specific none" for="swap">Swap (MiB)</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled>
|
||||||
|
<label class="container-specific none" for="template-image">Template Image</label>
|
||||||
|
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
|
||||||
|
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
|
||||||
|
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
|
||||||
|
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled>
|
||||||
|
<label class="container-specific none" for="password">Password</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" autocomplete="new-password" required disabled>
|
||||||
|
<label class="container-specific none" for="confirm-password">Confirm Password</label>
|
||||||
|
<input class="w3-input w3-border container-specific none" name="confirm-password" id="confirm-password" type="password" autocomplete="new-password" required disabled>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div id="controls" class="w3-center w3-container">
|
||||||
|
<button id="cancel" type="submit" value="cancel" form="form" class="w3-button w3-margin" formnovalidate>CANCEL</button>
|
||||||
|
<button id="confirm" type="submit" value="confirm" form="form" class="w3-button w3-margin" >CONFIRM</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div id="instance-table">
|
||||||
|
<div id="instance-table-header">
|
||||||
|
<p>ID</p>
|
||||||
|
<p>Name</p>
|
||||||
|
<p class="hide-tiny">Type</p>
|
||||||
|
<p class="hide-small">Status</p>
|
||||||
|
<p class="hide-medium">Host Name</p>
|
||||||
|
<p class="hide-medium">Host Status</p>
|
||||||
|
<p>Actions</p>
|
||||||
|
</div>
|
||||||
|
<hr style="grid-column: 1 / -1; padding: 0; margin: 0;">
|
||||||
|
<div id="instance-container">
|
||||||
|
{{range .instances}}
|
||||||
|
{{template "instance-card" .}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/login.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/dialog.js">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "header" .}}
|
||||||
|
<main class="flex" style="justify-content: center; align-items: center;">
|
||||||
|
<div class="w3-container w3-card w3-margin w3-padding" style="height: fit-content;">
|
||||||
|
<h2 class="w3-center">{{.global.Organization}} Login</h2>
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
{{template "head" .}}
|
||||||
|
<script src="websrc/scripts/settings.js" type="module"></script>
|
||||||
|
<link rel="modulepreload" href="websrc/scripts/utils.js">
|
||||||
|
<style>
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: left;
|
||||||
|
column-gap: 0.5em;
|
||||||
|
}
|
||||||
|
label + p {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
#save.disabled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#save.enabled {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "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>
|
||||||
|
<label><input class="w3-radio" type="radio" id="sync-never" name="sync-scheme" value="never" required>Never Sync</label>
|
||||||
|
<p>App will never automatically sync. Reload the page to sync the latest cluster state.</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset>
|
||||||
|
<legend>App Sync Frequency</legend>
|
||||||
|
<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 disabled" id="save" type="submit">SAVE</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/config.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" aria-label="config" viewBox="4 2 17 19" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M10 8H5V3m.291 13.357a8 8 0 10.188-8.991" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/add.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/config.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-inactive.svg
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-inactive.svg
|
||||||
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg"/>
|
||||||
|
After Width: | Height: | Size: 76 B |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="drag" 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>
|
||||||
|
After Width: | Height: | Size: 345 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/add.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="backup" viewBox="1 1 22 22" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M18.172 1a2 2 0 011.414.586l2.828 2.828A2 2 0 0123 5.828V20a3 3 0 01-3 3H4a3 3 0 01-3-3V4a3 3 0 013-3h14.172zM4 3a1 1 0 00-1 1v16a1 1 0 001 1h1v-6a3 3 0 013-3h8a3 3 0 013 3v6h1a1 1 0 001-1V6.828a2 2 0 00-.586-1.414l-1.828-1.828A2 2 0 0017.172 3H17v2a3 3 0 01-3 3h-4a3 3 0 01-3-3V3H4zm13 18v-6a1 1 0 00-1-1H8a1 1 0 00-1 1v6h10zM9 3h6v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V3z" fill="#808080"/></svg>
|
||||||
|
After Width: | Height: | Size: 540 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/config.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/config-inactive.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="instance console" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="" viewBox="2 2 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M2 7a5 5 0 015-5h10a5 5 0 015 5v10a5 5 0 01-5 5H7a5 5 0 01-5-5V7zm5-3a3 3 0 00-3 3v10a3 3 0 003 3h10a3 3 0 003-3V7a3 3 0 00-3-3H7zm0 13a1 1 0 011-1h8a1 1 0 110 2H8a1 1 0 01-1-1zm1.707-9.707a1 1 0 10-1.414 1.414L9.586 11l-2.293 2.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414l-3-3z" fill="#808080"/></svg>
|
||||||
|
After Width: | Height: | Size: 458 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-inactive.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="start instance" xmlns="http://www.w3.org/2000/svg" viewBox="4.2 2.4 9 12"><path d="M4.25 3l1.166-.624 8 5.333v1.248l-8 5.334-1.166-.624V3zm1.5 1.401v7.864l5.898-3.932L5.75 4.401z" fill="#0f0"/></svg>
|
||||||
|
After Width: | Height: | Size: 237 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="stop instance" xmlns="http://www.w3.org/2000/svg" viewBox="-10 -10 350 350"><path stroke-width="20" d="M315 0H15C6.716 0 0 6.716 0 15v300c0 8.284 6.716 15 15 15h300c8.284 0 15-6.716 15-15V15c0-8.284-6.716-15-15-15zm-15 300H30V30h270v270z" stroke="#f00" fill="#f00"/></svg>
|
||||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
|||||||
|
../../common/add.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/config.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-active.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../common/delete-inactive.svg
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="add device" 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: 316 B |
|
After Width: | Height: | Size: 87 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="cpu" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>
|
||||||
|
After Width: | Height: | Size: 104 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="#808080"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="config" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 229.034 229.034"><style>:root{color:#fff}@media (prefers-color-scheme:light){:root{color:#000}}</style><path d="M218.411 167.068l-70.305-70.301 9.398-35.073a7.504 7.504 0 00-1.94-7.245L103.311 2.197A7.499 7.499 0 0096.066.256L56.812 10.774a7.502 7.502 0 00-3.362 12.548l39.259 39.262-6.364 23.756-23.751 6.363-39.263-39.26a7.498 7.498 0 00-7.245-1.94 7.498 7.498 0 00-5.303 5.303L.266 96.059a7.5 7.5 0 001.941 7.244l52.249 52.255a7.5 7.5 0 007.245 1.941l35.076-9.4 70.302 70.306c6.854 6.854 15.968 10.628 25.662 10.629h.001c9.695 0 18.81-3.776 25.665-10.631 14.153-14.151 14.156-37.178.004-51.335zM207.8 207.795a21.15 21.15 0 01-15.058 6.239h-.002a21.153 21.153 0 01-15.056-6.236l-73.363-73.367a7.5 7.5 0 00-7.245-1.942L62 141.889 15.875 95.758l6.035-22.523 33.139 33.137a7.499 7.499 0 007.244 1.941l32.116-8.604a7.5 7.5 0 005.304-5.304l8.606-32.121a7.5 7.5 0 00-1.941-7.244L73.242 21.901l22.524-6.036 46.128 46.129-9.398 35.073a7.5 7.5 0 001.941 7.245l73.365 73.361c8.305 8.307 8.304 21.819-.002 30.122z" fill="currentColor"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="delete" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#f00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 310 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="" viewBox="3 2 18 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M10 11v6M14 11v6M4 7h16M6 7h12v11a3 3 0 01-3 3H9a3 3 0 01-3-3V7zM9 5a2 2 0 012-2h2a2 2 0 012 2v2H9V5z" stroke="#ffbfbf" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
After Width: | Height: | Size: 307 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" aria-label="search" id="symb" 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>
|
||||||
|
After Width: | Height: | Size: 367 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15"><g fill="#0f0" font-family="monospace" font-weight="bold"><text y="14" font-size="16">H</text><text x="9" y="8" font-size="10">0</text></g></svg>
|
||||||
|
After Width: | Height: | Size: 215 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="cpu" 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>
|
||||||
|
After Width: | Height: | Size: 813 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg role="img" aria-label="pci device" id="symb" 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>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="cd" 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>
|
||||||
|
After Width: | Height: | Size: 509 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="disk" 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>
|
||||||
|
After Width: | Height: | Size: 421 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="network" 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>
|
||||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="memory" 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>
|
||||||
|
After Width: | Height: | Size: 628 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="swap" 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="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>
|
||||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="active" fill="#0f0" viewBox="0 0 16 16" stroke="#none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 163 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="inactive" fill="#f00" viewBox="0 0 16 16" stroke="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="8"/></svg>
|
||||||
|
After Width: | Height: | Size: 164 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="symb" role="img" aria-label="loading" 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>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
/*! Sortable 1.15.7 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function o(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=Array(e);n<e;n++)o[n]=t[n];return o}function i(t,e,n){return(e=function(t){t=function(t,e){if("object"!=typeof t||!t)return t;var n=t[Symbol.toPrimitive];if(void 0===n)return("string"===e?String:Number)(t);e=n.call(t,e||"default");if("object"!=typeof e)return e;throw new TypeError("@@toPrimitive must return a primitive value.")}(t,"string");return"symbol"==typeof t?t:t+""}(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function a(){return(a=Object.assign?Object.assign.bind():function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)!{}.hasOwnProperty.call(o,n)||(t[n]=o[n])}return t}).apply(null,arguments)}function r(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(e){for(var t=1;t<arguments.length;t++){var n=null!=arguments[t]?arguments[t]:{};t%2?r(Object(n),!0).forEach(function(t){i(e,t,n[t])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(n)):r(Object(n)).forEach(function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(n,t))})}return e}function l(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};var n,o={};for(n in t)if({}.hasOwnProperty.call(t,n)){if(-1!==e.indexOf(n))continue;o[n]=t[n]}return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],-1===e.indexOf(n)&&{}.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function e(t){return function(t){if(Array.isArray(t))return o(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return o(t,e);var n={}.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?o(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function s(t){return(s="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),c=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),d=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),h={capture:!1,passive:!1};function f(t,e,n){t.addEventListener(e,n,!y&&h)}function p(t,e,n){t.removeEventListener(e,n,!y&&h)}function g(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function m(t){return t.host&&t!==document&&t.host.nodeType&&t.host!==t?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&g(t,e)||o&&t===n)return t}while(t!==n&&(t=m(t)))}return null}var v,b=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(b," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(b," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function D(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBouLine truncated
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
|
/* W3.CSS 5.02 March 31 2025 by Jan Egil and Borge Refsnes */
|
||||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
|
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
|
||||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
|
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
|
||||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||||
@@ -108,6 +108,8 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
|
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
|
||||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
|
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
|
||||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
|
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
|
||||||
|
.w3-grid{display:grid}.w3-grid-padding{display:grid;gap:16px}.w3-flex{display:flex}
|
||||||
|
.w3-text-center{text-align:center}.w3-text-bold,.w3-bold{font-weight:bold}.w3-text-italic,.w3-italic{font-style:italic}
|
||||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
|
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
|
||||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
|
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
|
||||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
|
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
|
||||||
@@ -148,6 +150,7 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
|
.w3-button:hover{color:#000!important;background-color:#ccc!important}
|
||||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
||||||
.w3-hover-none:hover{box-shadow:none!important}
|
.w3-hover-none:hover{box-shadow:none!important}
|
||||||
|
.w3-rtl{direction:rtl}.w3-ltr{direction:ltr}
|
||||||
/* Colors */
|
/* Colors */
|
||||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
|
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
|
||||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
|
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
|
||||||
@@ -175,6 +178,19 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
|||||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
|
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
|
||||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
|
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
|
||||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
|
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
|
||||||
|
.w3-asphalt,.w3-hover-asphalt:hover{color:#fff!important;background-color:#343a40!important}
|
||||||
|
.w3-crimson,.w3-hover-crimson:hover{color:#fff!important;background-color:#a20025!important}
|
||||||
|
.w3-cobalt,w3-hover-cobalt:hover{color:#fff!important;background-color:#0050ef!important}
|
||||||
|
.w3-emerald,.w3-hover-emerald:hover{color:#fff!important;background-color:#008a00!important}
|
||||||
|
.w3-olive,.w3-hover-olive:hover{color:#fff!important;background-color:#6d8764!important}
|
||||||
|
.w3-paper,.w3-hover-paper:hover{color:#000!important;background-color:#f8f9fa!important}
|
||||||
|
.w3-sienna,.w3-hover-sienna:hover{color:#fff!important;background-color:#a0522d!important}
|
||||||
|
.w3-taupe,.w3-hover-taupe:hover{color:#fff!important;background-color:#87794e!important}
|
||||||
|
.w3-danger{color:#fff!important;background-color:#dd0000!important}
|
||||||
|
.w3-note{color:#000!important;background-color:#fff599!important}
|
||||||
|
.w3-info{color:#fff!important;background-color:#0a6fc2!important}
|
||||||
|
.w3-warning{color:#000!important;background-color:#ffb305!important}
|
||||||
|
.w3-success{color:#fff!important;background-color:#008a00!important}
|
||||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
|
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
|
||||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
|
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
|
||||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
|
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// Copyright 2018 The Go Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
const enosys = () => {
|
||||||
|
const err = new Error("not implemented");
|
||||||
|
err.code = "ENOSYS";
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!globalThis.fs) {
|
||||||
|
let outputBuf = "";
|
||||||
|
globalThis.fs = {
|
||||||
|
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||||
|
writeSync(fd, buf) {
|
||||||
|
outputBuf += decoder.decode(buf);
|
||||||
|
const nl = outputBuf.lastIndexOf("\n");
|
||||||
|
if (nl != -1) {
|
||||||
|
console.log(outputBuf.substring(0, nl));
|
||||||
|
outputBuf = outputBuf.substring(nl + 1);
|
||||||
|
}
|
||||||
|
return buf.length;
|
||||||
|
},
|
||||||
|
write(fd, buf, offset, length, position, callback) {
|
||||||
|
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||||
|
callback(enosys());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = this.writeSync(fd, buf);
|
||||||
|
callback(null, n);
|
||||||
|
},
|
||||||
|
chmod(path, mode, callback) { callback(enosys()); },
|
||||||
|
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
close(fd, callback) { callback(enosys()); },
|
||||||
|
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||||
|
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||||
|
fstat(fd, callback) { callback(enosys()); },
|
||||||
|
fsync(fd, callback) { callback(null); },
|
||||||
|
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||||
|
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||||
|
link(path, link, callback) { callback(enosys()); },
|
||||||
|
lstat(path, callback) { callback(enosys()); },
|
||||||
|
mkdir(path, perm, callback) { callback(enosys()); },
|
||||||
|
open(path, flags, mode, callback) { callback(enosys()); },
|
||||||
|
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||||
|
readdir(path, callback) { callback(enosys()); },
|
||||||
|
readlink(path, callback) { callback(enosys()); },
|
||||||
|
rename(from, to, callback) { callback(enosys()); },
|
||||||
|
rmdir(path, callback) { callback(enosys()); },
|
||||||
|
stat(path, callback) { callback(enosys()); },
|
||||||
|
symlink(path, link, callback) { callback(enosys()); },
|
||||||
|
truncate(path, length, callback) { callback(enosys()); },
|
||||||
|
unlink(path, callback) { callback(enosys()); },
|
||||||
|
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.process) {
|
||||||
|
globalThis.process = {
|
||||||
|
getuid() { return -1; },
|
||||||
|
getgid() { return -1; },
|
||||||
|
geteuid() { return -1; },
|
||||||
|
getegid() { return -1; },
|
||||||
|
getgroups() { throw enosys(); },
|
||||||
|
pid: -1,
|
||||||
|
ppid: -1,
|
||||||
|
umask() { throw enosys(); },
|
||||||
|
cwd() { throw enosys(); },
|
||||||
|
chdir() { throw enosys(); },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.path) {
|
||||||
|
globalThis.path = {
|
||||||
|
resolve(...pathSegments) {
|
||||||
|
return pathSegments.join("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.crypto) {
|
||||||
|
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.performance) {
|
||||||
|
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextEncoder) {
|
||||||
|
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!globalThis.TextDecoder) {
|
||||||
|
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new TextEncoder("utf-8");
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
globalThis.Go = class {
|
||||||
|
constructor() {
|
||||||
|
this.argv = ["js"];
|
||||||
|
this.env = {};
|
||||||
|
this.exit = (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
console.warn("exit code:", code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._exitPromise = new Promise((resolve) => {
|
||||||
|
this._resolveExitPromise = resolve;
|
||||||
|
});
|
||||||
|
this._pendingEvent = null;
|
||||||
|
this._scheduledTimeouts = new Map();
|
||||||
|
this._nextCallbackTimeoutID = 1;
|
||||||
|
|
||||||
|
const setInt64 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setInt32 = (addr, v) => {
|
||||||
|
this.mem.setUint32(addr + 0, v, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInt64 = (addr) => {
|
||||||
|
const low = this.mem.getUint32(addr + 0, true);
|
||||||
|
const high = this.mem.getInt32(addr + 4, true);
|
||||||
|
return low + high * 4294967296;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = (addr) => {
|
||||||
|
const f = this.mem.getFloat64(addr, true);
|
||||||
|
if (f === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isNaN(f)) {
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = this.mem.getUint32(addr, true);
|
||||||
|
return this._values[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeValue = (addr, v) => {
|
||||||
|
const nanHead = 0x7FF80000;
|
||||||
|
|
||||||
|
if (typeof v === "number" && v !== 0) {
|
||||||
|
if (isNaN(v)) {
|
||||||
|
this.mem.setUint32(addr + 4, nanHead, true);
|
||||||
|
this.mem.setUint32(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.mem.setFloat64(addr, v, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v === undefined) {
|
||||||
|
this.mem.setFloat64(addr, 0, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = this._ids.get(v);
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._idPool.pop();
|
||||||
|
if (id === undefined) {
|
||||||
|
id = this._values.length;
|
||||||
|
}
|
||||||
|
this._values[id] = v;
|
||||||
|
this._goRefCounts[id] = 0;
|
||||||
|
this._ids.set(v, id);
|
||||||
|
}
|
||||||
|
this._goRefCounts[id]++;
|
||||||
|
let typeFlag = 0;
|
||||||
|
switch (typeof v) {
|
||||||
|
case "object":
|
||||||
|
if (v !== null) {
|
||||||
|
typeFlag = 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "string":
|
||||||
|
typeFlag = 2;
|
||||||
|
break;
|
||||||
|
case "symbol":
|
||||||
|
typeFlag = 3;
|
||||||
|
break;
|
||||||
|
case "function":
|
||||||
|
typeFlag = 4;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||||
|
this.mem.setUint32(addr, id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSlice = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSliceOfValues = (addr) => {
|
||||||
|
const array = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
const a = new Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
a[i] = loadValue(array + i * 8);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadString = (addr) => {
|
||||||
|
const saddr = getInt64(addr + 0);
|
||||||
|
const len = getInt64(addr + 8);
|
||||||
|
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||||
|
}
|
||||||
|
|
||||||
|
const testCallExport = (a, b) => {
|
||||||
|
this._inst.exports.testExport0();
|
||||||
|
return this._inst.exports.testExport(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeOrigin = Date.now() - performance.now();
|
||||||
|
this.importObject = {
|
||||||
|
_gotest: {
|
||||||
|
add: (a, b) => a + b,
|
||||||
|
callExport: testCallExport,
|
||||||
|
},
|
||||||
|
gojs: {
|
||||||
|
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||||
|
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||||
|
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||||
|
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||||
|
|
||||||
|
// func wasmExit(code int32)
|
||||||
|
"runtime.wasmExit": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const code = this.mem.getInt32(sp + 8, true);
|
||||||
|
this.exited = true;
|
||||||
|
delete this._inst;
|
||||||
|
delete this._values;
|
||||||
|
delete this._goRefCounts;
|
||||||
|
delete this._ids;
|
||||||
|
delete this._idPool;
|
||||||
|
this.exit(code);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||||
|
"runtime.wasmWrite": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const fd = getInt64(sp + 8);
|
||||||
|
const p = getInt64(sp + 16);
|
||||||
|
const n = this.mem.getInt32(sp + 24, true);
|
||||||
|
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func resetMemoryDataView()
|
||||||
|
"runtime.resetMemoryDataView": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func nanotime1() int64
|
||||||
|
"runtime.nanotime1": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func walltime() (sec int64, nsec int32)
|
||||||
|
"runtime.walltime": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const msec = (new Date).getTime();
|
||||||
|
setInt64(sp + 8, msec / 1000);
|
||||||
|
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func scheduleTimeoutEvent(delay int64) int32
|
||||||
|
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this._nextCallbackTimeoutID;
|
||||||
|
this._nextCallbackTimeoutID++;
|
||||||
|
this._scheduledTimeouts.set(id, setTimeout(
|
||||||
|
() => {
|
||||||
|
this._resume();
|
||||||
|
while (this._scheduledTimeouts.has(id)) {
|
||||||
|
// for some reason Go failed to register the timeout event, log and try again
|
||||||
|
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||||
|
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||||
|
this._resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getInt64(sp + 8),
|
||||||
|
));
|
||||||
|
this.mem.setInt32(sp + 16, id, true);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func clearTimeoutEvent(id int32)
|
||||||
|
"runtime.clearTimeoutEvent": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getInt32(sp + 8, true);
|
||||||
|
clearTimeout(this._scheduledTimeouts.get(id));
|
||||||
|
this._scheduledTimeouts.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func getRandomData(r []byte)
|
||||||
|
"runtime.getRandomData": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
crypto.getRandomValues(loadSlice(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func finalizeRef(v ref)
|
||||||
|
"syscall/js.finalizeRef": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const id = this.mem.getUint32(sp + 8, true);
|
||||||
|
this._goRefCounts[id]--;
|
||||||
|
if (this._goRefCounts[id] === 0) {
|
||||||
|
const v = this._values[id];
|
||||||
|
this._values[id] = null;
|
||||||
|
this._ids.delete(v);
|
||||||
|
this._idPool.push(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func stringVal(value string) ref
|
||||||
|
"syscall/js.stringVal": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, loadString(sp + 8));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueGet(v ref, p string) ref
|
||||||
|
"syscall/js.valueGet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 32, result);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueSet(v ref, p string, x ref)
|
||||||
|
"syscall/js.valueSet": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueDelete(v ref, p string)
|
||||||
|
"syscall/js.valueDelete": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueIndex(v ref, i int) ref
|
||||||
|
"syscall/js.valueIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueSetIndex(v ref, i int, x ref)
|
||||||
|
"syscall/js.valueSetIndex": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueCall": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const m = Reflect.get(v, loadString(sp + 16));
|
||||||
|
const args = loadSliceOfValues(sp + 32);
|
||||||
|
const result = Reflect.apply(m, v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, result);
|
||||||
|
this.mem.setUint8(sp + 64, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 56, err);
|
||||||
|
this.mem.setUint8(sp + 64, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueInvoke": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.apply(v, undefined, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueNew(v ref, args []ref) (ref, bool)
|
||||||
|
"syscall/js.valueNew": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
try {
|
||||||
|
const v = loadValue(sp + 8);
|
||||||
|
const args = loadSliceOfValues(sp + 16);
|
||||||
|
const result = Reflect.construct(v, args);
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, result);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
} catch (err) {
|
||||||
|
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||||
|
storeValue(sp + 40, err);
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueLength(v ref) int
|
||||||
|
"syscall/js.valueLength": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||||
|
},
|
||||||
|
|
||||||
|
// valuePrepareString(v ref) (ref, int)
|
||||||
|
"syscall/js.valuePrepareString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||||
|
storeValue(sp + 16, str);
|
||||||
|
setInt64(sp + 24, str.length);
|
||||||
|
},
|
||||||
|
|
||||||
|
// valueLoadString(v ref, b []byte)
|
||||||
|
"syscall/js.valueLoadString": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const str = loadValue(sp + 8);
|
||||||
|
loadSlice(sp + 16).set(str);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func valueInstanceOf(v ref, t ref) bool
|
||||||
|
"syscall/js.valueInstanceOf": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||||
|
"syscall/js.copyBytesToGo": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadSlice(sp + 8);
|
||||||
|
const src = loadValue(sp + 32);
|
||||||
|
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||||
|
"syscall/js.copyBytesToJS": (sp) => {
|
||||||
|
sp >>>= 0;
|
||||||
|
const dst = loadValue(sp + 8);
|
||||||
|
const src = loadSlice(sp + 16);
|
||||||
|
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||||
|
this.mem.setUint8(sp + 48, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const toCopy = src.subarray(0, dst.length);
|
||||||
|
dst.set(toCopy);
|
||||||
|
setInt64(sp + 40, toCopy.length);
|
||||||
|
this.mem.setUint8(sp + 48, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug": (value) => {
|
||||||
|
console.log(value);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(instance) {
|
||||||
|
if (!(instance instanceof WebAssembly.Instance)) {
|
||||||
|
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||||
|
}
|
||||||
|
this._inst = instance;
|
||||||
|
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||||
|
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||||
|
NaN,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
globalThis,
|
||||||
|
this,
|
||||||
|
];
|
||||||
|
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||||
|
this._ids = new Map([ // mapping from JS values to reference ids
|
||||||
|
[0, 1],
|
||||||
|
[null, 2],
|
||||||
|
[true, 3],
|
||||||
|
[false, 4],
|
||||||
|
[globalThis, 5],
|
||||||
|
[this, 6],
|
||||||
|
]);
|
||||||
|
this._idPool = []; // unused ids that have been garbage collected
|
||||||
|
this.exited = false; // whether the Go program has exited
|
||||||
|
|
||||||
|
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||||
|
let offset = 4096;
|
||||||
|
|
||||||
|
const strPtr = (str) => {
|
||||||
|
const ptr = offset;
|
||||||
|
const bytes = encoder.encode(str + "\0");
|
||||||
|
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||||
|
offset += bytes.length;
|
||||||
|
if (offset % 8 !== 0) {
|
||||||
|
offset += 8 - (offset % 8);
|
||||||
|
}
|
||||||
|
return ptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
const argc = this.argv.length;
|
||||||
|
|
||||||
|
const argvPtrs = [];
|
||||||
|
this.argv.forEach((arg) => {
|
||||||
|
argvPtrs.push(strPtr(arg));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const keys = Object.keys(this.env).sort();
|
||||||
|
keys.forEach((key) => {
|
||||||
|
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||||
|
});
|
||||||
|
argvPtrs.push(0);
|
||||||
|
|
||||||
|
const argv = offset;
|
||||||
|
argvPtrs.forEach((ptr) => {
|
||||||
|
this.mem.setUint32(offset, ptr, true);
|
||||||
|
this.mem.setUint32(offset + 4, 0, true);
|
||||||
|
offset += 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||||
|
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||||
|
const wasmMinDataAddr = 4096 + 8192;
|
||||||
|
if (offset >= wasmMinDataAddr) {
|
||||||
|
throw new Error("total length of command line and environment variables exceeds limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._inst.exports.run(argc, argv);
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
await this._exitPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resume() {
|
||||||
|
if (this.exited) {
|
||||||
|
throw new Error("Go program has already exited");
|
||||||
|
}
|
||||||
|
this._inst.exports.resume();
|
||||||
|
if (this.exited) {
|
||||||
|
this._resolveExitPromise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeFuncWrapper(id) {
|
||||||
|
const go = this;
|
||||||
|
return function () {
|
||||||
|
const event = { id: id, this: this, args: arguments };
|
||||||
|
go._pendingEvent = event;
|
||||||
|
go._resume();
|
||||||
|
return event.result;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// wasm setup code
|
||||||
|
export default function init (path) {
|
||||||
|
if (typeof global === "undefined") {
|
||||||
|
var global = globalThis;
|
||||||
|
}
|
||||||
|
return new Promise ((res) => {
|
||||||
|
const go = new Go();
|
||||||
|
const run = (obj) => {
|
||||||
|
const wasm = obj.instance;
|
||||||
|
global.wfa = wasm
|
||||||
|
go.run(wasm);
|
||||||
|
res(wasm)
|
||||||
|
}
|
||||||
|
if ('instantiateStreaming' in WebAssembly) {
|
||||||
|
WebAssembly.instantiateStreaming(fetch(path), go.importObject).then(function (obj) {
|
||||||
|
run(obj)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fetch(path).then(resp =>
|
||||||
|
resp.arrayBuffer()
|
||||||
|
).then(bytes =>
|
||||||
|
WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
|
||||||
|
run(obj)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { requestAPI, setAppearance } from "./utils.js";
|
||||||
|
import { dialog, error } from "./dialog.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePasswordChangeButton () {
|
||||||
|
const template = document.querySelector("#change-password-dialog");
|
||||||
|
const d = dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const result = await requestAPI("/access/password", "POST", { password: form.get("new-password") });
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to change password but got: ${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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { requestAPI, getURIData, setAppearance, requestDash } from "./utils.js";
|
||||||
|
import { error, dialog } from "./dialog.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
let node;
|
||||||
|
let type;
|
||||||
|
let vmid;
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
const uriData = getURIData();
|
||||||
|
node = uriData.node;
|
||||||
|
type = uriData.type;
|
||||||
|
vmid = uriData.vmid;
|
||||||
|
|
||||||
|
document.querySelector("#backup-add").addEventListener("click", handleBackupAddButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BackupCard extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
|
||||||
|
const editButton = this.shadowRoot.querySelector("#edit-btn");
|
||||||
|
if (editButton.classList.contains("clickable")) {
|
||||||
|
editButton.onclick = this.handleEditButton.bind(this);
|
||||||
|
editButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.editButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||||
|
if (deleteButton.classList.contains("clickable")) {
|
||||||
|
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||||
|
deleteButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleDeleteButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreButton = this.shadowRoot.querySelector("#restore-btn");
|
||||||
|
if (restoreButton.classList.contains("clickable")) {
|
||||||
|
restoreButton.onclick = this.handleRestoreButton.bind(this);
|
||||||
|
restoreButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleRestoreButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get volid () {
|
||||||
|
return this.dataset.volid;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEditButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#edit-dialog");
|
||||||
|
dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid,
|
||||||
|
notes: form.get("notes")
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/notes`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to edit backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeleteButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "DELETE", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to delete backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRestoreButton () {
|
||||||
|
const template = this.shadowRoot.querySelector("#restore-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
volid: this.volid
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup/restore`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to delete backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("backup-card", BackupCard);
|
||||||
|
|
||||||
|
async function refreshBackups () {
|
||||||
|
let backups = await requestDash(`/backups/backups?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
if (backups.status !== 200) {
|
||||||
|
error("Error fetching backups.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
backups = backups.data;
|
||||||
|
const container = document.querySelector("#backups-container");
|
||||||
|
container.setHTMLUnsafe(backups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackupAddButton () {
|
||||||
|
const template = document.querySelector("#create-backup-dialog");
|
||||||
|
dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
notes: form.get("notes")
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/backup`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to create backup but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshBackups();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
import { requestAPI } from "./utils.js";
|
import { getSetting, requestAPI } from "./utils.js";
|
||||||
import { API } from "../vars.js";
|
import { error } from "./dialog.js";
|
||||||
|
|
||||||
export async function setupClientSync (callback) {
|
export async function setupClientSync (callback) {
|
||||||
let scheme = localStorage.getItem("sync-scheme");
|
const scheme = getSetting("sync-scheme");
|
||||||
let rate = Number(localStorage.getItem("sync-rate"));
|
const rate = getSetting("sync-rate");
|
||||||
if (!scheme) {
|
if (scheme === "never") {
|
||||||
scheme = "always";
|
return;
|
||||||
localStorage.setItem("sync-scheme", "always");
|
|
||||||
}
|
}
|
||||||
if (!rate) {
|
else if (scheme === "always") {
|
||||||
rate = "5";
|
|
||||||
localStorage.setItem("sync-rate", "5");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scheme === "always") {
|
|
||||||
callback();
|
|
||||||
window.setInterval(callback, rate * 1000);
|
window.setInterval(callback, rate * 1000);
|
||||||
}
|
}
|
||||||
else if (scheme === "hash") {
|
else if (scheme === "hash") {
|
||||||
const newHash = (await requestAPI("/sync/hash")).data;
|
const newHash = (await requestAPI("/sync/hash")).data;
|
||||||
localStorage.setItem("sync-current-hash", newHash);
|
localStorage.setItem("sync-current-hash", newHash);
|
||||||
callback();
|
|
||||||
window.setInterval(async () => {
|
window.setInterval(async () => {
|
||||||
const newHash = (await requestAPI("/sync/hash")).data;
|
const newHash = (await requestAPI("/sync/hash")).data;
|
||||||
if (localStorage.getItem("sync-current-hash") !== newHash) {
|
if (localStorage.getItem("sync-current-hash") !== newHash) {
|
||||||
@@ -30,9 +22,8 @@ export async function setupClientSync (callback) {
|
|||||||
}, rate * 1000);
|
}, rate * 1000);
|
||||||
}
|
}
|
||||||
else if (scheme === "interrupt") {
|
else if (scheme === "interrupt") {
|
||||||
callback();
|
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
|
||||||
const socket = new WebSocket(`wss://${API.replace("https://", "")}/sync/interrupt`);
|
socket.addEventListener("open", (_event) => {
|
||||||
socket.addEventListener("open", (event) => {
|
|
||||||
socket.send(`rate ${rate}`);
|
socket.send(`rate ${rate}`);
|
||||||
});
|
});
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
@@ -41,12 +32,12 @@ export async function setupClientSync (callback) {
|
|||||||
callback();
|
callback();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.error("clientsync: recieved unexpected message from server, closing socket.");
|
error("clientsync: recieved unexpected message from server, closing socket.");
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.error(`clientsync: unsupported scheme ${scheme} selected.`);
|
error(`clientsync: unsupported scheme ${scheme} selected.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { requestPVE, requestAPI, goToPage, getURIData, setAppearance, setIconSrc, requestDash, getCookie } from "./utils.js";
|
||||||
|
import { error, dialog } from "./dialog.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
let node;
|
||||||
|
let type;
|
||||||
|
let vmid;
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
const uriData = getURIData();
|
||||||
|
node = uriData.node;
|
||||||
|
type = uriData.type;
|
||||||
|
vmid = uriData.vmid;
|
||||||
|
|
||||||
|
initVolumes();
|
||||||
|
initNetworks();
|
||||||
|
initDevices();
|
||||||
|
|
||||||
|
document.querySelector("#config-form").addEventListener("submit", handleFormExit);
|
||||||
|
}
|
||||||
|
|
||||||
|
class VolumeAction extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
|
if (this.dataset.type === "move") {
|
||||||
|
this.addEventListener("click", this.handleDiskMove);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "resize") {
|
||||||
|
this.addEventListener("click", this.handleDiskResize);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "delete") {
|
||||||
|
this.addEventListener("click", this.handleDiskDelete);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "attach") {
|
||||||
|
this.addEventListener("click", this.handleDiskAttach);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "detach") {
|
||||||
|
this.addEventListener("click", this.handleDiskDetach);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatusLoading () {
|
||||||
|
const icon = document.querySelector(`img[data-volume="${this.dataset.volume}"]`);
|
||||||
|
setIconSrc(icon, "websrc/images/status/loading.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiskDetach () {
|
||||||
|
const disk = this.dataset.volume;
|
||||||
|
dialog(this.template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.setStatusLoading();
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/detach`, "POST");
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to detach ${disk} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiskAttach () {
|
||||||
|
dialog(this.template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const device = form.get("device");
|
||||||
|
this.setStatusLoading();
|
||||||
|
const body = {
|
||||||
|
source: this.dataset.volume.replace("unused", ""),
|
||||||
|
mp: form.get("mp")
|
||||||
|
};
|
||||||
|
const prefix = type === "qemu" ? "scsi" : "mp";
|
||||||
|
const disk = `${prefix}${device}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/attach`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to attach ${this.dataset.volume} to ${disk} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiskResize () {
|
||||||
|
dialog(this.template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const disk = this.dataset.volume;
|
||||||
|
this.setStatusLoading();
|
||||||
|
const body = {
|
||||||
|
size: form.get("size-increment")
|
||||||
|
};
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/resize`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to resize ${disk} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiskMove () {
|
||||||
|
const d = dialog(this.template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const disk = this.dataset.volume;
|
||||||
|
this.setStatusLoading();
|
||||||
|
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) {
|
||||||
|
error(`Attempted to move ${disk} to ${body.storage} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const content = type === "qemu" ? "images" : "rootdir";
|
||||||
|
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||||
|
const select = d.querySelector("#storage-select");
|
||||||
|
storage.data.forEach((element) => {
|
||||||
|
if (element.content.includes(content)) {
|
||||||
|
select.add(new Option(element.storage));
|
||||||
|
}
|
||||||
|
select.selectedIndex = -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiskDelete () {
|
||||||
|
const disk = this.dataset.volume;
|
||||||
|
dialog(this.template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.setStatusLoading();
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/delete`, "DELETE");
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to delete ${disk} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("volume-action", VolumeAction);
|
||||||
|
|
||||||
|
async function initVolumes () {
|
||||||
|
document.querySelector("#disk-add").addEventListener("click", handleDiskAdd);
|
||||||
|
if (type === "qemu") {
|
||||||
|
document.querySelector("#cd-add").addEventListener("click", handleCDAdd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshVolumes () {
|
||||||
|
let volumes = await requestDash(`/config/volumes?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
if (volumes.status !== 200) {
|
||||||
|
error("Error fetching instance volumes.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
volumes = volumes.data;
|
||||||
|
const container = document.querySelector("#volumes");
|
||||||
|
container.setHTMLUnsafe(volumes);
|
||||||
|
}
|
||||||
|
|
||||||
|
initVolumes();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDiskAdd () {
|
||||||
|
const template = document.querySelector("#add-disk-dialog");
|
||||||
|
const d = dialog(template, 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" ? "scsi" : "mp";
|
||||||
|
const disk = `${prefix}${id}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/disk/${disk}/create`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to create ${disk} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = type === "qemu" ? "images" : "rootdir";
|
||||||
|
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
|
||||||
|
const select = d.querySelector("#storage-select");
|
||||||
|
storage.data.forEach((element) => {
|
||||||
|
if (element.content.includes(content)) {
|
||||||
|
select.add(new Option(element.storage));
|
||||||
|
}
|
||||||
|
select.selectedIndex = -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCDAdd () {
|
||||||
|
const template = document.querySelector("#add-cd-dialog");
|
||||||
|
const d = dialog(template, 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) {
|
||||||
|
error(`Attempted to mount ${body.iso} to ${disk} but got: result.error`);
|
||||||
|
}
|
||||||
|
refreshVolumes();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isos = await requestAPI(`/access/users/${getCookie("username")}/vm-isos`, "GET");
|
||||||
|
const select = d.querySelector("#iso-select");
|
||||||
|
|
||||||
|
for (const iso of isos.data) {
|
||||||
|
select.add(new Option(iso.name, iso.volid));
|
||||||
|
}
|
||||||
|
select.selectedIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkAction extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
|
if (this.dataset.type === "config") {
|
||||||
|
this.addEventListener("click", this.handleNetworkConfig);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "delete") {
|
||||||
|
this.addEventListener("click", this.handleNetworkDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatusLoading () {
|
||||||
|
const icon = document.querySelector(`img[data-network="${this.dataset.network}"]`);
|
||||||
|
setIconSrc(icon, "websrc/images/status/loading.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleNetworkConfig () {
|
||||||
|
const netDetails = this.dataset.value;
|
||||||
|
const netID = this.dataset.network;
|
||||||
|
const d = dialog(this.template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.setStatusLoading();
|
||||||
|
const body = {
|
||||||
|
rate: form.get("rate")
|
||||||
|
};
|
||||||
|
const net = `${netID}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/modify`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to change ${net} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshNetworks();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
d.querySelector("#rate").value = netDetails.split("rate=")[1].split(",")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleNetworkDelete () {
|
||||||
|
const netID = this.dataset.network;
|
||||||
|
dialog(this.template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
setIconSrc(document.querySelector(`svg[data-network="${netID}"]`), "websrc/images/status/loading.svg");
|
||||||
|
const net = `${netID}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/delete`, "DELETE");
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to delete ${net} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshNetworks();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("network-action", NetworkAction);
|
||||||
|
|
||||||
|
async function initNetworks () {
|
||||||
|
document.querySelector("#network-add").addEventListener("click", handleNetworkAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNetworks () {
|
||||||
|
let nets = await requestDash(`/config/nets?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
if (nets.status !== 200) {
|
||||||
|
error("Error fetching instance nets.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nets = nets.data;
|
||||||
|
const container = document.querySelector("#networks");
|
||||||
|
container.setHTMLUnsafe(nets);
|
||||||
|
}
|
||||||
|
|
||||||
|
initNetworks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNetworkAdd () {
|
||||||
|
const template = document.querySelector("#add-net-dialog");
|
||||||
|
dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
rate: form.get("rate")
|
||||||
|
};
|
||||||
|
if (type === "lxc") {
|
||||||
|
body.name = form.get("name");
|
||||||
|
}
|
||||||
|
const id = form.get("netid");
|
||||||
|
const net = `net${id}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/net/${net}/create`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to create ${net} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshNetworks();
|
||||||
|
refreshBoot();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeviceAction extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.template = this.shadowRoot.querySelector("#dialog-template");
|
||||||
|
if (this.dataset.type === "config") {
|
||||||
|
this.addEventListener("click", this.handleDeviceConfig);
|
||||||
|
}
|
||||||
|
else if (this.dataset.type === "delete") {
|
||||||
|
this.addEventListener("click", this.handleDeviceDelete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatusLoading () {
|
||||||
|
const icon = document.querySelector(`img[data-device="${this.dataset.device}"]`);
|
||||||
|
setIconSrc(icon, "websrc/images/status/loading.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeviceConfig () {
|
||||||
|
const deviceID = this.dataset.device;
|
||||||
|
const deviceDetails = this.dataset.value;
|
||||||
|
const deviceName = this.dataset.name;
|
||||||
|
const d = dialog(this.template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.setStatusLoading();
|
||||||
|
const body = {
|
||||||
|
device: form.get("device"),
|
||||||
|
pcie: form.get("pcie") ? 1 : 0
|
||||||
|
};
|
||||||
|
const device = `${deviceID}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/modify`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to add ${device} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availDevices = await requestAPI(`/cluster/${node}/pci`, "GET");
|
||||||
|
d.querySelector("#device").append(new Option(deviceName, deviceDetails.split(",")[0]));
|
||||||
|
for (const availDevice of availDevices.data) {
|
||||||
|
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||||
|
}
|
||||||
|
d.querySelector("#pcie").checked = deviceDetails.includes("pcie=1");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDeviceDelete () {
|
||||||
|
const deviceID = this.dataset.device;
|
||||||
|
dialog(this.template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.setStatusLoading();
|
||||||
|
const device = `${deviceID}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${device}/delete`, "DELETE");
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to delete ${device} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("device-action", DeviceAction);
|
||||||
|
|
||||||
|
async function initDevices () {
|
||||||
|
if (type === "qemu") {
|
||||||
|
document.querySelector("#device-add").addEventListener("click", handleDeviceAdd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDevices () {
|
||||||
|
let devices = await requestDash(`/config/devices?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
if (devices.status !== 200) {
|
||||||
|
error("Error fetching instance devices.");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
devices = devices.data;
|
||||||
|
const container = document.querySelector("#devices");
|
||||||
|
container.setHTMLUnsafe(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
initDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeviceAdd () {
|
||||||
|
const template = document.querySelector("#add-device-dialog");
|
||||||
|
const d = dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const hostpci = form.get("hostpci");
|
||||||
|
const body = {
|
||||||
|
device: form.get("device"),
|
||||||
|
pcie: form.get("pcie") ? 1 : 0
|
||||||
|
};
|
||||||
|
const deviceID = `hostpci${hostpci}`;
|
||||||
|
const result = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci/${deviceID}/create`, "POST", body);
|
||||||
|
if (result.status !== 200) {
|
||||||
|
error(`Attempted to add ${body.device} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availDevices = await requestAPI(`/cluster/${node}/${type}/${vmid}/pci`, "GET");
|
||||||
|
for (const availDevice of availDevices.data) {
|
||||||
|
d.querySelector("#device").append(new Option(availDevice.device_name, availDevice.device_bus));
|
||||||
|
}
|
||||||
|
d.querySelector("#pcie").checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBoot () {
|
||||||
|
let boot = await requestDash(`/config/boot?node=${node}&type=${type}&vmid=${vmid}`, "GET");
|
||||||
|
if (boot.status !== 200) {
|
||||||
|
error("Error fetching instance boot order.");
|
||||||
|
}
|
||||||
|
else if (type === "qemu") {
|
||||||
|
boot = boot.data;
|
||||||
|
const container = document.querySelector("#boot-order");
|
||||||
|
container.setHTMLUnsafe(boot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormExit (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
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) {
|
||||||
|
goToPage("index");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error(`Attempted to set basic resources but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Spawn modal dialog from template node. Assumes the following structure:
|
||||||
|
* <template>
|
||||||
|
* <dialog>
|
||||||
|
* <p id="prompt"></p>
|
||||||
|
* <div id="body">
|
||||||
|
* <form id="form"> ... </form>
|
||||||
|
* </div>
|
||||||
|
* <div id="controls">
|
||||||
|
* <button value="..." form="form"
|
||||||
|
* <button value="..." form="form"
|
||||||
|
* ...
|
||||||
|
* </div>
|
||||||
|
* </dialog>
|
||||||
|
* </template>
|
||||||
|
* Where prompt is the modal dialog's prompt or header,
|
||||||
|
* body contains an optional form or other information,
|
||||||
|
* and controls contains a series of buttons which controls the form
|
||||||
|
*/
|
||||||
|
export function dialog (template, onclose = async (_result, _form) => { }) {
|
||||||
|
const dialog = template.content.querySelector("dialog").cloneNode(true);
|
||||||
|
document.body.append(dialog);
|
||||||
|
dialog.addEventListener("close", async () => {
|
||||||
|
const formElem = dialog.querySelector("form");
|
||||||
|
const formData = formElem ? new FormData(formElem) : null;
|
||||||
|
await onclose(dialog.returnValue, formData);
|
||||||
|
formElem.reset();
|
||||||
|
dialog.close();
|
||||||
|
dialog.parentElement.removeChild(dialog);
|
||||||
|
});
|
||||||
|
if (!dialog.querySelector("form")) {
|
||||||
|
for (const control of dialog.querySelector("#controls").childNodes) {
|
||||||
|
control.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dialog.close(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.body.append(dialog);
|
||||||
|
dialog.showModal();
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorDialog extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
dialog = null;
|
||||||
|
errors = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
this.shadowRoot = this.attachShadow({ mode: "open" });
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<link rel="stylesheet" href="websrc/modules/w3.css">
|
||||||
|
<link rel="stylesheet" href="websrc/css/style.css">
|
||||||
|
<link rel="stylesheet" href="websrc/css/form.css">
|
||||||
|
<style>
|
||||||
|
#errors {
|
||||||
|
margin-bottom: 0;
|
||||||
|
max-height: 20lh;
|
||||||
|
min-height: 20lh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
#errors * {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<dialog class="w3-container w3-card w3-border-0">
|
||||||
|
<form method="dialog">
|
||||||
|
<p class="w3-large" id="prompt">Error</p>
|
||||||
|
<div id="errors" class="flex column"></div>
|
||||||
|
<div class="w3-center" id="controls">
|
||||||
|
<button class="w3-button w3-margin" type="submit" value="ok">OK</button>
|
||||||
|
<button class="w3-button w3-margin" type="submit" value="copy">Copy</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
`;
|
||||||
|
this.dialog = this.shadowRoot.querySelector("dialog");
|
||||||
|
this.errors = this.shadowRoot.querySelector("#errors");
|
||||||
|
|
||||||
|
for (const control of this.shadowRoot.querySelector("#controls").childNodes) {
|
||||||
|
control.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dialog.close(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dialog.addEventListener("close", () => {
|
||||||
|
if (this.dialog.returnValue === "copy") {
|
||||||
|
let errors = "";
|
||||||
|
for (const error of this.errors.childNodes) {
|
||||||
|
errors += `${error.innerText}\n`;
|
||||||
|
}
|
||||||
|
navigator.clipboard.writeText(errors);
|
||||||
|
}
|
||||||
|
this.dialog.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appendError (error) {
|
||||||
|
error = `${(new Date()).toUTCString()}: ${error}`;
|
||||||
|
const p = document.createElement("p");
|
||||||
|
p.innerText = error;
|
||||||
|
this.errors.appendChild(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal () {
|
||||||
|
this.dialog.showModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("error-dialog", ErrorDialog);
|
||||||
|
|
||||||
|
export function error (message) {
|
||||||
|
let dialog = document.querySelector("error-dialog");
|
||||||
|
if (dialog === null) {
|
||||||
|
dialog = document.createElement("error-dialog");
|
||||||
|
document.body.append(dialog);
|
||||||
|
dialog.appendError(message);
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
dialog.appendError(message);
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const blank = document.createElement("img");
|
||||||
|
blank.src = "websrc/images/common/blank.png"; // for whatever reason an svg does NOT work here
|
||||||
|
|
||||||
|
class DraggableContainer extends HTMLElement {
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.content = this.shadowRoot.querySelector("#wrapper");
|
||||||
|
|
||||||
|
window.Sortable.create(this.content, {
|
||||||
|
group: this.dataset.group,
|
||||||
|
ghostClass: "ghost",
|
||||||
|
setData: function (dataTransfer, _dragEl) {
|
||||||
|
dataTransfer.setDragImage(blank, 0, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
append (newNode) {
|
||||||
|
this.content.appendChild(newNode, this.bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBefore (newNode, referenceNode) {
|
||||||
|
this.content.insertBefore(newNode, referenceNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector (query) {
|
||||||
|
return this.content.querySelector(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasChildNodes (query) {
|
||||||
|
return this.querySelector(query) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild (node) {
|
||||||
|
if (node && this.content.contains(node)) {
|
||||||
|
this.content.removeChild(node);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set value (value) {}
|
||||||
|
|
||||||
|
get value () {
|
||||||
|
const value = [];
|
||||||
|
this.content.querySelectorAll(".draggable-item").forEach((element) => {
|
||||||
|
if (element.dataset.value) {
|
||||||
|
value.push(element.dataset.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("draggable-container", DraggableContainer);
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import { requestPVE, requestAPI, setAppearance, getSetting, requestDash, setIconSrc, setIconAlt, getCookie } from "./utils.js";
|
||||||
|
import { dialog, error } from "./dialog.js";
|
||||||
|
import { setupClientSync } from "./clientsync.js";
|
||||||
|
import wfaInit from "../modules/wfa.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
var wfa;
|
||||||
|
|
||||||
|
async function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
wfa = await wfaInit("websrc/modules/wfa.wasm.gz");
|
||||||
|
initInstances();
|
||||||
|
|
||||||
|
document.querySelector("#instance-add").addEventListener("click", handleInstanceAddButton);
|
||||||
|
document.querySelector("#vm-search").addEventListener("input", sortInstances);
|
||||||
|
|
||||||
|
setupClientSync(refreshInstances);
|
||||||
|
}
|
||||||
|
|
||||||
|
class InstanceCard extends HTMLElement {
|
||||||
|
actionLock = false;
|
||||||
|
shadowRoot = null;
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
super();
|
||||||
|
const internals = this.attachInternals();
|
||||||
|
this.shadowRoot = internals.shadowRoot;
|
||||||
|
this.actionLock = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get type () {
|
||||||
|
return this.dataset.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
set type (type) {
|
||||||
|
this.dataset.type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
get status () {
|
||||||
|
return this.dataset.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
set status (status) {
|
||||||
|
this.dataset.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
get vmid () {
|
||||||
|
return this.dataset.vmid;
|
||||||
|
}
|
||||||
|
|
||||||
|
set vmid (vmid) {
|
||||||
|
this.dataset.vmid = vmid;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name () {
|
||||||
|
return this.dataset.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
set name (name) {
|
||||||
|
this.dataset.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
get node () {
|
||||||
|
return {
|
||||||
|
name: this.dataset.node,
|
||||||
|
status: this.dataset.nodestatus
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set node (node) {
|
||||||
|
this.dataset.node = node.name;
|
||||||
|
this.dataset.nodetsatus = node.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
set searchQueryResult (result) {
|
||||||
|
this.dataset.searchqueryresult = JSON.stringify(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
get searchQueryResult () {
|
||||||
|
return JSON.parse(!this.dataset.searchqueryresult ? "{}" : this.dataset.searchqueryresult);
|
||||||
|
}
|
||||||
|
|
||||||
|
update () {
|
||||||
|
const nameParagraph = this.shadowRoot.querySelector("#instance-name");
|
||||||
|
nameParagraph.innerText = "";
|
||||||
|
if (this.searchQueryResult.alignment) {
|
||||||
|
let i = 0; // name index
|
||||||
|
let c = 0; // alignment index
|
||||||
|
const alignment = this.searchQueryResult.alignment;
|
||||||
|
while (i < this.name.length && c < alignment.length) {
|
||||||
|
if (alignment[c] === "M") {
|
||||||
|
const part = document.createElement("span");
|
||||||
|
part.innerText = this.name[i];
|
||||||
|
part.style = "color: var(--lightbg-text-color); background-color: var(--highlight-color);";
|
||||||
|
nameParagraph.append(part);
|
||||||
|
i++;
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
else if (alignment[c] === "I") {
|
||||||
|
const part = document.createElement("span");
|
||||||
|
part.innerText = this.name[i];
|
||||||
|
nameParagraph.append(part);
|
||||||
|
i++;
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
else if (alignment[c] === "D") {
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
else if (alignment[c] === "X") {
|
||||||
|
const part = document.createElement("span");
|
||||||
|
part.innerText = this.name[i];
|
||||||
|
nameParagraph.append(part);
|
||||||
|
i++;
|
||||||
|
c++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nameParagraph.innerHTML = this.name ? this.name : " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
const powerButton = this.shadowRoot.querySelector("#power-btn");
|
||||||
|
if (powerButton !== null) {
|
||||||
|
if (powerButton.classList.contains("clickable")) {
|
||||||
|
powerButton.onclick = this.handlePowerButton.bind(this);
|
||||||
|
powerButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handlePowerButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteButton = this.shadowRoot.querySelector("#delete-btn");
|
||||||
|
if (deleteButton !== null) {
|
||||||
|
if (deleteButton.classList.contains("clickable")) {
|
||||||
|
deleteButton.onclick = this.handleDeleteButton.bind(this);
|
||||||
|
deleteButton.onkeydown = (event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.handleDeleteButton();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusLoading () {
|
||||||
|
this.status = "loading";
|
||||||
|
const statusicon = this.shadowRoot.querySelector("#status");
|
||||||
|
const powerbtn = this.shadowRoot.querySelector("#power-btn");
|
||||||
|
setIconSrc(statusicon, "websrc/images/status/loading.svg");
|
||||||
|
setIconAlt(statusicon, "websrc/instance is loading");
|
||||||
|
setIconSrc(powerbtn, "websrc/images/status/loading.svg");
|
||||||
|
setIconAlt(powerbtn, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async handlePowerButton () {
|
||||||
|
if (!this.actionLock) {
|
||||||
|
const template = this.shadowRoot.querySelector("#power-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.actionLock = true;
|
||||||
|
const targetAction = this.status === "running" ? "stop" : "start";
|
||||||
|
|
||||||
|
const result = await requestPVE(`/nodes/${this.node.name}/${this.type}/${this.vmid}/status/${targetAction}`, "POST", { node: this.node.name, vmid: this.vmid });
|
||||||
|
this.setStatusLoading();
|
||||||
|
|
||||||
|
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
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (taskStatus.data.status === "stopped") { // task stopped but was not successful
|
||||||
|
error(`Attempted to ${targetAction} ${this.vmid} but got: ${taskStatus.data.exitstatus}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else { // task has not stopped
|
||||||
|
await waitFor(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actionLock = false;
|
||||||
|
refreshInstances();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDeleteButton () {
|
||||||
|
if (!this.actionLock && this.status === "stopped") {
|
||||||
|
const template = this.shadowRoot.querySelector("#delete-dialog");
|
||||||
|
dialog(template, async (result, _form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
this.actionLock = true;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
error(`Attempted to delete ${this.vmid} but got: ${result.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actionLock = false;
|
||||||
|
refreshInstances();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("instance-card", InstanceCard);
|
||||||
|
|
||||||
|
async function refreshInstances () {
|
||||||
|
let instances = await requestDash("/index/instances", "GET");
|
||||||
|
if (instances.status !== 200) {
|
||||||
|
error(`Error fetching instances: ${instances.status} ${instances.error !== undefined ? instances.error : ""}`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
instances = instances.data;
|
||||||
|
const container = document.querySelector("#instance-container");
|
||||||
|
container.setHTMLUnsafe(instances);
|
||||||
|
sortInstances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initInstances () {
|
||||||
|
const container = document.querySelector("#instance-container");
|
||||||
|
let instances = container.children;
|
||||||
|
instances = [].slice.call(instances);
|
||||||
|
for (let i = 0; i < instances.length; i++) {
|
||||||
|
instances[i].update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortInstances () {
|
||||||
|
const searchCriteria = getSetting("search-criteria");
|
||||||
|
const searchQuery = document.querySelector("#search").value || null;
|
||||||
|
let criteria;
|
||||||
|
if (!searchQuery) {
|
||||||
|
criteria = (item, _query = null) => {
|
||||||
|
return { score: item.vmid, alignment: null };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (searchCriteria === "exact") {
|
||||||
|
criteria = (item, query) => {
|
||||||
|
const substrInc = item.includes(query);
|
||||||
|
if (substrInc) {
|
||||||
|
const substrStartIndex = item.indexOf(query);
|
||||||
|
const queryLength = query.length;
|
||||||
|
const remaining = item.length - substrInc - queryLength + 1;
|
||||||
|
const alignment = `${"X".repeat(substrStartIndex)}${"M".repeat(queryLength)}${"X".repeat(remaining)}`;
|
||||||
|
return { score: -1, alignment };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const alignment = `${"X".repeat(item.length)}`;
|
||||||
|
return { score: 0, alignment };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (searchCriteria === "fuzzy") {
|
||||||
|
const penalties = {
|
||||||
|
m: 0,
|
||||||
|
x: 2,
|
||||||
|
o: 3,
|
||||||
|
e: 1
|
||||||
|
};
|
||||||
|
criteria = (item, query) => {
|
||||||
|
// lower is better
|
||||||
|
const { score, CIGAR } = wfa.wfAlign(query, item, penalties, true);
|
||||||
|
const alignment = wfa.DecodeCIGAR(CIGAR);
|
||||||
|
const maxlength = Math.max(item.length, query.length);
|
||||||
|
return { score: score / maxlength, alignment };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.querySelector("#instance-container");
|
||||||
|
let instances = container.children;
|
||||||
|
instances = [].slice.call(instances);
|
||||||
|
|
||||||
|
for (let i = 0; i < instances.length; i++) {
|
||||||
|
if (!instances[i].dataset.name) { // if the instance has no name, assume its just empty string
|
||||||
|
instances[i].dataset.name = "";
|
||||||
|
}
|
||||||
|
const { score, alignment } = criteria(instances[i].dataset.name.toLowerCase(), searchQuery ? searchQuery.toLowerCase() : "");
|
||||||
|
instances[i].searchQueryResult = { score, alignment };
|
||||||
|
}
|
||||||
|
const sortCriteria = (a, b) => {
|
||||||
|
const aScore = a.searchQueryResult.score;
|
||||||
|
const bScore = b.searchQueryResult.score;
|
||||||
|
if (aScore === bScore) {
|
||||||
|
return a.vmid > b.vmid ? 1 : -1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return aScore - bScore;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
instances.sort(sortCriteria);
|
||||||
|
|
||||||
|
for (let i = 0; i < instances.length; i++) {
|
||||||
|
container.appendChild(instances[i]);
|
||||||
|
instances[i].update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstanceAddButton () {
|
||||||
|
const template = document.querySelector("#create-instance-dialog");
|
||||||
|
const d = dialog(template, async (result, form) => {
|
||||||
|
if (result === "confirm") {
|
||||||
|
const body = {
|
||||||
|
name: form.get("name"),
|
||||||
|
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) {
|
||||||
|
refreshInstances();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
error(`Attempted to create new instance ${vmid} but got: ${result.error}`);
|
||||||
|
refreshInstances();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup type select
|
||||||
|
const typeSelect = d.querySelector("#type");
|
||||||
|
typeSelect.selectedIndex = -1;
|
||||||
|
// on type change, reveal or hide the container specific section
|
||||||
|
typeSelect.addEventListener("change", () => {
|
||||||
|
if (typeSelect.value === "qemu") {
|
||||||
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
|
element.classList.add("none");
|
||||||
|
element.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
|
element.classList.remove("none");
|
||||||
|
element.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
d.querySelectorAll(".container-specific").forEach((element) => {
|
||||||
|
element.classList.add("none");
|
||||||
|
element.disabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup pool select
|
||||||
|
const poolSelect = d.querySelector("#pool");
|
||||||
|
poolSelect.innerHTML = "";
|
||||||
|
// add user pools to selector
|
||||||
|
const userPools = Object.keys((await requestAPI("/access/pools", "GET")).data.pools);
|
||||||
|
userPools.forEach((element) => {
|
||||||
|
poolSelect.add(new Option(element));
|
||||||
|
});
|
||||||
|
poolSelect.selectedIndex = -1;
|
||||||
|
// on pool change, get the allowed nodes for that pool, then repopulate the node selector
|
||||||
|
poolSelect.addEventListener("change", async () => {
|
||||||
|
const pool = (await requestAPI(`/access/pools/${poolSelect.value}`, "GET")).data.pool;
|
||||||
|
|
||||||
|
const nodeSelect = d.querySelector("#node");
|
||||||
|
nodeSelect.innerHTML = "";
|
||||||
|
const clusterNodes = (await requestPVE("/nodes", "GET")).data;
|
||||||
|
const allowedNodes = Object.keys(pool["nodes-allowed"]);
|
||||||
|
clusterNodes.forEach((element) => {
|
||||||
|
if (element.status === "online" && allowedNodes.includes(element.node)) {
|
||||||
|
nodeSelect.add(new Option(element.node));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
nodeSelect.selectedIndex = -1;
|
||||||
|
|
||||||
|
// set vmid min/max
|
||||||
|
d.querySelector("#vmid").min = pool["vmid-allowed"].min;
|
||||||
|
d.querySelector("#vmid").max = pool["vmid-allowed"].max;
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup node select
|
||||||
|
const nodeSelect = d.querySelector("#node");
|
||||||
|
nodeSelect.selectedIndex = -1;
|
||||||
|
// on node change, get the available storages and repopulate the storage selector
|
||||||
|
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
|
||||||
|
const node = nodeSelect.value;
|
||||||
|
const storage = (await requestPVE(`/nodes/${node}/storage`, "GET")).data;
|
||||||
|
rootfsStorage.innerHTML = "";
|
||||||
|
storage.forEach((element) => {
|
||||||
|
if (element.content.includes(rootfsContent)) {
|
||||||
|
rootfsStorage.add(new Option(element.storage));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rootfsStorage.selectedIndex = -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup root dir select
|
||||||
|
const rootfsStorage = d.querySelector("#rootfs-storage");
|
||||||
|
rootfsStorage.selectedIndex = -1;
|
||||||
|
// set rootfs content type (rootdir)
|
||||||
|
const rootfsContent = "rootdir";
|
||||||
|
|
||||||
|
// setup templateImage depending on selected image storage
|
||||||
|
const templateImage = d.querySelector("#template-image");
|
||||||
|
// add template images to selector
|
||||||
|
const templates = await requestAPI(`/access/users/${getCookie("username")}/ct-templates`, "GET");
|
||||||
|
for (const template of templates.data) {
|
||||||
|
templateImage.append(new Option(template.name, template.volid));
|
||||||
|
}
|
||||||
|
templateImage.selectedIndex = -1;
|
||||||
|
|
||||||
|
// setup custom password checker for containers
|
||||||
|
const password = d.querySelector("#password");
|
||||||
|
const confirmPassword = d.querySelector("#confirm-password");
|
||||||
|
function validatePassword () {
|
||||||
|
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
|
||||||
|
}
|
||||||
|
password.addEventListener("change", validatePassword);
|
||||||
|
confirmPassword.addEventListener("keyup", validatePassword);
|
||||||
|
|
||||||
|
d.showModal();
|
||||||
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
import { requestTicket, goToPage, deleteAllCookies, requestPVE, setTitleAndHeader } from "./utils.js";
|
import { goToPage, setAppearance, requestAPI } from "./utils.js";
|
||||||
import { alert } from "./dialog.js";
|
import { error } from "./dialog.js";
|
||||||
|
|
||||||
window.addEventListener("DOMContentLoaded", init);
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
async function init () {
|
async function init () {
|
||||||
setTitleAndHeader();
|
|
||||||
await deleteAllCookies();
|
await deleteAllCookies();
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
const formSubmitButton = document.querySelector("#submit");
|
const formSubmitButton = document.querySelector("#submit");
|
||||||
const realms = await requestPVE("/access/domains", "GET");
|
|
||||||
const realmSelect = document.querySelector("#realm");
|
|
||||||
realms.data.forEach((element) => {
|
|
||||||
realmSelect.add(new Option(element.comment, element.realm));
|
|
||||||
if ("default" in element && element.default === 1) {
|
|
||||||
realmSelect.value = element.realm;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
formSubmitButton.addEventListener("click", async (e) => {
|
formSubmitButton.addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = document.querySelector("form");
|
const form = document.querySelector("form");
|
||||||
@@ -24,20 +17,28 @@ async function init () {
|
|||||||
const ticket = await requestTicket(formData.get("username"), formData.get("password"), formData.get("realm"));
|
const ticket = await requestTicket(formData.get("username"), formData.get("password"), formData.get("realm"));
|
||||||
if (ticket.status === 200) {
|
if (ticket.status === 200) {
|
||||||
formSubmitButton.innerText = "LOGIN";
|
formSubmitButton.innerText = "LOGIN";
|
||||||
goToPage("index.html");
|
goToPage("index");
|
||||||
}
|
}
|
||||||
else if (ticket.status === 401) {
|
else if (ticket.status === 401) {
|
||||||
alert("Authenticaton failed.");
|
error("Authenticaton failed.");
|
||||||
formSubmitButton.innerText = "LOGIN";
|
formSubmitButton.innerText = "LOGIN";
|
||||||
}
|
}
|
||||||
else if (ticket.status === 408) {
|
else if (ticket.status === 408) {
|
||||||
alert("Network error.");
|
error("Network error.");
|
||||||
formSubmitButton.innerText = "LOGIN";
|
formSubmitButton.innerText = "LOGIN";
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
alert("An error occured.");
|
error(`An error occured: ${JSON.stringify(ticket)}`);
|
||||||
formSubmitButton.innerText = "LOGIN";
|
formSubmitButton.innerText = "LOGIN";
|
||||||
console.error(ticket.error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function requestTicket (username, password, realm) {
|
||||||
|
const response = await requestAPI("/access/ticket", "POST", { username: `${username}@${realm}`, password }, false);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAllCookies () {
|
||||||
|
await requestAPI("/access/ticket", "DELETE");
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { setAppearance, getSetting, setSetting } from "./utils.js";
|
||||||
|
|
||||||
|
window.addEventListener("DOMContentLoaded", init);
|
||||||
|
|
||||||
|
function init () {
|
||||||
|
setAppearance();
|
||||||
|
|
||||||
|
document.querySelectorAll("[id^=sync-]").forEach((v) => {
|
||||||
|
v.addEventListener("change", handleSettingsChange);
|
||||||
|
});
|
||||||
|
document.querySelector(`#sync-${getSetting("sync-scheme")}`).checked = true;
|
||||||
|
document.querySelector("#sync-rate").value = getSetting("sync-rate");
|
||||||
|
|
||||||
|
document.querySelectorAll("[id^=search-]").forEach((v) => {
|
||||||
|
v.addEventListener("change", handleSettingsChange);
|
||||||
|
});
|
||||||
|
document.querySelector(`#search-${getSetting("search-criteria")}`).checked = true;
|
||||||
|
|
||||||
|
document.querySelector("#appearance-theme").addEventListener("change", handleSettingsChange);
|
||||||
|
document.querySelector("#appearance-theme").value = getSetting("appearance-theme");
|
||||||
|
|
||||||
|
document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSettingsChange (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(document.querySelector("#settings"));
|
||||||
|
const saveBtn = document.querySelector("#save");
|
||||||
|
if (getSetting("sync-scheme") !== form.get("sync-scheme")) {
|
||||||
|
saveBtn.classList.remove("disabled");
|
||||||
|
saveBtn.classList.add("enabled");
|
||||||
|
}
|
||||||
|
else if (getSetting("sync-rate") !== Number(form.get("sync-rate"))) {
|
||||||
|
saveBtn.classList.remove("disabled");
|
||||||
|
saveBtn.classList.add("enabled");
|
||||||
|
}
|
||||||
|
else if (getSetting("search-criteria") !== form.get("search-criteria")) {
|
||||||
|
saveBtn.classList.remove("disabled");
|
||||||
|
saveBtn.classList.add("enabled");
|
||||||
|
}
|
||||||
|
else if (getSetting("appearance-theme") !== form.get("appearance-theme")) {
|
||||||
|
saveBtn.classList.remove("disabled");
|
||||||
|
saveBtn.classList.add("enabled");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
saveBtn.classList.remove("enabled");
|
||||||
|
saveBtn.classList.add("disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveSettings (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(document.querySelector("#settings"));
|
||||||
|
const saveBtn = document.querySelector("#save");
|
||||||
|
setSetting("sync-scheme", form.get("sync-scheme"));
|
||||||
|
setSetting("sync-rate", Number(form.get("sync-rate")));
|
||||||
|
setSetting("search-criteria", form.get("search-criteria"));
|
||||||
|
setSetting("appearance-theme", form.get("appearance-theme"));
|
||||||
|
saveBtn.classList.remove("enabled");
|
||||||
|
saveBtn.classList.add("disabled");
|
||||||
|
init();
|
||||||
|
}
|
||||||