use GODEBUG=embedfollowsymlinks=1 to support symlinked web src files
This commit is contained in:
1 parent
98790dbe3b
commit
5c658db34e
94 files changed
+11
-14
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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, "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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user