Files
proxmox-selfservice/support-provisioning-portal/assets/portal.js
Sven Steinert 2c1949bf1e new file: CHANGELOG.md
modified:   README.md
	modified:   support-provisioning-portal/assets/portal.css
	modified:   support-provisioning-portal/assets/portal.js
	modified:   support-provisioning-portal/includes/class-spp-activator.php
	modified:   support-provisioning-portal/includes/class-spp-admin-page.php
	modified:   support-provisioning-portal/includes/class-spp-http-proxmox-client.php
	modified:   support-provisioning-portal/includes/class-spp-mock-proxmox-client.php
	new file:   support-provisioning-portal/includes/class-spp-permissions.php
	modified:   support-provisioning-portal/includes/class-spp-plugin.php
	modified:   support-provisioning-portal/includes/class-spp-repository.php
	modified:   support-provisioning-portal/includes/class-spp-rest-controller.php
	modified:   support-provisioning-portal/includes/class-spp-shortcode.php
	modified:   support-provisioning-portal/includes/interface-spp-proxmox-client.php
	modified:   support-provisioning-portal/support-provisioning-portal.php
2026-04-24 15:13:42 +02:00

448 lines
17 KiB
JavaScript

(function () {
const roots = document.querySelectorAll(".spp-portal[data-rest-url]");
roots.forEach((root) => {
const api = root.dataset.restUrl;
const nonce = root.dataset.nonce;
const permissions = new Set(JSON.parse(root.dataset.permissions || "[]"));
const can = (permission) => permissions.has(permission);
let state = {
view: "deployments",
deployments: [],
templates: [],
quota: null,
selectedDeployment: null,
error: null,
loading: true
};
const request = async (path, options = {}) => {
const response = await fetch(`${api}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
"X-WP-Nonce": nonce,
...(options.headers || {})
}
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload && payload.message ? payload.message : `Request failed with ${response.status}`);
}
return payload;
};
const load = async () => {
state = { ...state, loading: true, error: null };
render();
try {
const [deployments, templates, quota] = await Promise.all([
request("/deployments"),
request("/templates"),
request("/quota")
]);
state = { ...state, deployments, templates, quota, loading: false };
} catch (error) {
state = { ...state, error: error.message, loading: false };
}
render();
};
const lifecycle = async (id, action) => {
try {
const path = action === "delete" ? `/deployments/${id}` : `/deployments/${id}/${action}`;
await request(path, { method: action === "delete" ? "DELETE" : "POST" });
await load();
state.selectedDeployment = action === "delete" ? null : await request(`/deployments/${id}`);
state.view = action === "delete" ? "deployments" : "detail";
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const prolongDeployment = async (event, id) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const deployment = await request(`/deployments/${id}/prolong`, {
method: "POST",
body: JSON.stringify({
ttlHours: form.get("ttlHours") ? Number(form.get("ttlHours")) : undefined,
neverExpire: form.get("neverExpire") === "1"
})
});
await load();
state = { ...state, view: "detail", selectedDeployment: deployment };
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const createDeployment = async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const deployment = await request("/deployments", {
method: "POST",
body: JSON.stringify({
templateId: Number(form.get("templateId")),
name: String(form.get("name")),
ttlHours: form.get("ttlHours") ? Number(form.get("ttlHours")) : undefined,
neverExpire: form.get("neverExpire") === "1"
})
});
await load();
state = { ...state, view: "detail", selectedDeployment: deployment };
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const shareDeployment = async (event, id) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await request(`/deployments/${id}/shares`, {
method: "POST",
body: JSON.stringify({
user: String(form.get("user"))
})
});
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const removeShare = async (id, userId) => {
try {
await request(`/deployments/${id}/shares/${userId}`, { method: "DELETE" });
state.selectedDeployment = await request(`/deployments/${id}`);
state.error = null;
} catch (error) {
state = { ...state, error: error.message };
}
render();
};
const statusBadge = (status) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`;
const accessLabel = (deployment) => {
if (deployment.accessType === "shared") {
return '<span class="spp-badge SHARED">SHARED</span>';
}
if (deployment.accessType === "admin") {
return '<span class="spp-badge ADMIN">ADMIN</span>';
}
return '<span class="spp-badge OWNER">OWNER</span>';
};
const actionButton = (permission, action, label, id, className = "", disabled = false) => {
if (!can(permission)) {
return "";
}
return `<button class="spp-button ${className}" data-action="${action}" data-id="${id}" ${disabled ? "disabled" : ""} type="button">${label}</button>`;
};
const ipList = (ips) => {
if (!Array.isArray(ips) || ips.length === 0) {
return "Pending";
}
return ips.map(escapeHtml).join(", ");
};
const dateTime = (value) => {
if (!value) {
return "Never";
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(value.replace(" ", "T")));
};
const quotaLine = () => {
if (!state.quota) {
return "";
}
const userLimit = state.quota.userLimitMb > 0 ? `${state.quota.userLimitMb} MB` : "Unlimited";
const globalLimit = state.quota.globalLimitMb > 0 ? `${state.quota.globalLimitMb} MB` : "Unlimited";
return `
<div class="spp-quota">
<span>Your RAM: <strong>${state.quota.userUsedMb} MB</strong> / ${userLimit}</span>
<span>Global RAM: <strong>${state.quota.globalUsedMb} MB</strong> / ${globalLimit}</span>
</div>
`;
};
const header = () => `
<div class="spp-header">
<div>
<h2 class="spp-title">Support Provisioning Portal</h2>
<p class="spp-subtitle">Template-based VM provisioning for support work.</p>
${quotaLine()}
</div>
<div class="spp-tabs">
<button class="spp-button" data-view="deployments" type="button">Deployments</button>
<button class="spp-button" data-view="templates" type="button">Templates</button>
${can("create_deployments") ? '<button class="spp-button spp-button-primary" data-view="create" type="button">Create</button>' : ""}
</div>
</div>
`;
const deploymentsView = () => {
if (state.deployments.length === 0) {
return '<div class="spp-panel"><p style="padding:16px;margin:0;">No deployments have been created yet.</p></div>';
}
return `
<div class="spp-panel">
<table class="spp-table">
<thead>
<tr><th>Name</th><th>Status</th><th>Access</th><th>Template</th><th>IP addresses</th><th>Expires</th><th></th></tr>
</thead>
<tbody>
${state.deployments.map((deployment) => `
<tr>
<td><strong>${escapeHtml(deployment.name)}</strong></td>
<td>${statusBadge(deployment.status)}</td>
<td>${accessLabel(deployment)}</td>
<td>${escapeHtml(deployment.templateName)}</td>
<td>${ipList(deployment.ipAddresses)}</td>
<td>${dateTime(deployment.expiresAt)}</td>
<td><button class="spp-button" data-detail="${deployment.id}" type="button">Open</button></td>
</tr>
`).join("")}
</tbody>
</table>
</div>
`;
};
const templatesView = () => `
<div class="spp-grid">
${state.templates.map((template) => `
<article class="spp-card">
<h3>${escapeHtml(template.name)}</h3>
<p>${escapeHtml(template.description)}</p>
<div class="spp-meta">
<span>OS<strong>${template.osType}</strong></span>
<span>CPU<strong>${template.cpuCores} cores</strong></span>
<span>Memory<strong>${template.memoryMb} MB</strong></span>
<span>Disk<strong>${template.diskGb} GB</strong></span>
<span>Default TTL<strong>${template.defaultTtlHours}h</strong></span>
</div>
</article>
`).join("")}
</div>
`;
const createView = () => {
if (!can("create_deployments")) {
return '<div class="spp-panel"><p style="padding:16px;margin:0;">You do not have permission to create deployments.</p></div>';
}
return `
<div class="spp-panel" style="padding:16px;">
<form class="spp-form" id="spp-create-form">
<label>Template
<select class="spp-select" name="templateId" required>
${state.templates.map((template) => `<option value="${template.id}">${escapeHtml(template.name)}</option>`).join("")}
</select>
</label>
<label>Deployment name
<input class="spp-input" name="name" minlength="3" maxlength="160" required placeholder="pbx-repro-case-1842">
</label>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Use template default">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Create Deployment</button>
</form>
</div>
`;
};
const detailView = () => {
const deployment = state.selectedDeployment;
if (!deployment) {
return deploymentsView();
}
const shares = Array.isArray(deployment.shares) ? deployment.shares : [];
return `
<div class="spp-panel" style="padding:16px;">
${deployment.status === "EXPIRED" ? `
<div class="spp-warning">
This deployment is expired${deployment.errorMessage ? "" : " and has been stopped"}. Prolong its TTL to unlock start actions, or delete it when the data is no longer needed.
${deployment.errorMessage ? `<br><strong>Stop warning:</strong> ${escapeHtml(deployment.errorMessage)}` : ""}
</div>
` : ""}
<div class="spp-header">
<div>
<button class="spp-button" data-view="deployments" type="button">Back</button>
<h2 class="spp-title" style="margin-top:12px;">${escapeHtml(deployment.name)}</h2>
${statusBadge(deployment.status)}
</div>
<div class="spp-actions">
${actionButton("start_deployments", "start", "Start", deployment.id, "spp-button-primary", deployment.status === "RUNNING" || deployment.status === "EXPIRED")}
${actionButton("stop_deployments", "stop", "Stop", deployment.id, "", deployment.status === "STOPPED" || deployment.status === "EXPIRED")}
${actionButton("refresh_deployment_ips", "refresh-ips", "Refresh IPs", deployment.id, "", deployment.status === "DELETED")}
${deployment.canDelete ? actionButton("delete_deployments", "delete", "Delete", deployment.id, "spp-button-danger", deployment.status === "DELETED") : ""}
</div>
</div>
<div class="spp-meta">
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</strong></span>
<span>Access<strong>${deployment.accessType ? escapeHtml(deployment.accessType) : "Owner"}</strong></span>
<span>Proxmox VM ID<strong>${deployment.proxmoxVmId || "Pending"}</strong></span>
<span>IP addresses<strong>${ipList(deployment.ipAddresses)}</strong></span>
<span>CPU<strong>${deployment.cpuCores} cores</strong></span>
<span>Memory<strong>${deployment.memoryMb} MB</strong></span>
<span>Disk<strong>${deployment.diskGb} GB</strong></span>
<span>Created<strong>${dateTime(deployment.createdAt)}</strong></span>
<span>Expires<strong>${dateTime(deployment.expiresAt)}</strong></span>
</div>
${can("prolong_deployments") ? `
<form class="spp-prolong-form" data-prolong-form="${deployment.id}">
<h3>Prolong TTL</h3>
<label>TTL hours
<input class="spp-input" name="ttlHours" type="number" min="1" max="720" placeholder="Hours from now">
</label>
<label class="spp-check">
<input name="neverExpire" type="checkbox" value="1">
<span>Never expire</span>
</label>
<button class="spp-button spp-button-primary" type="submit">Prolong</button>
</form>
` : ""}
${deployment.canShare ? `
<div class="spp-share-panel">
<h3>Shared Access</h3>
<div class="spp-share-list">
${shares.length === 0 ? '<p>No users have shared access.</p>' : shares.map((share) => `
<div class="spp-share-row">
<span>
<strong>${escapeHtml(share.displayName || share.userLogin)}</strong>
<small>${escapeHtml(share.userLogin)}${share.userEmail ? ` - ${escapeHtml(share.userEmail)}` : ""}</small>
</span>
<button class="spp-button" data-remove-share="${share.id}" data-id="${deployment.id}" type="button">Remove</button>
</div>
`).join("")}
</div>
<form class="spp-share-form" data-share-form="${deployment.id}">
<label>Share with user login or email
<input class="spp-input" name="user" required>
</label>
<button class="spp-button spp-button-primary" type="submit">Share</button>
</form>
</div>
` : ""}
</div>
`;
};
const render = () => {
root.innerHTML = `
${header()}
${state.error ? `<p class="spp-error">${escapeHtml(state.error)}</p>` : ""}
${state.loading ? '<div class="spp-panel"><p style="padding:16px;margin:0;">Loading...</p></div>' : ""}
${!state.loading && state.view === "deployments" ? deploymentsView() : ""}
${!state.loading && state.view === "templates" ? templatesView() : ""}
${!state.loading && state.view === "create" ? createView() : ""}
${!state.loading && state.view === "detail" ? detailView() : ""}
`;
root.querySelectorAll("[data-view]").forEach((button) => {
button.addEventListener("click", () => {
state = { ...state, view: button.dataset.view, selectedDeployment: null };
render();
});
});
root.querySelectorAll("[data-detail]").forEach((button) => {
button.addEventListener("click", async () => {
try {
state.selectedDeployment = await request(`/deployments/${button.dataset.detail}`);
state.view = "detail";
} catch (error) {
state.error = error.message;
}
render();
});
});
root.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => lifecycle(Number(button.dataset.id), button.dataset.action));
});
root.querySelectorAll("[data-remove-share]").forEach((button) => {
button.addEventListener("click", () => removeShare(Number(button.dataset.id), Number(button.dataset.removeShare)));
});
const form = root.querySelector("#spp-create-form");
if (form) {
form.addEventListener("submit", createDeployment);
const neverExpire = form.querySelector('input[name="neverExpire"]');
const ttlHours = form.querySelector('input[name="ttlHours"]');
if (neverExpire && ttlHours) {
neverExpire.addEventListener("change", () => {
ttlHours.disabled = neverExpire.checked;
if (neverExpire.checked) {
ttlHours.value = "";
}
});
}
}
root.querySelectorAll("[data-prolong-form]").forEach((form) => {
form.addEventListener("submit", (event) => prolongDeployment(event, Number(form.dataset.prolongForm)));
const neverExpire = form.querySelector('input[name="neverExpire"]');
const ttlHours = form.querySelector('input[name="ttlHours"]');
if (neverExpire && ttlHours) {
neverExpire.addEventListener("change", () => {
ttlHours.disabled = neverExpire.checked;
if (neverExpire.checked) {
ttlHours.value = "";
}
});
}
});
root.querySelectorAll("[data-share-form]").forEach((form) => {
form.addEventListener("submit", (event) => shareDeployment(event, Number(form.dataset.shareForm)));
});
};
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[character]));
load();
});
})();