use GODEBUG=embedfollowsymlinks=1 to support symlinked web src files
tronnet/workflows: Code Style -- Web Components / style-web (push) Failing after 18s
tronnet/workflows: Static Analysis -- Golang / static-go (push) Failing after 22s

This commit is contained in:
alu committed 2026-10-22 20:54:23 +00:00
1 parent 98790dbe3b
commit 5c658db34e
94 files changed
+11 -14

No files matched your search

+30
View File
@@ -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);
}
+145
View File
@@ -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();
}
});
}
+43
View File
@@ -0,0 +1,43 @@
import { getSetting, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
export async function setupClientSync (callback) {
const scheme = getSetting("sync-scheme");
const rate = getSetting("sync-rate");
if (scheme === "never") {
return;
}
else if (scheme === "always") {
window.setInterval(callback, rate * 1000);
}
else if (scheme === "hash") {
const newHash = (await requestAPI("/sync/hash")).data;
localStorage.setItem("sync-current-hash", newHash);
window.setInterval(async () => {
const newHash = (await requestAPI("/sync/hash")).data;
if (localStorage.getItem("sync-current-hash") !== newHash) {
localStorage.setItem("sync-current-hash", newHash);
callback();
}
}, rate * 1000);
}
else if (scheme === "interrupt") {
const socket = new WebSocket(`wss://${window.API.replace("https://", "")}/sync/interrupt`);
socket.addEventListener("open", (_event) => {
socket.send(`rate ${rate}`);
});
socket.addEventListener("message", (event) => {
const message = event.data.toString();
if (message === "sync") {
callback();
}
else {
error("clientsync: recieved unexpected message from server, closing socket.");
socket.close();
}
});
}
else {
error(`clientsync: unsupported scheme ${scheme} selected.`);
}
}
+479
View File
@@ -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, "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, "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}"]`), "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, "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}`);
}
}
+130
View File
@@ -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;
}
+61
View File
@@ -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);
+438
View File
@@ -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 : "&nbsp;";
}
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, "images/status/loading.svg");
setIconAlt(statusicon, "instance is loading");
setIconSrc(powerbtn, "images/status/loading.svg");
setIconAlt(powerbtn, "");
}
async handlePowerButton () {
if (!this.actionLock) {
const template = this.shadowRoot.querySelector("#power-dialog");
dialog(template, async (result, _form) => {
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();
}
+44
View File
@@ -0,0 +1,44 @@
import { goToPage, setAppearance, requestAPI } from "./utils.js";
import { error } from "./dialog.js";
window.addEventListener("DOMContentLoaded", init);
async function init () {
await deleteAllCookies();
setAppearance();
const formSubmitButton = document.querySelector("#submit");
formSubmitButton.addEventListener("click", async (e) => {
e.preventDefault();
const form = document.querySelector("form");
const formData = new FormData(form);
formSubmitButton.innerText = "Authenticating...";
const ticket = await requestTicket(formData.get("username"), formData.get("password"), formData.get("realm"));
if (ticket.status === 200) {
formSubmitButton.innerText = "LOGIN";
goToPage("index");
}
else if (ticket.status === 401) {
error("Authenticaton failed.");
formSubmitButton.innerText = "LOGIN";
}
else if (ticket.status === 408) {
error("Network error.");
formSubmitButton.innerText = "LOGIN";
}
else {
error(`An error occured: ${JSON.stringify(ticket)}`);
formSubmitButton.innerText = "LOGIN";
}
});
}
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");
}
+62
View File
@@ -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();
}
+171
View File
@@ -0,0 +1,171 @@
export function setCookie (cname, cval) {
document.cookie = `${cname}=${cval}`;
}
export function getCookie (cname) {
const name = cname + "=";
const decodedCookie = decodeURIComponent(document.cookie);
const ca = decodedCookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") {
c = c.substring(1);
}
if (c.indexOf(name) === 0) {
return c.substring(name.length, c.length);
}
}
return "";
}
export async function requestPVE (path, method, body = null) {
const prms = new URLSearchParams(body);
const content = {
method,
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
};
if (method === "POST") {
content.body = prms.toString();
content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken");
}
const response = await request(`${window.API}/proxmox${path}`, content);
return response;
}
export async function requestAPI (path, method, body = null) {
const prms = new URLSearchParams(body);
const content = {
method,
mode: "cors",
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
};
if (method === "POST" || method === "DELETE") {
content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken");
}
if (body) {
content.body = prms.toString();
}
const response = await request(`${window.API}${path}`, content);
return response;
}
export async function requestDash (path, method, body = null) {
const prms = new URLSearchParams(body);
const content = {
method,
credentials: "include",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
}
};
content.headers.CSRFPreventionToken = getCookie("CSRFPreventionToken");
if (body) {
content.body = prms.toString();
}
const response = await request(`${window.DASH}${path}`, content);
return response;
}
async function request (url, content) {
try {
const response = await fetch(url, content);
const contentType = response.headers.get("Content-Type");
const res = {};
if (contentType === null) {
res.data = null;
res.status = response.status;
}
else if (contentType.includes("application/json")) {
res.data = await response.json();
res.status = response.status;
}
else if (contentType.includes("text/html")) {
res.data = await response.text();
res.status = response.status;
}
else if (contentType.includes("text/plain")) {
res.data = await response.text();
res.status = response.status;
}
else {
res.data = null;
res.status = response.status;
}
if (!response.ok) {
return { status: response.status, error: res.data ? res.data.error : response.status };
}
else {
return res;
}
}
catch (error) {
return { status: 400, error };
}
}
export function goToPage (page, data = null) {
const params = data ? (new URLSearchParams(data)).toString() : "";
window.location.href = `${page}${data ? "?" : ""}${params}`;
}
export function getURIData () {
const url = new URL(window.location.href);
return Object.fromEntries(url.searchParams);
}
const settings = {
"sync-scheme": {"type": String, "default": "always"},
"sync-rate": {"type": Number, "default": 5},
"search-criteria": {"type": String, "default": "fuzzy"},
"appearance-theme": {"type": String, "default": "auto"}
};
export function getSetting (key) {
const meta = settings[key];
let value = localStorage.getItem(key);
if (value === null || meta === null) {
value = meta.default;
localStorage.setItem(key, meta.default);
}
return meta.type(value);
}
export function setSetting (key, value) {
localStorage.setItem(key, value);
}
export function setAppearance () {
const theme = getSetting("appearance-theme");
if (theme === "auto") {
document.querySelector(":root").classList.remove("dark-theme", "light-theme");
}
else if (theme === "dark") {
document.querySelector(":root").classList.remove("light-theme");
document.querySelector(":root").classList.add("dark-theme");
}
else if (theme === "light") {
document.querySelector(":root").classList.add("light-theme");
document.querySelector(":root").classList.remove("dark-theme");
}
}
export function setIconSrc (icon, path) {
icon.setAttribute("src", path);
}
export function setIconAlt (icon, alt) {
icon.setAttribute("alt", alt);
}