diff --git a/scripts/admin.js b/scripts/admin.js index b9bec1e..283fed8 100644 --- a/scripts/admin.js +++ b/scripts/admin.js @@ -1,4 +1,4 @@ -import { setTitleAndHeader, setAppearance, requestAPI, goToPage } from "./utils.js"; +import { setTitleAndHeader, setAppearance, requestAPI, goToPage, isEmpty } from "./utils.js"; window.addEventListener("DOMContentLoaded", init); @@ -52,6 +52,9 @@ class UserCard extends HTMLElement { * { margin: 0; } + p { + width: 100%; + }

@@ -95,7 +98,12 @@ class UserCard extends HTMLElement { nameParagraph.innerText = this.username; const groupsParagraph = this.shadowRoot.querySelector("#user-groups"); - groupsParagraph.innerText = `${this.groups.toString()}`; + if (isEmpty(this.groups)) { + groupsParagraph.innerHTML = " "; + } + else { + groupsParagraph.innerText = this.groups.toString(); + } const adminParagraph = this.shadowRoot.querySelector("#user-admin"); adminParagraph.innerText = this.admin; diff --git a/scripts/user.js b/scripts/user.js new file mode 100644 index 0000000..9585b61 --- /dev/null +++ b/scripts/user.js @@ -0,0 +1,496 @@ +import { goToPage, getURIData, setTitleAndHeader, setAppearance, requestAPI, resourcesConfig, mergeDeep, addResourceLine, setSVGAlt, setSVGSrc } from "./utils.js"; +import { alert, dialog } from "./dialog.js"; + +window.addEventListener("DOMContentLoaded", init); + +let username; +let userData; +let allGroups; +let allNodes; +let allPools; +let clusterResourceConfig; + +const resourceInputTypes = { // input types for each resource for config page + cpu: { + element: "interactive-list", + align: "start" + }, + cores: { + element: "input", + attributes: { + type: "number" + } + }, + memory: { + element: "input", + attributes: { + type: "number" + } + }, + swap: { + element: "input", + attributes: { + type: "number" + } + }, + network: { + element: "input", + attributes: { + type: "number" + } + }, + pci: { + element: "interactive-list", + align: "start" + } +}; + +class InteractiveList extends HTMLElement { + #name; + #addText; + + constructor () { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + + + + +

+
+ +
+ `; + this.addBtn = this.shadowRoot.querySelector("#add-btn"); + this.addBtn.onclick = this.#handleAdd.bind(this); + this.container = this.shadowRoot.querySelector("#container"); + setSVGSrc(this.addBtn, "images/common/add.svg"); + setSVGAlt(this.addBtn, "Add Item"); + } + + get name () { + return this.#name; + } + + set name (name) { + this.#name = name; + } + + get addText () { + return this.#addText; + } + + set addText (addText) { + this.#addText = addText; + } + + get value () { + + } + + set value (value) { + for (const item of value) { + this.#addItem(item); + } + } + + #addItem (item) { + const itemElem = document.createElement("interactive-list-match-item"); + itemElem.name = item.name; + itemElem.match = item.match; + itemElem.max = item.max; + this.container.appendChild(itemElem); + } + + #handleAdd () { + const header = `Add New ${this.#name} Rule`; + + const body = ` +
+ + + + + + +
+ `; + + dialog(header, body, (result, form) => { + if (result === "confirm") { + const newItem = { + name: form.get("name"), + match: form.get("match"), + max: form.get("max") + }; + this.#addItem(newItem); + } + }); + } +} + +class InteractiveListMatchItem extends HTMLElement { + #name; + #match; + #max; + + #nameElem; + #matchElem; + #maxElem; + + constructor () { + super(); + this.attachShadow({ mode: "open" }); + this.shadowRoot.innerHTML = ` + + + + +
+

+

match=""

+

max=

+ +
+ `; + this.#nameElem = this.shadowRoot.querySelector("#name"); + this.#matchElem = this.shadowRoot.querySelector("#match"); + this.#maxElem = this.shadowRoot.querySelector("#max"); + + this.deleteBtn = this.shadowRoot.querySelector("#delete-btn"); + this.deleteBtn.onclick = this.#handleDelete.bind(this); + setSVGSrc(this.deleteBtn, "images/actions/delete-active.svg"); + setSVGAlt(this.deleteBtn, "Delete Item"); + } + + #update () { + this.#nameElem.innerText = this.#name; + this.#matchElem.innerText = this.#match; + this.#maxElem.innerText = this.#max; + } + + get name () { + return this.#name; + } + + set name (name) { + this.#name = name; + this.#update(); + } + + get match () { + return this.#match; + } + + set match (match) { + this.#match = match; + this.#update(); + } + + get max () { + return this.#max; + } + + set max (max) { + this.#max = max; + this.#update(); + } + + #handleDelete () { + const header = `Delete ${this.name}`; + const body = `

Are you sure you want to delete ${this.name}

`; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + if (this.parentElement) { + this.parentElement.removeChild(this); + } + } + }); + } +} + +customElements.define("interactive-list", InteractiveList); +customElements.define("interactive-list-match-item", InteractiveListMatchItem); + +const resourcesConfigPage = mergeDeep({}, resourcesConfig, resourceInputTypes); + +async function init () { + setAppearance(); + setTitleAndHeader(); + const cookie = document.cookie; + if (cookie === "") { + goToPage("login.html"); + } + + const uriData = getURIData(); + username = uriData.username; + + document.querySelector("#name").innerHTML = document.querySelector("#name").innerHTML.replace("%{username}", username); + + await getUser(); + await populateGroups(); + await populateResources(); + await populateCluster(); + + clusterResourceConfig = (await requestAPI("/global/config/resources")).resources; + + document.querySelector("#exit").addEventListener("click", handleFormExit); +} + +async function getUser () { + userData = (await requestAPI(`/access/users/${username}`)).user; + allGroups = (await requestAPI("/access/groups")).groups; + allNodes = (await requestAPI("/cluster/nodes")).nodes; + allPools = (await requestAPI("/cluster/pools")).pools; +} + +async function populateGroups () { + const groupsDisabled = document.querySelector("#groups-disabled"); + const groupsEnabled = document.querySelector("#groups-enabled"); + // for each group in cluster + for (const groupName of Object.keys(allGroups)) { + const group = allGroups[groupName]; + const item = document.createElement("draggable-item"); + item.data = group; + item.innerHTML = ` +
+ drag icon +

${group.attributes.cn}

+
+ `; + // if user in group + if (userData.attributes.memberOf.indexOf(group.dn) !== -1) { + groupsEnabled.append(item); + } + // user is not in group + else { + groupsDisabled.append(item); + } + } +} + +async function populateResources () { + const field = document.querySelector("#resources"); + for (const resourceName of Object.keys(userData.resources)) { + const resource = userData.resources[resourceName]; + if (resourcesConfigPage[resourceName]) { + const resourceConfig = resourcesConfigPage[resourceName]; + let resourceLine; + + if (resourceName === "cpu" || resourceName === "pci") { + resourceLine = addResourceLine(resourcesConfigPage, field, resourceName, { value: resource.global }, "(Global)"); + } + else { + resourceLine = addResourceLine(resourcesConfigPage, field, resourceName, { value: resource.global.max }, "(Global)"); + } + + postPopulateResourceLine(field, resourceName, "global", resourceConfig, resourceLine); + + for (const nodeSpecificName of Object.keys(resource.nodes)) { // for each node specific, add a line with the node name as a prefix + if (resourceName === "cpu" || resourceName === "pci") { + resourceLine = addResourceLine(resourcesConfigPage, field, resourceName, { value: resource.nodes[nodeSpecificName] }, `(${nodeSpecificName})`); + } + else { + resourceLine = addResourceLine(resourcesConfigPage, field, resourceName, { value: resource.nodes[nodeSpecificName].max }, `(${nodeSpecificName})`); + } + + postPopulateResourceLine(field, resourceName, nodeSpecificName, resourceConfig, resourceLine); + } + } + } + document.querySelector("#resource-add").addEventListener("click", handleResourceAdd); +} + +function postPopulateResourceLine (field, resourceName, resourceScope, resourceConfig, resourceLine) { + const deleteBtn = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + deleteBtn.classList.add("clickable"); + setSVGSrc(deleteBtn, "images/actions/delete-active.svg"); + setSVGAlt(deleteBtn, "Delete Rule"); + field.appendChild(deleteBtn); + + resourceLine.field = field; + resourceLine.deleteBtn = deleteBtn; + deleteBtn.onclick = handleResourceDelete.bind(resourceLine); + + if (resourceConfig.align && resourceConfig.align === "start") { + resourceLine.icon.style.alignSelf = "start"; + resourceLine.icon.style.marginTop = "calc(8px + (0.5lh - 0.5em))"; + resourceLine.label.style.alignSelf = "start"; + } + + resourceLine.resourceName = resourceName; + resourceLine.resourceScope = resourceScope; +} + +async function handleResourceAdd () { + const header = "Add New Resource Constraint"; + const body = ` +
+ + + + +
+ `; + + const d = dialog(header, body, async (result, form) => { + if (result === "confirm") { + const name = form.get("name"); + const type = clusterResourceConfig[name].type; + const scope = form.get("scope"); + + console.log(name, type, scope); + + // check if the resource name is not in the cluster config resources + if (!clusterResourceConfig[name]) { + alert(`${name} is not an allowed resource name`); + } + // check if a global scope rule already exists in the user's resource config + else if (scope === "global" && userData.resources[name] && userData.resources[name].global) { + alert(`${name} (${scope}) is already a rule`); + } + // check if node specific rule already exists in the user's resource config + else if (scope !== "global" && userData.resources[name] && userData.resources[name].nodes[scope]) { + alert(`${name} (${scope}) is already a rule`); + } + // no existing rule exists, add a new resource rule line and add a the rule to userData + else { + // if the rule does not exist at all, add a temporary filler to mark that a new rule has been created + if (!userData.resources[name]) { + userData.resources[name] = { + global: null, + node: {} + }; + } + + const field = document.querySelector("#resources"); + let resourceLine; + + if (scope === "global" && type === "numeric") { + userData.resources[name].global = { max: 0 }; + resourceLine = addResourceLine(resourcesConfigPage, field, name, { value: userData.resources[name].global.max }, "(Global)"); + } + else if (scope === "global" && type === "list") { + userData.resources[name].global = []; + resourceLine = addResourceLine(resourcesConfigPage, field, name, { value: userData.resources[name].global }, "(Global)"); + } + else if (scope !== "global" && type === "numeric") { + userData.resources[name].nodes[scope] = { max: 0 }; + resourceLine = addResourceLine(resourcesConfigPage, field, name, { value: userData.resources[name].nodes[scope].max }, `(${scope})`); + } + else if (scope !== "global" && type === "list") { + userData.resources[name].nodes[scope] = []; + resourceLine = addResourceLine(resourcesConfigPage, field, name, { value: userData.resources[name].nodes[scope] }, `(${scope})`); + } + + postPopulateResourceLine(field, name, scope, resourcesConfigPage[name], resourceLine); + } + } + }); + + const nameSelect = d.querySelector("#name"); + for (const resourceName of Object.keys(clusterResourceConfig)) { + nameSelect.add(new Option(resourceName, resourceName)); + } + + const scopeSelect = d.querySelector("#scope"); + for (const node of allNodes) { + scopeSelect.add(new Option(node, node)); + } +} + +async function handleResourceDelete () { + const header = `Delete Resource Constraint ${this.label.innerText}`; + const body = `

Are you sure you want to delete VM ${this.label.innerText}

`; + + dialog(header, body, async (result, form) => { + if (result === "confirm") { + this.icon.parentElement.removeChild(this.icon); + this.label.parentElement.removeChild(this.label); + this.element.parentElement.removeChild(this.element); + this.unit.parentElement.removeChild(this.unit); + this.deleteBtn.parentElement.removeChild(this.deleteBtn); + + if (this.resourceScope === "global") { + userData.resources[this.resourceName].global = false; + } + else { + userData.resources[this.resourceName].nodes[this.resourceScope] = false; + } + } + }); +} + +async function populateCluster () { + const nodesEnabled = document.querySelector("#nodes-enabled"); + const nodesDisabled = document.querySelector("#nodes-disabled"); + const poolsEnabled = document.querySelector("#pools-enabled"); + const poolsDisabled = document.querySelector("#pools-disabled"); + + for (const node of allNodes) { // for each node of all cluster nodes + const item = document.createElement("draggable-item"); + item.data = node; + item.innerHTML = ` +
+ drag icon +

${node}

+
+ `; + if (userData.cluster.nodes[node] === true) { + nodesEnabled.append(item); + } + else { + nodesDisabled.append(item); + } + } + + for (const pool of allPools) { // for each pool of all cluster pools + const item = document.createElement("draggable-item"); + item.data = pool; + item.innerHTML = ` +
+ drag icon +

${pool}

+
+ `; + if (userData.cluster.pools[pool] === true) { + poolsEnabled.append(item); + } + else { + poolsDisabled.append(item); + } + } + + const vmidMin = document.querySelector("#vmid-min"); + const vmidMax = document.querySelector("#vmid-max"); + + vmidMin.value = userData.cluster.vmid.min; + vmidMax.value = userData.cluster.vmid.max; + + const adminCheckbox = document.querySelector("#admin"); + adminCheckbox.checked = userData.cluster.admin === true; +} + +async function handleFormExit () { + // TODO +} diff --git a/scripts/utils.js b/scripts/utils.js index bee37d9..61f46ee 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -408,6 +408,25 @@ export function mergeDeep (target, ...sources) { return mergeDeep(target, ...sources); } +/** + * Checks if object or array is empty + * @param {*} obj + * @returns + */ +export function isEmpty (obj) { + if (obj instanceof Array) { + return obj.length === 0; + } + else { + for (const prop in obj) { + if (Object.hasOwn(obj, prop)) { + return false; + } + } + return true; + } +} + export function addResourceLine (config, field, resourceType, attributesOverride, labelPrefix = null) { const resourceConfig = config[resourceType]; const iconHref = resourceConfig.icon; @@ -427,56 +446,62 @@ export function addResourceLine (config, field, resourceType, attributesOverride label.htmlFor = id; field.append(label); + let element; + if (elementType === "input") { - const input = document.createElement("input"); + element = document.createElement("input"); for (const k in attributes) { - input.setAttribute(k, attributes[k]); + element.setAttribute(k, attributes[k]); } - input.id = id; - input.name = id; - input.required = true; - input.classList.add("w3-input"); - input.classList.add("w3-border"); - field.append(input); + element.id = id; + element.name = id; + element.required = true; + element.classList.add("w3-input"); + element.classList.add("w3-border"); + field.append(element); } else if (elementType === "select" || elementType === "multi-select") { - const select = document.createElement("select"); + element = document.createElement("select"); for (const option of attributes.options) { - select.append(new Option(option)); + element.append(new Option(option)); } - select.value = attributes.value; - select.id = id; - select.name = id; - select.required = true; - select.classList.add("w3-select"); - select.classList.add("w3-border"); + element.value = attributes.value; + element.id = id; + element.name = id; + element.required = true; + element.classList.add("w3-select"); + element.classList.add("w3-border"); if (elementType === "multi-select") { - select.setAttribute("multiple", true); + element.setAttribute("multiple", true); } - field.append(select); + field.append(element); } else if (customElements.get(elementType)) { - const elem = document.createElement(elementType); + element = document.createElement(elementType); if (attributes.options) { for (const option of attributes.options) { - elem.append(new Option(option)); + element.append(new Option(option)); } } - elem.value = attributes.value; - elem.id = id; - elem.name = id; - elem.required = true; - field.append(elem); + element.value = attributes.value; + element.id = id; + element.name = id; + element.required = true; + field.append(element); } + let unit; + if (unitText) { - const unit = document.createElement("p"); + unit = document.createElement("p"); unit.innerText = unitText; field.append(unit); } else { - const unit = document.createElement("div"); + unit = document.createElement("div"); unit.classList.add("hidden"); field.append(unit); } + + return { icon, label, element, unit }; } diff --git a/user.html b/user.html new file mode 100644 index 0000000..e263201 --- /dev/null +++ b/user.html @@ -0,0 +1,102 @@ + + + + + + proxmox - dashboard + + + + + + + + + + + + + +
+

proxmox

+ + + +
+
+
+

Admin / Users / %{username}

+
+
+ Groups +
+

Member Of

+

Not Member Of

+ + +
+
+
+ Resources +
+
+ +
+
+
+ Cluster +
+ + +
+ +
+

Allowed

+

Not Allowed

+ + +
+ +
+

Allowed

+

Not Allowed

+ + +
+ + +
+ + +
+
+
+
+ +
+
+
+
+ + \ No newline at end of file