${escapeHtml(template.name)}
${escapeHtml(template.description)}
(function () { const roots = document.querySelectorAll(".spp-portal[data-rest-url]"); roots.forEach((root) => { const api = root.dataset.restUrl; const nonce = root.dataset.nonce; 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 statusBadge = (status) => `${status.replace("_", " ")}`; 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 `
Template-based VM provisioning for support work.
${quotaLine()}No deployments have been created yet.
| Name | Status | Template | IP addresses | Expires | |
|---|---|---|---|---|---|
| ${escapeHtml(deployment.name)} | ${statusBadge(deployment.status)} | ${escapeHtml(deployment.templateName)} | ${ipList(deployment.ipAddresses)} | ${dateTime(deployment.expiresAt)} |
${escapeHtml(template.description)}
${escapeHtml(state.error)}
` : ""} ${state.loading ? 'Loading...