modified: README.md
This commit is contained in:
272
support-provisioning-portal/assets/portal.css
Normal file
272
support-provisioning-portal/assets/portal.css
Normal file
@@ -0,0 +1,272 @@
|
||||
.spp-portal,
|
||||
.spp-admin-wrap {
|
||||
--spp-ink: var(--wp--preset--color--contrast, currentColor);
|
||||
--spp-muted: color-mix(in srgb, currentColor 68%, transparent);
|
||||
--spp-line: color-mix(in srgb, currentColor 22%, transparent);
|
||||
--spp-field: color-mix(in srgb, currentColor 5%, transparent);
|
||||
--spp-surface: var(--wp--preset--color--base, Canvas);
|
||||
--spp-accent: var(--wp--preset--color--primary, #2271b1);
|
||||
color: var(--spp-ink);
|
||||
}
|
||||
|
||||
.spp-admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.spp-settings,
|
||||
.spp-panel,
|
||||
.spp-card {
|
||||
background: var(--spp-surface);
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.spp-settings {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.spp-settings label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 0 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spp-settings input,
|
||||
.spp-settings select,
|
||||
.spp-input,
|
||||
.spp-select {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--spp-surface);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.spp-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.spp-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.spp-subtitle {
|
||||
margin: 4px 0 0;
|
||||
color: var(--spp-muted);
|
||||
}
|
||||
|
||||
.spp-tabs,
|
||||
.spp-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spp-button {
|
||||
display: inline-flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--spp-line);
|
||||
border-radius: 6px;
|
||||
background: var(--spp-surface);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.spp-button-primary {
|
||||
background: var(--spp-accent);
|
||||
border-color: var(--spp-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.spp-button-danger {
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.spp-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.spp-panel {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spp-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.spp-table th {
|
||||
background: var(--spp-field);
|
||||
color: var(--spp-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.spp-table th,
|
||||
.spp-table td {
|
||||
border-bottom: 1px solid var(--spp-line);
|
||||
padding: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.spp-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.spp-badge {
|
||||
display: inline-flex;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 4px 9px;
|
||||
}
|
||||
|
||||
.spp-badge.RUNNING {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.spp-badge.STOPPED,
|
||||
.spp-badge.DELETED {
|
||||
background: #f1f5f9;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.spp-badge.EXPIRED {
|
||||
background: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.spp-badge.PROVISIONING,
|
||||
.spp-badge.DELETING {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.spp-badge.FAILED,
|
||||
.spp-error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.spp-warning {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 6px;
|
||||
color: #9a3412;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.spp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spp-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.spp-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.spp-card p {
|
||||
color: var(--spp-muted);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.spp-quota {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin-top: 8px;
|
||||
color: var(--spp-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spp-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
color: var(--spp-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.spp-meta strong {
|
||||
color: var(--spp-ink);
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.spp-form {
|
||||
display: grid;
|
||||
max-width: 640px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.spp-prolong-form {
|
||||
border-top: 1px solid var(--spp-line);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
max-width: 520px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.spp-prolong-form h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-form label,
|
||||
.spp-prolong-form label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.spp-check {
|
||||
align-items: center;
|
||||
display: flex !important;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spp-check input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spp-error {
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.spp-admin-grid,
|
||||
.spp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
353
support-provisioning-portal/assets/portal.js
Normal file
353
support-provisioning-portal/assets/portal.js
Normal file
@@ -0,0 +1,353 @@
|
||||
(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) => `<span class="spp-badge ${status}">${status.replace("_", " ")}</span>`;
|
||||
|
||||
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>
|
||||
<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>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>${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 = () => `
|
||||
<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();
|
||||
}
|
||||
|
||||
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">
|
||||
<button class="spp-button spp-button-primary" data-action="start" data-id="${deployment.id}" ${deployment.status === "RUNNING" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Start</button>
|
||||
<button class="spp-button" data-action="stop" data-id="${deployment.id}" ${deployment.status === "STOPPED" || deployment.status === "EXPIRED" ? "disabled" : ""} type="button">Stop</button>
|
||||
<button class="spp-button" data-action="refresh-ips" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Refresh IPs</button>
|
||||
<button class="spp-button spp-button-danger" data-action="delete" data-id="${deployment.id}" ${deployment.status === "DELETED" ? "disabled" : ""} type="button">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spp-meta">
|
||||
<span>Template<strong>${escapeHtml(deployment.templateName)}</strong></span>
|
||||
<span>Requested by<strong>${escapeHtml(deployment.requestedByName)}</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>
|
||||
<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>
|
||||
</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));
|
||||
});
|
||||
|
||||
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 = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[character]));
|
||||
|
||||
load();
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user