Compare commits

...

10 Commits

Author SHA1 Message Date
80ac09e040 add fuzzy search using wfa 2024-06-05 22:11:53 +00:00
199f1b1e99 fix lint 2024-04-27 03:50:29 +00:00
29c36cdbf5 update for new pool and node format 2024-04-16 21:38:25 +00:00
a6a76930f9 fix styling 2024-01-29 21:49:19 +00:00
e5a0e92ebb fix instance power button prompt 2023-12-23 02:07:25 +00:00
230fe2c9e4 update account page, instance creation form, and config page,
fix issue with instance delete confirm popup,
fix issues with dialog exit handling without form element
2023-11-15 20:18:01 +00:00
7f4bdacef0 fix linting 2023-11-14 22:24:26 +00:00
e97f8d5bbc fix bugs in network config change,
generalize dialog layout,
add form validation callback,
add password change form
2023-11-14 00:09:41 +00:00
4806d7f18f fix bug with sync and deleting instance 2023-11-07 05:09:28 +00:00
89f0b14c21 add password form to account,
fix form input grid styling
2023-10-31 18:13:40 +00:00
13 changed files with 599 additions and 128 deletions

View File

@ -9,6 +9,7 @@
<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>
@ -58,10 +59,16 @@
<div class="w3-card w3-padding">
<h3>Account Details</h3>
<p id="username">Username:</p>
<p id="pool">Pool:</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>

View File

@ -1,8 +1,6 @@
.input-grid {
float: left;
display: grid;
column-gap: 10px;
row-gap: 5px;
gap: 5px 10px;
align-items: center;
width: 100%;
}
@ -34,6 +32,10 @@ fieldset > *:last-child {
margin-bottom: 8px;
}
fieldset > .input-grid {
float: left;
}
body:not(:-moz-handler-blocked) fieldset {
display: table-cell;
}

View File

@ -112,3 +112,15 @@ hr, * {
.spacer {
min-height: 1em;
}
.w3-select, select {
padding: 8px;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cstyle%3E:root%7Bcolor:%23fff%7D@media (prefers-color-scheme:light)%7B:root%7Bcolor:%23000%7D%7D%3C/style%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M12.707 14.707a1 1 0 01-1.414 0l-5-5a1 1 0 011.414-1.414L12 12.586l4.293-4.293a1 1 0 111.414 1.414l-5 5z' fill='currentColor'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 8px top 50%;
background-size: 1em auto;
}

View File

@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/form.css">
<script src="scripts/index.js" type="module"></script>
<script src="scripts/instance.js" type="module"></script>
<script src="modules/wfa.js" type="module"></script>
<style>
#instance-container > div {
border-bottom: 1px solid white;

328
modules/wfa.js Normal file
View File

@ -0,0 +1,328 @@
class WavefrontComponent {
constructor () {
this.lo = [0]; // lo for each wavefront
this.hi = [0]; // hi for each wavefront
this.W = []; // wavefront diag distance for each wavefront
this.A = []; // compact CIGAR for backtrace
}
// get value for wavefront=score, diag=k
get_val (score, k) {
if (this.W[score] !== undefined && this.W[score][k] !== undefined) {
return this.W[score][k];
}
else {
return NaN;
}
}
// set value for wavefront=score, diag=k
set_val (score, k, val) {
if (this.W[score]) {
this.W[score][k] = val;
}
else {
this.W[score] = [];
this.W[score][k] = val;
}
}
// get alignment traceback
get_traceback (score, k) {
if (this.A[score] !== undefined && this.A[score][k] !== undefined) {
return this.A[score][k];
}
else {
return undefined;
}
}
// set alignment traceback
set_traceback (score, k, traceback) {
if (this.A[score]) {
this.A[score][k] = traceback;
}
else {
this.A[score] = [];
this.A[score][k] = traceback;
}
}
// get hi for wavefront=score
get_hi (score) {
const hi = this.hi[score];
return isNaN(hi) ? 0 : hi;
}
// set hi for wavefront=score
set_hi (score, hi) {
this.hi[score] = hi;
}
// get lo for wavefront=score
get_lo (score) {
const lo = this.lo[score];
return isNaN(lo) ? 0 : lo;
}
// set lo for wavefront=score
set_lo (score, lo) {
this.lo[score] = lo;
}
// string representation of all wavefronts
toString () {
const traceback_str = ["OI", "EI", "OD", "ED", "SB", "IN", "DL", "EN"];
let s = "<";
let min_lo = Infinity;
let max_hi = -Infinity;
// get the min lo and max hi values across all wavefronts
for (let i = 0; i < this.W.length; i++) {
const lo = this.lo[i];
const hi = this.hi[i];
if (lo < min_lo) {
min_lo = lo;
}
if (hi > max_hi) {
max_hi = hi;
}
}
// print out two headers, one for wavefront and one for traceback
for (let k = min_lo; k <= max_hi; k++) {
s += FormatNumberLength(k, 2);
if (k < max_hi) {
s += "|";
}
}
s += ">\t<";
for (let k = min_lo; k <= max_hi; k++) {
s += FormatNumberLength(k, 2);
if (k < max_hi) {
s += "|";
}
}
s += ">\n";
// for each wavefront
for (let i = 0; i < this.W.length; i++) {
s += "[";
const lo = this.lo[i];
const hi = this.hi[i];
// print out the wavefront matrix
for (let k = min_lo; k <= max_hi; k++) {
if (this.W[i] !== undefined && this.W[i][k] !== undefined && !isNaN(this.W[i][k])) {
s += FormatNumberLength(this.W[i][k], 2);
}
else if (k < lo || k > hi) {
s += "--";
}
else {
s += " ";
}
if (k < max_hi) {
s += "|";
}
}
s += "]\t[";
// print out the traceback matrix
for (let k = min_lo; k <= max_hi; k++) {
if (this.A[i] !== undefined && this.A[i][k] !== undefined) {
s += traceback_str[this.A[i][k].toString()];
}
else if (k < lo || k > hi) {
s += "--";
}
else {
s += " ";
}
if (k < max_hi) {
s += "|";
}
}
s += "]\n";
}
return s;
}
}
const traceback = {
OpenIns: 0,
ExtdIns: 1,
OpenDel: 2,
ExtdDel: 3,
Sub: 4,
Ins: 5,
Del: 6,
End: 7
};
function FormatNumberLength (num, length) {
let r = "" + num;
while (r.length < length) {
r = " " + r;
}
return r;
}
function min (args) {
args.forEach((el, idx, arr) => {
arr[idx] = isNaN(el) ? Infinity : el;
});
const min = Math.min.apply(Math, args);
return min === Infinity ? NaN : min;
}
function max (args) {
args.forEach((el, idx, arr) => {
arr[idx] = isNaN(el) ? -Infinity : el;
});
const max = Math.max.apply(Math, args);
return max === -Infinity ? NaN : max;
}
function argmax (args) {
const val = max(args);
return args.indexOf(val);
}
export default function wf_align (s1, s2, penalties) {
const n = s1.length;
const m = s2.length;
const A_k = m - n;
const A_offset = m;
let score = 0;
const M = new WavefrontComponent();
M.set_val(0, 0, 0);
M.set_hi(0, 0);
M.set_lo(0, 0);
M.set_traceback(0, 0, traceback.End);
const I = new WavefrontComponent();
const D = new WavefrontComponent();
while (true) {
wf_extend(M, s1, n, s2, m, score);
if (M.get_val(score, A_k) >= A_offset) {
break;
}
score++;
wf_next(M, I, D, score, penalties);
}
return wf_backtrace(M, I, D, score, penalties, A_k, A_offset);
}
function wf_extend (M, s1, n, s2, m, score) {
const lo = M.get_lo(score);
const hi = M.get_hi(score);
for (let k = lo; k <= hi; k++) {
let v = M.get_val(score, k) - k;
let h = M.get_val(score, k);
if (isNaN(v) || isNaN(h)) {
continue;
}
while (s1[v] === s2[h]) {
M.set_val(score, k, M.get_val(score, k) + 1);
v++;
h++;
if (v > n || h > m) {
break;
}
}
}
}
function wf_next (M, I, D, score, penalties) {
const x = penalties.x;
const o = penalties.o;
const e = penalties.e;
const lo = min([M.get_lo(score - x), M.get_lo(score - o - e), I.get_lo(score - e), D.get_lo(score - e)]) - 1;
const hi = max([M.get_hi(score - x), M.get_hi(score - o - e), I.get_hi(score - e), D.get_hi(score - e)]) + 1;
M.set_hi(score, hi);
I.set_hi(score, hi);
D.set_hi(score, hi);
M.set_lo(score, lo);
I.set_lo(score, lo);
D.set_lo(score, lo);
for (let k = lo; k <= hi; k++) {
I.set_val(score, k, max([
M.get_val(score - o - e, k - 1),
I.get_val(score - e, k - 1)
]) + 1);
I.set_traceback(score, k, [traceback.OpenIns, traceback.ExtdIns][argmax([
M.get_val(score - o - e, k - 1),
I.get_val(score - e, k - 1)
])]);
D.set_val(score, k, max([
M.get_val(score - o - e, k + 1),
D.get_val(score - e, k + 1)
]));
D.set_traceback(score, k, [traceback.OpenDel, traceback.ExtdDel][argmax([
M.get_val(score - o - e, k + 1),
D.get_val(score - e, k + 1)
])]);
M.set_val(score, k, max([
M.get_val(score - x, k) + 1,
I.get_val(score, k),
D.get_val(score, k)
]));
M.set_traceback(score, k, [traceback.Sub, traceback.Ins, traceback.Del][argmax([
M.get_val(score - x, k) + 1,
I.get_val(score, k),
D.get_val(score, k)
])]);
}
}
function wf_backtrace (M, I, D, score, penalties, A_k) {
const traceback_CIGAR = ["I", "I", "D", "D", "X", "", "", ""];
const x = penalties.x;
const o = penalties.o;
const e = penalties.e;
let CIGAR_rev = ""; // reversed CIGAR
let tb_s = score; // traceback score
let tb_k = A_k; // traceback diag k
let current_traceback = M.get_traceback(tb_s, tb_k);
let done = false;
while (!done) {
CIGAR_rev += traceback_CIGAR[current_traceback];
switch (current_traceback) {
case traceback.OpenIns:
tb_s = tb_s - o - e;
tb_k = tb_k - 1;
current_traceback = M.get_traceback(tb_s, tb_k);
break;
case traceback.ExtdIns:
tb_s = tb_s - e;
tb_k = tb_k - 1;
current_traceback = I.get_traceback(tb_s, tb_k);
break;
case traceback.OpenDel:
tb_s = tb_s - o - e;
tb_k = tb_k + 1;
current_traceback = M.get_traceback(tb_s, tb_k);
break;
case traceback.ExtdDel:
tb_s = tb_s - e;
tb_k = tb_k + 1;
current_traceback = D.get_traceback(tb_s, tb_k);
break;
case traceback.Sub:
tb_s = tb_s - x;
// tb_k = tb_k;
current_traceback = M.get_traceback(tb_s, tb_k);
break;
case traceback.Ins:
// tb_s = tb_s;
// tb_k = tb_k;
current_traceback = I.get_traceback(tb_s, tb_k);
break;
case traceback.Del:
// tb_s = tb_s;
// tb_k = tb_k;
current_traceback = D.get_traceback(tb_s, tb_k);
break;
case traceback.End:
done = true;
break;
}
}
const CIGAR = Array.from(CIGAR_rev).reverse().join("");
return { CIGAR, score };
}

View File

@ -4,7 +4,7 @@
"description": "Front-end for ProxmoxAAS",
"type": "module",
"scripts": {
"lint": "html-validator --continue; stylelint --formatter verbose --fix **/*.css; DEBUG=eslint:cli-engine eslint --fix ."
"lint": "html-validator --continue; stylelint --formatter verbose --fix **/*.css; DEBUG=eslint:cli-engine eslint --fix scripts/"
},
"devDependencies": {
"eslint": "^8.43.0",

View File

@ -1,3 +1,4 @@
import { dialog } from "./dialog.js";
import { requestAPI, goToPage, getCookie, setTitleAndHeader } from "./utils.js";
window.addEventListener("DOMContentLoaded", init);
@ -28,20 +29,20 @@ async function init () {
let resources = requestAPI("/user/dynamic/resources");
let meta = requestAPI("/global/config/resources");
let instances = requestAPI("/user/config/cluster");
let nodes = requestAPI("/user/config/nodes");
let userCluster = requestAPI("/user/config/cluster");
resources = await resources;
meta = await meta;
instances = await instances;
nodes = await nodes;
userCluster = await userCluster;
document.querySelector("#username").innerText = `Username: ${getCookie("username")}`;
document.querySelector("#pool").innerText = `Pool: ${instances.pool}`;
document.querySelector("#vmid").innerText = `VMID Range: ${instances.vmid.min} - ${instances.vmid.max}`;
document.querySelector("#nodes").innerText = `Nodes: ${nodes.toString()}`;
document.querySelector("#pool").innerText = `Pools: ${Object.keys(userCluster.pools).toString()}`;
document.querySelector("#vmid").innerText = `VMID Range: ${userCluster.vmid.min} - ${userCluster.vmid.max}`;
document.querySelector("#nodes").innerText = `Nodes: ${Object.keys(userCluster.nodes).toString()}`;
populateResources("#resource-container", meta, resources);
document.querySelector("#change-password").addEventListener("click", handlePasswordChangeForm);
}
function populateResources (containerID, meta, resources) {
@ -50,12 +51,12 @@ function populateResources (containerID, meta, resources) {
Object.keys(meta).forEach((resourceType) => {
if (meta[resourceType].display) {
if (meta[resourceType].type === "list") {
resources[resourceType].forEach((listResource) => {
resources[resourceType].total.forEach((listResource) => {
createResourceUsageChart(container, listResource.name, listResource.avail, listResource.used, listResource.max, null);
});
}
else {
createResourceUsageChart(container, meta[resourceType].name, resources[resourceType].avail, resources[resourceType].used, resources[resourceType].max, meta[resourceType]);
createResourceUsageChart(container, meta[resourceType].name, resources[resourceType].total.avail, resources[resourceType].total.used, resources[resourceType].total.max, meta[resourceType]);
}
}
});
@ -117,3 +118,32 @@ function parseNumber (value, unitData) {
return `${value} ${unit}`;
}
}
function handlePasswordChangeForm () {
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="new-password">New Password</label>
<input class="w3-input w3-border" id="new-password" name="new-password" type="password"required>
<label for="confirm-password">Confirm Password</label>
<input class="w3-input w3-border" id="confirm-password" name="confirm-password" type="password" required>
</form>
`;
const d = dialog("Change Password", body, async (result, form) => {
if (result === "confirm") {
const result = await requestAPI("/auth/password", "POST", { password: form.get("new-password") });
if (result.status !== 200) {
alert(result.error);
}
}
});
const password = d.querySelector("#new-password");
const confirmPassword = d.querySelector("#confirm-password");
function validatePassword () {
confirmPassword.setCustomValidity(password.value !== confirmPassword.value ? "Passwords Don't Match" : "");
}
password.addEventListener("change", validatePassword);
confirmPassword.addEventListener("keyup", validatePassword);
}

View File

@ -54,8 +54,10 @@ async function populateResources () {
const global = await requestAPI("/global/config/resources");
const user = await requestAPI("/user/config/resources");
let options = [];
if (global.cpu.whitelist) {
user.cpu.forEach((userType) => {
const globalCPU = global.cpu;
const userCPU = node in user.cpu.nodes ? user.cpu.nodes[node] : user.cpu.global;
if (globalCPU.whitelist) {
userCPU.forEach((userType) => {
options.push(userType.name);
});
options = options.sort((a, b) => {
@ -65,7 +67,7 @@ async function populateResources () {
else {
const supported = await requestPVE(`/nodes/${node}/capabilities/qemu/cpu`);
supported.data.forEach((supportedType) => {
if (!user.cpu.some((userType) => supportedType.name === userType.name)) {
if (!userCPU.some((userType) => supportedType.name === userType.name)) {
options.push(supportedType.name);
}
});
@ -233,7 +235,7 @@ function addDiskLine (fieldset, busPrefix, busName, device, diskDetails) {
async function handleDiskDetach () {
const disk = this.dataset.disk;
const header = `Detach ${disk}`;
const body = `<p>Are you sure you want to detach disk</p><p>${disk}</p>`;
const body = `<p>Are you sure you want to detach disk ${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
@ -250,7 +252,12 @@ async function handleDiskDetach () {
async function handleDiskAttach () {
const header = `Attach ${this.dataset.disk}`;
const body = `<label for="device">${type === "qemu" ? "SATA" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" required></input>`;
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SATA" : "MP"}</label>
<input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" required></input>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -274,7 +281,12 @@ async function handleDiskAttach () {
async function handleDiskResize () {
const header = `Resize ${this.dataset.disk}`;
const body = "<label for=\"size-increment\">Size Increment (GiB)</label><input class=\"w3-input w3-border\" name=\"size-increment\" id=\"size-increment\" type=\"number\" min=\"0\" max=\"131072\"></input>";
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="size-increment">Size Increment (GiB)</label>
<input class="w3-input w3-border" name="size-increment" id="size-increment" type="number" min="0" max="131072"></input>
</form>
`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -310,8 +322,10 @@ async function handleDiskMove () {
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select"><option hidden disabled selected value></option>${options}</select>`;
const body = `
${select}
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
${select}
<label for="delete-check">Delete Source</label><input class="w3-input w3-border" name="delete-check" id="delete-check" type="checkbox" checked required>
</form>
`;
dialog(header, body, async (result, form) => {
@ -337,7 +351,7 @@ async function handleDiskMove () {
async function handleDiskDelete () {
const disk = this.dataset.disk;
const header = `Delete ${disk}`;
const body = `<p>Are you sure you want to <strong>delete</strong> disk</p><p>${disk}</p>`;
const body = `<p>Are you sure you want to <strong>delete</strong> disk${disk}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
document.querySelector(`img[data-disk="${disk}"]`).src = "images/status/loading.svg";
@ -367,9 +381,11 @@ async function handleDiskAdd () {
const select = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${options}</select>`;
const body = `
<label for="device">${type === "qemu" ? "SATA" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" value="0" required></input>
${select}
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required></input>
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">${type === "qemu" ? "SATA" : "MP"}</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="${type === "qemu" ? "5" : "255"}" value="0" required></input>
${select}
<label for="size">Size (GiB)</label><input class="w3-input w3-border" name="size" id="size" type="number" min="0" max="131072" required></input>
</form>
`;
dialog(header, body, async (result, form) => {
@ -393,23 +409,15 @@ async function handleDiskAdd () {
}
async function handleCDAdd () {
const content = "iso";
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
const isos = await requestAPI("/user/vm-isos", "GET");
const header = "Add a CDROM";
let storageOptions = "";
storage.data.forEach((element) => {
if (element.content.includes(content)) {
storageOptions += `<option value="${element.storage}">${element.storage}</option>"`;
}
});
const storageSelect = `<label for="storage-select">Storage</label><select class="w3-select w3-border" name="storage-select" id="storage-select" required><option hidden disabled selected value></option>${storageOptions}</select>`;
const body = `
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required></input>
${storageSelect}
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">IDE</label><input class="w3-input w3-border" name="device" id="device" type="number" min="0" max="3" required></input>
<label for="iso-select">Image</label><select class="w3-select w3-border" name="iso-select" id="iso-select" required></select>
</form>
`;
const d = dialog(header, body, async (result, form) => {
@ -428,17 +436,12 @@ async function handleCDAdd () {
}
});
d.querySelector("#storage-select").addEventListener("change", async () => {
const storage = document.querySelector("#storage-select").value;
const ISOSelect = document.querySelector("#iso-select");
ISOSelect.innerHTML = "<option hidden disabled selected value></option>";
const isos = await requestPVE(`/nodes/${node}/storage/${storage}/content`, "GET", { content });
isos.data.forEach((element) => {
if (element.content.includes(content)) {
ISOSelect.append(new Option(element.volid.replace(`${storage}:${content}/`, ""), element.volid));
}
});
});
const isoSelect = d.querySelector("#iso-select");
for (const iso of isos) {
isoSelect.append(new Option(iso.name, iso.volid));
}
isoSelect.selectedIndex = -1;
}
async function populateNetworks () {
@ -509,7 +512,11 @@ async function handleNetworkConfig () {
const netID = this.dataset.network;
const netDetails = this.dataset.values;
const header = `Edit net${netID}`;
const body = "<label for=\"rate\">Rate Limit (MB/s)</label><input type=\"number\" id=\"rate\" name=\"rate\" class=\"w3-input w3-border\">";
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -523,7 +530,8 @@ async function handleNetworkConfig () {
}
await getConfig();
populateNetworks();
updateBootLine(`boot-net${netID}`, { id: `net${netID}`, prefix: "net", value: config.data[`net${netID}`] });
const id = `net${netID}`;
updateBootLine(`boot-net${netID}`, { id, prefix: "net", value: id, detail: config.data[`net${netID}`] });
}
});
@ -551,10 +559,15 @@ async function handleNetworkDelete () {
async function handleNetworkAdd () {
const header = "Create Network Interface";
let body = "<label for=\"netid\">Interface ID</label><input type=\"number\" id=\"netid\" name=\"netid\" class=\"w3-input w3-border\"><label for=\"rate\">Rate Limit (MB/s)</label><input type=\"number\" id=\"rate\" name=\"rate\" class=\"w3-input w3-border\">";
let body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="netid">Interface ID</label><input type="number" id="netid" name="netid" class="w3-input w3-border">
<label for="rate">Rate Limit (MB/s)</label><input type="number" id="rate" name="rate" class="w3-input w3-border">
`;
if (type === "lxc") {
body += "<label for=\"name\">Interface Name</label><input type=\"text\" id=\"name\" name=\"name\" class=\"w3-input w3-border\"></input>";
}
body += "</form>";
dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -648,7 +661,11 @@ async function handleDeviceConfig () {
const deviceDetails = this.dataset.values;
const deviceName = this.dataset.name;
const header = `Edit Expansion Card ${deviceID}`;
const body = "<label for=\"device\">Device</label><select id=\"device\" name=\"device\" required></select><label for=\"pcie\">PCI-Express</label><input type=\"checkbox\" id=\"pcie\" name=\"pcie\" class=\"w3-input w3-border\">";
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -694,7 +711,11 @@ async function handleDeviceDelete () {
async function handleDeviceAdd () {
const header = "Add Expansion Card";
const body = "<label for=\"device\">Device</label><select id=\"device\" name=\"device\" required></select><label for=\"pcie\">PCI-Express</label><input type=\"checkbox\" id=\"pcie\" name=\"pcie\" class=\"w3-input w3-border\">";
const body = `
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="device">Device</label><select id="device" name="device" required></select><label for="pcie">PCI-Express</label><input type="checkbox" id="pcie" name="pcie" class="w3-input w3-border">
</form>
`;
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -798,11 +819,11 @@ function updateBootLine (id, newData) {
const enabled = document.querySelector("#enabled");
const disabled = document.querySelector("#disabled");
let element = null;
if (enabled.getItemByID(id)) {
element = enabled.getItemByID(id);
if (enabled.querySelector(`#${id}`)) {
element = enabled.querySelector(`#${id}`);
}
if (disabled.getItemByID(id)) {
element = disabled.getItemByID(id);
if (disabled.querySelector(`#${id}`)) {
element = disabled.querySelector(`#${id}`);
}
if (element) {
const container = element.container;

View File

@ -1,20 +1,32 @@
export function dialog (header, body, callback = async (result, form) => { }) {
export function dialog (header, body, onclose = async (result, form) => { }) {
const dialog = document.createElement("dialog");
dialog.innerHTML = `
<p class="w3-large" id="prompt" style="text-align: center;"></p>
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form"></form>
<div id="body"></div>
<div class="w3-center w3-container">
<button value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
<button id="cancel" value="cancel" form="form" class="w3-button w3-margin" style="background-color: var(--negative-color, #f00); color: var(--lightbg-text-color, black);" formnovalidate>CANCEL</button>
<button id="confirm" value="confirm" form="form" class="w3-button w3-margin" style="background-color: var(--positive-color, #0f0); color: var(--lightbg-text-color, black);">CONFIRM</button>
</div>
`;
dialog.className = "w3-container w3-card w3-border-0";
dialog.querySelector("#prompt").innerText = header;
dialog.querySelector("form").innerHTML = body;
dialog.querySelector("#body").innerHTML = body;
dialog.addEventListener("close", async () => {
await callback(dialog.returnValue, new FormData(dialog.querySelector("form")));
const formElem = dialog.querySelector("form");
const formData = formElem ? new FormData(formElem) : null;
await onclose(dialog.returnValue, formData);
dialog.parentElement.removeChild(dialog);
});
if (!dialog.querySelector("form")) {
dialog.querySelector("#confirm").addEventListener("click", async (e) => {
e.preventDefault();
dialog.close(e.target.value);
});
dialog.querySelector("#cancel").addEventListener("click", async (e) => {
e.preventDefault();
dialog.close(e.target.value);
});
}
document.body.append(dialog);
dialog.showModal();
return dialog;

View File

@ -1,6 +1,7 @@
import { requestPVE, requestAPI, goToPage, setTitleAndHeader } from "./utils.js";
import { alert, dialog } from "./dialog.js";
import { setupClientSync } from "./clientsync.js";
import wf_align from "../modules/wfa.js";
window.addEventListener("DOMContentLoaded", init);
@ -38,6 +39,11 @@ async function getInstances () {
}
async function populateInstances () {
let searchCriteria = localStorage.getItem("search-criteria");
if (!searchCriteria) {
searchCriteria = "fuzzy";
localStorage.setItem("search-criteria", "fuzzy");
}
const searchQuery = document.querySelector("#search").value || null;
let criteria;
if (!searchQuery) {
@ -45,7 +51,7 @@ async function populateInstances () {
return (a.vmid > b.vmid) ? 1 : -1;
};
}
else {
else if (searchCriteria === "exact") {
criteria = (a, b) => {
const aInc = a.name.toLowerCase().includes(searchQuery.toLowerCase());
const bInc = b.name.toLowerCase().includes(searchQuery.toLowerCase());
@ -63,6 +69,27 @@ async function populateInstances () {
}
};
}
else if (searchCriteria === "fuzzy") {
const penalties = {
m: 0,
x: 1,
o: 1,
e: 1
};
criteria = (a, b) => {
// lower is better
const aAlign = wf_align(a.name.toLowerCase(), searchQuery.toLowerCase(), penalties);
const aScore = aAlign.score / a.name.length;
const bAlign = wf_align(b.name.toLowerCase(), searchQuery.toLowerCase(), penalties);
const bScore = bAlign.score / b.name.length;
if (aScore === bScore) {
return a.vmid > b.vmid ? 1 : -1;
}
else {
return aScore - bScore;
}
};
}
instances.sort(criteria);
const instanceContainer = document.querySelector("#instance-container");
instanceContainer.innerHTML = "";
@ -78,42 +105,47 @@ async function handleInstanceAdd () {
const header = "Create New Instance";
const body = `
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" required></input>
<label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required></input>
<label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required></input>
<label for="memory">Memory (MiB)</label>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required></input>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled></input>
<label class="container-specific none" for="template-storage">Template Storage</label>
<select class="w3-select w3-border container-specific none" name="template-storage" id="template-storage" required disabled></select>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled></input>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled></input>
<form method="dialog" class="input-grid" style="grid-template-columns: auto 1fr;" id="form">
<label for="type">Instance Type</label>
<select class="w3-select w3-border" name="type" id="type" required>
<option value="lxc">Container</option>
<option value="qemu">Virtual Machine</option>
</select>
<label for="node">Node</label>
<select class="w3-select w3-border" name="node" id="node" required></select>
<label for="name">Name</label>
<input class="w3-input w3-border" name="name" id="name" required></input>
<label for="vmid">ID</label>
<input class="w3-input w3-border" name="vmid" id="vmid" type="number" required></input>
<label for="pool">Pool</label>
<select class="w3-select w3-border" name="pool" id="pool" required></select>
<label for="cores">Cores (Threads)</label>
<input class="w3-input w3-border" name="cores" id="cores" type="number" min="1" max="8192" required></input>
<label for="memory">Memory (MiB)</label>
<input class="w3-input w3-border" name="memory" id="memory" type="number" min="16", step="1" required></input>
<p class="container-specific none" style="grid-column: 1 / span 2; text-align: center;">Container Options</p>
<label class="container-specific none" for="swap">Swap (MiB)</label>
<input class="w3-input w3-border container-specific none" name="swap" id="swap" type="number" min="0" step="1" required disabled></input>
<label class="container-specific none" for="template-image">Template Image</label>
<select class="w3-select w3-border container-specific none" name="template-image" id="template-image" required disabled></select>
<label class="container-specific none" for="rootfs-storage">ROOTFS Storage</label>
<select class="w3-select w3-border container-specific none" name="rootfs-storage" id="rootfs-storage" required disabled></select>
<label class="container-specific none" for="rootfs-size">ROOTFS Size (GiB)</label>
<input class="w3-input w3-border container-specific none" name="rootfs-size" id="rootfs-size" type="number" min="0" max="131072" required disabled></input>
<label class="container-specific none" for="password">Password</label>
<input class="w3-input w3-border container-specific none" name="password" id="password" type="password" required disabled></input>
</form>
`;
const templates = await requestAPI("/user/ct-templates", "GET");
const d = dialog(header, body, async (result, form) => {
if (result === "confirm") {
const body = {
name: form.get("name"),
cores: form.get("cores"),
memory: form.get("memory")
memory: form.get("memory"),
pool: form.get("pool")
};
if (form.get("type") === "lxc") {
body.swap = form.get("swap");
@ -153,55 +185,64 @@ async function handleInstanceAdd () {
}
});
const templateContent = "iso";
const templateStorage = d.querySelector("#template-storage");
templateStorage.selectedIndex = -1;
const rootfsContent = "rootdir";
const rootfsStorage = d.querySelector("#rootfs-storage");
rootfsStorage.selectedIndex = -1;
const userResources = await requestAPI("/user/dynamic/resources", "GET");
const userCluster = await requestAPI("/user/config/cluster", "GET");
const nodeSelect = d.querySelector("#node");
const clusterNodes = await requestPVE("/nodes", "GET");
const allowedNodes = await requestAPI("/user/config/nodes", "GET");
const allowedNodes = Object.keys(userCluster.nodes);
clusterNodes.data.forEach((element) => {
if (element.status === "online" && allowedNodes.includes(element.node)) {
nodeSelect.add(new Option(element.node));
}
});
nodeSelect.selectedIndex = -1;
nodeSelect.addEventListener("change", async () => { // change template and rootfs storage based on node
nodeSelect.addEventListener("change", async () => { // change rootfs storage based on node
const node = nodeSelect.value;
const storage = await requestPVE(`/nodes/${node}/storage`, "GET");
storage.data.forEach((element) => {
if (element.content.includes(templateContent)) {
templateStorage.add(new Option(element.storage));
}
if (element.content.includes(rootfsContent)) {
rootfsStorage.add(new Option(element.storage));
}
});
templateStorage.selectedIndex = -1;
rootfsStorage.selectedIndex = -1;
// set core and memory min/max depending on node selected
if (node in userResources.cores.nodes) {
d.querySelector("#cores").max = userResources.cores.nodes[node].avail;
}
else {
d.querySelector("#cores").max = userResources.cores.global.avail;
}
if (node in userResources.memory.nodes) {
d.querySelector("#memory").max = userResources.memory.nodes[node].avail;
}
else {
d.querySelector("#memory").max = userResources.memory.global.avail;
}
});
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
templateStorage.addEventListener("change", async () => {
templateImage.innerHTML = "";
const content = "vztmpl";
const images = await requestPVE(`/nodes/${nodeSelect.value}/storage/${templateStorage.value}/content`, "GET");
images.data.forEach((element) => {
if (element.content.includes(content)) {
templateImage.append(new Option(element.volid.replace(`${templateStorage.value}:${content}/`, ""), element.volid));
}
});
templateImage.selectedIndex = -1;
});
const userResources = await requestAPI("/user/dynamic/resources", "GET");
const userCluster = await requestAPI("/user/config/cluster", "GET");
d.querySelector("#cores").max = userResources.cores.avail;
d.querySelector("#memory").max = userResources.memory.avail;
// set vmid min/max
d.querySelector("#vmid").min = userCluster.vmid.min;
d.querySelector("#vmid").max = userCluster.vmid.max;
// add user pools to selector
const poolSelect = d.querySelector("#pool");
const userPools = Object.keys(userCluster.pools);
userPools.forEach((element) => {
poolSelect.add(new Option(element));
});
poolSelect.selectedIndex = -1;
// add template images to selector
const templateImage = d.querySelector("#template-image"); // populate templateImage depending on selected image storage
for (const template of templates) {
templateImage.append(new Option(template.name, template.volid));
}
templateImage.selectedIndex = -1;
}

View File

@ -162,7 +162,7 @@ class InstanceCard extends HTMLElement {
async handlePowerButton () {
if (!this.actionLock) {
const header = `${this.status === "running" ? "Stop" : "Start"} VM ${this.vmid}`;
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM</p><p>${this.vmid}</p>`;
const body = `<p>Are you sure you want to ${this.status === "running" ? "stop" : "start"} VM ${this.vmid}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -219,7 +219,7 @@ class InstanceCard extends HTMLElement {
handleDeleteButton () {
if (!this.actionLock && this.status === "stopped") {
const header = `Delete VM ${this.vmid}`;
const body = `<p>Are you sure you want to <strong>delete</strong> VM </p><p>${this.vmid}</p>`;
const body = `<p>Are you sure you want to <strong>delete</strong> VM ${this.vmid}</p>`;
dialog(header, body, async (result, form) => {
if (result === "confirm") {
@ -233,7 +233,9 @@ class InstanceCard extends HTMLElement {
const result = await requestAPI(`/cluster/${this.node.name}/${this.type}/${this.vmid}/delete`, "DELETE");
if (result.status === 200) {
this.parentElement.removeChild(this);
if (this.parentElement) {
this.parentElement.removeChild(this);
}
}
else {
alert(result.error);

View File

@ -12,6 +12,10 @@ function init () {
if (rate) {
document.querySelector("#sync-rate").value = rate;
}
const search = localStorage.getItem("search-criteria");
if (search) {
document.querySelector(`#search-${search}`).checked = true;
}
document.querySelector("#settings").addEventListener("submit", handleSaveSettings, false);
}
@ -20,5 +24,6 @@ function handleSaveSettings (event) {
const form = new FormData(document.querySelector("#settings"));
localStorage.setItem("sync-scheme", form.get("sync-scheme"));
localStorage.setItem("sync-rate", form.get("sync-rate"));
localStorage.setItem("search-criteria", form.get("search-criteria"));
window.location.reload();
}

View File

@ -51,10 +51,10 @@
<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><span>Always Sync</span></label>
<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 synchronize only if there have been change. Medium resource usage.</p>
<p>App will periodically check for updates and synchronize only if needed. Medium resource usage.</p>
<label><input class="w3-radio" type="radio" id="sync-interrupt" name="sync-scheme" value="interrupt" required>Sync When Needed</label>
<p>App will react to changes and synchronize when changes are made. Low resource usage.</p>
</fieldset>
@ -65,6 +65,16 @@
</div>
</fieldset>
</div>
<div class="w3-card w3-padding">
<h3>Search Settings</h3>
<fieldset>
<legend>Instance Search Criteria</legend>
<label><input class="w3-radio" type="radio" id="search-exact" name="search-criteria" value="exact" required>Exact Match</label>
<p>Sorts by exact query match in instance name.</p>
<label><input class="w3-radio" type="radio" id="search-fuzzy" name="search-criteria" value="fuzzy" required>Fuzzy Match</label>
<p>Sorts by best matching to worst matching.</p>
</fieldset>
</div>
<div class="w3-container w3-center" id="form-actions">
<button class="w3-button w3-margin" id="save" type="submit">SAVE</button>
</div>