diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..03777d4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.log
+*.zip
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
index a9f75e1..2ec8bb9 100644
--- a/README.md
+++ b/README.md
@@ -1,123 +1,300 @@
-You are helping implement an internal web application called **Support Provisioning Portal**.
+# Support Provisioning Portal
-Your task is to bootstrap the project based on the local `README.md` in this repository and then implement the first vertical slice with strong engineering discipline.
+Internal WordPress plugin for support staff to provision standardized Proxmox VE VMs without direct Proxmox access.
-## Product context
+The application runs as a WordPress plugin and exposes both:
-The application is an internal self-service portal for support staff. It provisions standardized VMs on **Proxmox VE** through a backend service. Users must never interact with Proxmox directly.
+- an admin page at **Support Provisioning**
+- a frontend shortcode: `[support_provisioning_portal]`
+
+## Implemented First Slice
+
+- WordPress plugin bootstrap with activation hook
+- Custom database tables:
+ - `wp_spp_templates`
+ - `wp_spp_deployments`
+ - `wp_spp_audit_logs`
+- Seed data for 3 approved templates
+- WordPress REST API endpoints:
+ - `GET /wp-json/support-provisioning/v1/templates`
+ - `GET /wp-json/support-provisioning/v1/quota`
+ - `GET /wp-json/support-provisioning/v1/deployments`
+ - `GET /wp-json/support-provisioning/v1/deployments/:id`
+ - `POST /wp-json/support-provisioning/v1/deployments`
+ - `POST /wp-json/support-provisioning/v1/deployments/:id/start`
+ - `POST /wp-json/support-provisioning/v1/deployments/:id/stop`
+ - `POST /wp-json/support-provisioning/v1/deployments/:id/prolong`
+ - `POST /wp-json/support-provisioning/v1/deployments/:id/refresh-ips`
+ - `DELETE /wp-json/support-provisioning/v1/deployments/:id`
+- Mock Proxmox adapter for local/no-cluster use
+- HTTP token-auth Proxmox adapter behind the same interface
+- Audit log rows for every mutating action
+- Optional `Never expire` deployments
+- Per-user and global RAM contingents
+- IP address display for deployments when available from the mock adapter or Proxmox guest agent
+- Manual IP refresh action for deployments
+- Non-destructive expiration: expired VMs are stopped and locked, not deleted
+- Minimal admin/frontend UI for:
+ - deployment dashboard
+ - templates
+ - create deployment form
+ - deployment detail view
+ - start/stop/delete actions
+ - TTL prolongation for expired or active deployments
+ - theme-inherited form controls and colors for shortcode rendering
+
+## Install
+
+1. Copy the `support-provisioning-portal` folder into:
+
+ ```text
+ wp-content/plugins/support-provisioning-portal
+ ```
+
+2. Activate **Support Provisioning Portal** in WordPress admin.
+
+3. Open **Support Provisioning** in the WordPress admin menu.
+
+4. Optional: add this shortcode to an internal page:
+
+ ```text
+ [support_provisioning_portal]
+ ```
+
+## Permissions
+
+- Logged-in users with `read` can view templates and deployments.
+- Logged-in users with `edit_posts` can create, start, stop, and delete deployments.
+- WordPress REST nonces are required for UI mutations.
+
+## Expiration And Contingents
+
+Deployment creation supports either a TTL in hours or **Never expire**.
+
+When a deployment reaches its TTL:
+
+- WordPress cron and REST requests detect the expiration.
+- The plugin attempts to stop the VM.
+- The deployment status becomes `EXPIRED`.
+- The VM cannot be started again until the user prolongs its TTL.
+- The user can either prolong the TTL or delete the deployment.
+- Deleted deployments are hidden from the active deployment list, but audit rows remain.
+
+RAM contingents are configured from **Support Provisioning > Proxmox Settings**:
+
+- Default per-user RAM limit (MB)
+- Global RAM limit (MB)
+
+Set either value to `0` for unlimited. Active allocations include deployments in `PROVISIONING`, `STOPPED`, `RUNNING`, and `DELETING` states.
+
+Per-user overrides are available on each WordPress user profile under **Support Provisioning Contingent**. Leave the override empty to use the default per-user limit.
+
+## IP Addresses
+
+Deployments include an `ipAddresses` field in REST responses and show those addresses in the UI. In mock mode, deterministic documentation-range IPs are assigned. In HTTP mode, the plugin reads IPs from the Proxmox guest-agent `network-get-interfaces` endpoint when available.
+
+If the guest agent reports IPs only after boot, use **Refresh IPs** in the deployment detail view.
+
+## Proxmox Settings
+
+The plugin defaults to mock mode. Configure live Proxmox access from the **Support Provisioning** admin page:
+
+- Mode: `Mock` or `HTTP token auth`
+- Base URL, for example `https://proxmox.example.internal:8006`
+- Token ID, for example `user@realm!token-name`
+- Token Secret
+- Node, for example `pve-01`
+
+No Proxmox secrets are committed to the repository.
+
+## Configure A Live Proxmox Link
+
+Use this section when moving from mock mode to a real Proxmox VE node.
+
+### 1. Prepare Proxmox Templates
+
+The plugin only provisions from approved templates. For a first real test, either create Proxmox VM templates with the seeded IDs or update the plugin database rows to match your real template IDs.
+
+Seeded template IDs:
+
+| Plugin template | Proxmox template VMID |
+| --- | ---: |
+| Turnkey PBX Test Appliance | `9001` |
+| Windows Support Client | `9002` |
+| Linux Utility VM | `9003` |
+
+The fastest first test is to create one real Proxmox template with VMID `9003`, then use the **Linux Utility VM** option in the plugin.
+
+Template requirements:
+
+- The VM must be converted to a Proxmox template.
+- The template must exist on the Proxmox node configured in the plugin.
+- The plugin currently performs a full clone (`full=1`), so the target storage must have enough free capacity.
+- CPU and memory are set by the plugin after clone based on the template policy row.
+- For IP address display, install and enable `qemu-guest-agent` inside the guest and enable the guest agent option on the VM/template.
+
+If you do not want to use VMIDs `9001`, `9002`, or `9003`, update the template rows after activation:
+
+```sql
+UPDATE wp_spp_templates
+SET proxmox_template_id = 1234
+WHERE template_key = 'linux-utility-vm';
+```
+
+Replace `wp_` with your WordPress table prefix.
+
+### 2. Create A Dedicated Proxmox API User
+
+In Proxmox, create a dedicated user instead of using `root@pam` for the plugin.
+
+Example via Proxmox shell:
+
+```bash
+pveum user add wp-support@pve --comment "WordPress Support Provisioning Portal"
+```
+
+You can also create the user in the Proxmox UI under **Datacenter > Permissions > Users**.
+
+### 3. Create An API Token
+
+In the Proxmox UI:
+
+1. Go to **Datacenter > Permissions > API Tokens**.
+2. Click **Add**.
+3. User: `wp-support@pve`
+4. Token ID: `support-portal`
+5. Enable **Privilege Separation**.
+6. Save the token secret immediately. Proxmox only shows it once.
+
+The plugin stores these fields separately:
+
+- **Token ID**: `wp-support@pve!support-portal`
+- **Token Secret**: the secret value shown by Proxmox
+
+Do not include the `=` between token ID and secret in the WordPress settings. The plugin builds the required `PVEAPIToken=tokenid=secret` header internally.
+
+### 4. Grant Proxmox Permissions
+
+Proxmox permissions are path based. For a first controlled test, grant the token access only to the node/storage/template area you intend to use.
+
+The plugin needs permissions for:
+
+- getting the next VMID
+- cloning a template VM
+- changing CPU and memory after clone
+- starting and stopping VMs
+- deleting VMs
+- reading VM status
+- reading guest-agent network interfaces for IP display
+
+Practical first-test option:
+
+```bash
+pveum aclmod / -user wp-support@pve -role PVEVMAdmin
+```
+
+If the clone fails because storage permissions are missing, also grant datastore access on the storage path used by your templates/clones:
+
+```bash
+pveum aclmod /storage/local-lvm -user wp-support@pve -role PVEDatastoreAdmin
+```
+
+For a tighter production setup, create a custom role with only the required privileges and assign it to the API token or user:
+
+```bash
+pveum role add SupportProvisioner -privs "VM.Allocate VM.Audit VM.Clone VM.Config.CPU VM.Config.Memory VM.PowerMgmt Datastore.AllocateSpace Datastore.Audit Sys.Audit"
+pveum aclmod / -user wp-support@pve -role SupportProvisioner
+```
+
+Depending on your Proxmox version and storage layout, you may need to scope storage permissions separately, for example:
+
+```bash
+pveum aclmod /storage/local-lvm -user wp-support@pve -role SupportProvisioner
+```
+
+### 5. Check Proxmox TLS
+
+The plugin uses WordPress HTTP requests to call Proxmox. That means PHP/WordPress must trust the Proxmox HTTPS certificate.
+
+Recommended:
+
+- Use a DNS name for Proxmox, not a raw IP address.
+- Install a valid certificate on Proxmox, or install your internal CA certificate so the WordPress/PHP host trusts it.
+
+If Proxmox still uses its default self-signed certificate, WordPress may reject the request with an SSL/cURL certificate error. Fix the trust chain before debugging plugin credentials.
+
+### 6. Configure WordPress Plugin Settings
+
+In WordPress admin, open **Support Provisioning > Proxmox Settings**:
+
+| Setting | Value |
+| --- | --- |
+| Mode | `HTTP token auth` |
+| Base URL | `https://proxmox.example.internal:8006` |
+| Token ID | `wp-support@pve!support-portal` |
+| Token Secret | token secret from Proxmox |
+| Node | Proxmox node name, for example `pve-01` |
+
+The **Node** value must match the Proxmox node name exactly as shown in the Proxmox UI under **Datacenter**.
+
+### 7. First Live Test Checklist
+
+Before creating a deployment:
+
+- Confirm WordPress can reach Proxmox on TCP `8006`.
+- Confirm the Proxmox base URL opens from the WordPress server.
+- Confirm the configured node name is exact.
+- Confirm the selected plugin template points to a real Proxmox template VMID.
+- Confirm the token has clone, VM config, power, delete, audit, and datastore permissions.
+- Confirm target storage has enough capacity for a full clone.
+- Confirm RAM contingents in WordPress are not blocking the selected template.
+
+Then test in this order:
+
+1. Switch plugin mode to `HTTP token auth`.
+2. Create a deployment from **Linux Utility VM** or whichever template VMID you mapped.
+3. Open the deployment detail view.
+4. Click **Start**.
+5. Wait for the guest to boot.
+6. Click **Refresh IPs**.
+7. Confirm IP addresses appear.
+8. Click **Stop**.
+9. Click **Delete** when done.
+
+### 8. Common Failure Points
+
+| Symptom | Likely cause |
+| --- | --- |
+| `Missing Proxmox HTTP configuration` | One of Base URL, Token ID, Token Secret, or Node is empty. |
+| `Proxmox request failed with HTTP 401` | Token ID/secret is wrong, token was deleted, or the secret was copied incorrectly. |
+| `Proxmox request failed with HTTP 403` | Token exists but lacks permissions for clone/config/power/delete/storage. |
+| `Proxmox request failed with HTTP 404` | Node name or template VMID is wrong, or the template is on a different node. |
+| SSL/cURL certificate error | WordPress/PHP does not trust the Proxmox certificate. |
+| Deployment created but no IPs | Guest agent is missing, disabled, not running, or the VM has not finished booting. |
+| Start is blocked | Deployment is `EXPIRED`; prolong the TTL first. |
+
+## Design Notes
+
+- Templates are the only provisioning path.
+- Resource limits come from approved templates, not user input.
+- Deployment lifecycle operations are routed through a dedicated Proxmox client interface.
+- Live Proxmox access can be replaced or expanded without changing REST or UI code.
+- WordPress users are used as actors for audit logging. This keeps the first slice simple and leaves room for later RBAC/SSO mapping.
+
+## Original Bootstrap Brief
+
+The application is an internal self-service portal for support staff. It provisions standardized VMs on **Proxmox VE** through a backend service. Users must never interact with Proxmox directly. The application MUST run as a WordPress plugin.
Typical resources:
+
- turnkey PBX/test appliances
- Windows support clients
- Linux utility VMs
The system must enforce:
+
- template-based provisioning only
- policy-controlled resource limits
- audit logging
- lifecycle actions
- TTL / expiration support
-
-## Technical direction
-
-Use this stack unless the repository already contains an equivalent decision:
-- Monorepo with pnpm
-- Next.js + TypeScript for `apps/web`
-- Node.js + TypeScript for `apps/api`
-- Fastify preferred for the API
-- PostgreSQL + Prisma
-- Tailwind CSS for the web UI
-- Zod for request/response validation
-
-## Constraints
-
-- Do not overengineer.
-- Prefer a clean vertical slice over broad scaffolding.
-- Keep Proxmox integration behind a dedicated adapter/module.
-- Use clear domain names and explicit types.
-- Avoid magic strings.
-- Add TODOs only when truly necessary.
-- Document all non-obvious decisions in code comments or small docs.
-- Make the codebase easy to extend toward RBAC, SSO, and multi-cluster support later.
-
-## Initial implementation goals
-
-Implement a usable first slice with the following:
-
-1. Monorepo structure
- - `apps/web`
- - `apps/api`
- - `packages/types`
- - `packages/proxmox-client`
- - `prisma`
-
-2. Database schema with at least:
- - users
- - templates
- - deployments
- - audit_logs
-
-3. Backend API endpoints
- - `GET /api/templates`
- - `GET /api/deployments`
- - `GET /api/deployments/:id`
- - `POST /api/deployments`
- - `POST /api/deployments/:id/start`
- - `POST /api/deployments/:id/stop`
- - `DELETE /api/deployments/:id`
-
-4. Proxmox adapter
- - token auth support
- - stubbed or mockable implementation if no live environment is available
- - methods for clone/start/stop/delete/getStatus
- - clear interface separation between domain service and transport
-
-5. Web UI
- - dashboard page listing deployments
- - templates page
- - create deployment form
- - deployment detail view
- - simple status badges and action buttons
-
-6. Seed data
- - at least 3 sample templates
-
-7. Audit logging
- - every mutating action must write an audit log row
-
-8. Developer experience
- - `.env.example`
- - scripts for dev/build/lint
- - basic README sections updated if needed
-
-## Working style
-
-Proceed in small, reviewable steps:
-1. inspect repository
-2. propose concrete file plan
-3. implement foundation
-4. implement backend slice
-5. implement frontend slice
-6. verify types/build consistency
-7. summarize what was created and what remains
-
-## Output requirements
-
-- Start by reading the local `README.md` and align implementation with it.
-- Show the file/folder plan before making broad changes.
-- Then create the project incrementally.
-- When assumptions are required, state them briefly and choose the most pragmatic path.
-- Favor production-style code structure over tutorial-style code.
-- Where live Proxmox access is unavailable, create a mock adapter that can later be replaced without touching domain logic.
-
-## Quality bar
-
-- TypeScript strict mode
-- input validation at API boundaries
-- no secrets committed
-- minimal but coherent UI
-- compile-ready code where possible
-- no dead code
-- no fake features presented as complete
-
-Begin by inspecting the repository and proposing the exact bootstrap structure to create.
\ No newline at end of file
diff --git a/support-provisioning-portal/assets/portal.css b/support-provisioning-portal/assets/portal.css
new file mode 100644
index 0000000..66599a3
--- /dev/null
+++ b/support-provisioning-portal/assets/portal.css
@@ -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;
+ }
+}
diff --git a/support-provisioning-portal/assets/portal.js b/support-provisioning-portal/assets/portal.js
new file mode 100644
index 0000000..d433ce9
--- /dev/null
+++ b/support-provisioning-portal/assets/portal.js
@@ -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) => `${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 `
+
+ Your RAM: ${state.quota.userUsedMb} MB / ${userLimit}
+ Global RAM: ${state.quota.globalUsedMb} MB / ${globalLimit}
+
+ `;
+ };
+
+ const header = () => `
+
+ `;
+
+ const deploymentsView = () => {
+ if (state.deployments.length === 0) {
+ return 'No deployments have been created yet.
';
+ }
+
+ return `
+
+
+
+ Name Status Template IP addresses Expires
+
+
+ ${state.deployments.map((deployment) => `
+
+ ${escapeHtml(deployment.name)}
+ ${statusBadge(deployment.status)}
+ ${escapeHtml(deployment.templateName)}
+ ${ipList(deployment.ipAddresses)}
+ ${dateTime(deployment.expiresAt)}
+ Open
+
+ `).join("")}
+
+
+
+ `;
+ };
+
+ const templatesView = () => `
+
+ ${state.templates.map((template) => `
+
+ ${escapeHtml(template.name)}
+ ${escapeHtml(template.description)}
+
+ OS${template.osType}
+ CPU${template.cpuCores} cores
+ Memory${template.memoryMb} MB
+ Disk${template.diskGb} GB
+ Default TTL${template.defaultTtlHours}h
+
+
+ `).join("")}
+
+ `;
+
+ const createView = () => `
+
+
+
+ `;
+
+ const detailView = () => {
+ const deployment = state.selectedDeployment;
+ if (!deployment) {
+ return deploymentsView();
+ }
+
+ return `
+
+ ${deployment.status === "EXPIRED" ? `
+
+ 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 ? `Stop warning: ${escapeHtml(deployment.errorMessage)}` : ""}
+
+ ` : ""}
+
+
+ Template${escapeHtml(deployment.templateName)}
+ Requested by${escapeHtml(deployment.requestedByName)}
+ Proxmox VM ID${deployment.proxmoxVmId || "Pending"}
+ IP addresses${ipList(deployment.ipAddresses)}
+ CPU${deployment.cpuCores} cores
+ Memory${deployment.memoryMb} MB
+ Disk${deployment.diskGb} GB
+ Created${dateTime(deployment.createdAt)}
+ Expires${dateTime(deployment.expiresAt)}
+
+
+
+ `;
+ };
+
+ const render = () => {
+ root.innerHTML = `
+ ${header()}
+ ${state.error ? `${escapeHtml(state.error)}
` : ""}
+ ${state.loading ? '' : ""}
+ ${!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();
+ });
+})();
diff --git a/support-provisioning-portal/includes/class-spp-activator.php b/support-provisioning-portal/includes/class-spp-activator.php
new file mode 100644
index 0000000..c9b699f
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-activator.php
@@ -0,0 +1,188 @@
+get_charset_collate();
+ $templates = self::table('templates');
+ $deployments = self::table('deployments');
+ $audit_logs = self::table('audit_logs');
+
+ dbDelta("CREATE TABLE {$templates} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ template_key varchar(80) NOT NULL,
+ name varchar(160) NOT NULL,
+ description text NOT NULL,
+ os_type varchar(24) NOT NULL,
+ cpu_cores int unsigned NOT NULL,
+ memory_mb int unsigned NOT NULL,
+ disk_gb int unsigned NOT NULL,
+ default_ttl_hours int unsigned NOT NULL,
+ proxmox_template_id int unsigned NOT NULL,
+ is_active tinyint(1) NOT NULL DEFAULT 1,
+ created_at datetime NOT NULL,
+ updated_at datetime NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY template_key (template_key)
+ ) {$charset_collate};");
+
+ dbDelta("CREATE TABLE {$deployments} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ name varchar(160) NOT NULL,
+ status varchar(32) NOT NULL,
+ proxmox_vm_id int unsigned DEFAULT NULL,
+ ip_addresses longtext NULL,
+ error_message text NULL,
+ expires_at datetime NULL,
+ created_at datetime NOT NULL,
+ updated_at datetime NOT NULL,
+ template_id bigint(20) unsigned NOT NULL,
+ requested_by bigint(20) unsigned NOT NULL,
+ PRIMARY KEY (id),
+ KEY status (status),
+ KEY expires_at (expires_at),
+ KEY template_id (template_id),
+ KEY requested_by (requested_by)
+ ) {$charset_collate};");
+
+ $wpdb->query("ALTER TABLE {$deployments} MODIFY expires_at datetime NULL");
+
+ $ip_column = $wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM {$deployments} LIKE %s", 'ip_addresses'));
+ if ($ip_column === null) {
+ $wpdb->query("ALTER TABLE {$deployments} ADD COLUMN ip_addresses longtext NULL AFTER proxmox_vm_id");
+ }
+
+ dbDelta("CREATE TABLE {$audit_logs} (
+ id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+ action varchar(80) NOT NULL,
+ entity_type varchar(80) NOT NULL,
+ entity_id bigint(20) unsigned NOT NULL,
+ actor_id bigint(20) unsigned NOT NULL,
+ metadata longtext NULL,
+ created_at datetime NOT NULL,
+ PRIMARY KEY (id),
+ KEY entity_lookup (entity_type, entity_id),
+ KEY created_at (created_at)
+ ) {$charset_collate};");
+
+ }
+
+ private static function schedule_expiration_check(): void
+ {
+ if (!wp_next_scheduled('spp_expire_deployments')) {
+ wp_schedule_event(time() + 5 * MINUTE_IN_SECONDS, 'hourly', 'spp_expire_deployments');
+ }
+ }
+
+ public static function table(string $name): string
+ {
+ global $wpdb;
+
+ return $wpdb->prefix . 'spp_' . $name;
+ }
+
+ private static function seed_templates(): void
+ {
+ global $wpdb;
+
+ $table = self::table('templates');
+ $now = current_time('mysql');
+ $templates = [
+ [
+ 'template_key' => 'turnkey-pbx-test',
+ 'name' => 'Turnkey PBX Test Appliance',
+ 'description' => 'Small PBX appliance for call-flow reproduction and support testing.',
+ 'os_type' => 'APPLIANCE',
+ 'cpu_cores' => 2,
+ 'memory_mb' => 2048,
+ 'disk_gb' => 24,
+ 'default_ttl_hours' => 72,
+ 'proxmox_template_id' => 9001,
+ ],
+ [
+ 'template_key' => 'windows-support-client',
+ 'name' => 'Windows Support Client',
+ 'description' => 'Standard Windows client VM with support tooling pre-installed.',
+ 'os_type' => 'WINDOWS',
+ 'cpu_cores' => 4,
+ 'memory_mb' => 8192,
+ 'disk_gb' => 80,
+ 'default_ttl_hours' => 48,
+ 'proxmox_template_id' => 9002,
+ ],
+ [
+ 'template_key' => 'linux-utility-vm',
+ 'name' => 'Linux Utility VM',
+ 'description' => 'Lightweight Linux host for network checks, packet capture, and diagnostics.',
+ 'os_type' => 'LINUX',
+ 'cpu_cores' => 2,
+ 'memory_mb' => 2048,
+ 'disk_gb' => 32,
+ 'default_ttl_hours' => 168,
+ 'proxmox_template_id' => 9003,
+ ],
+ ];
+
+ foreach ($templates as $template) {
+ $exists = (int) $wpdb->get_var(
+ $wpdb->prepare("SELECT id FROM {$table} WHERE template_key = %s", $template['template_key'])
+ );
+
+ $data = array_merge($template, [
+ 'is_active' => 1,
+ 'updated_at' => $now,
+ ]);
+
+ if ($exists > 0) {
+ $wpdb->update($table, $data, ['id' => $exists]);
+ continue;
+ }
+
+ $wpdb->insert($table, array_merge($data, ['created_at' => $now]));
+ }
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-admin-page.php b/support-provisioning-portal/includes/class-spp-admin-page.php
new file mode 100644
index 0000000..4e85843
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-admin-page.php
@@ -0,0 +1,186 @@
+ 'string',
+ 'sanitize_callback' => static fn($value) => $value === 'http' ? 'http' : 'mock',
+ 'default' => 'mock',
+ ]);
+ register_setting('spp_settings', 'spp_proxmox_base_url', ['type' => 'string', 'sanitize_callback' => 'esc_url_raw']);
+ register_setting('spp_settings', 'spp_proxmox_token_id', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
+ register_setting('spp_settings', 'spp_proxmox_token_secret', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field']);
+ register_setting('spp_settings', 'spp_proxmox_node', ['type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'default' => 'pve-01']);
+ register_setting('spp_settings', 'spp_quota_user_memory_mb', [
+ 'type' => 'integer',
+ 'sanitize_callback' => static fn($value) => max(0, absint($value)),
+ 'default' => 0,
+ ]);
+ register_setting('spp_settings', 'spp_quota_global_memory_mb', [
+ 'type' => 'integer',
+ 'sanitize_callback' => static fn($value) => max(0, absint($value)),
+ 'default' => 0,
+ ]);
+ }
+
+ public function enqueue_assets(string $hook): void
+ {
+ if ($hook !== 'toplevel_page_support-provisioning-portal') {
+ return;
+ }
+
+ wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION);
+ wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true);
+ }
+
+ public function render(): void
+ {
+ if (!current_user_can('edit_posts')) {
+ wp_die(esc_html__('You do not have permission to access this page.', 'support-provisioning-portal'));
+ }
+
+ ?>
+
+
+
+ render_app_root(); ?>
+ render_settings(); ?>
+
+
+
+
+
+
+ ID, 'spp_memory_quota_mb', true);
+ ?>
+
+
+ repository->due_for_expiration() as $deployment) {
+ $stop_error = null;
+
+ if (!empty($deployment['proxmox_vm_id']) && $deployment['status'] !== 'STOPPED') {
+ try {
+ $this->proxmox->stop_vm((int) $deployment['proxmox_vm_id']);
+ } catch (Throwable $error) {
+ $stop_error = $error->getMessage();
+ }
+ }
+
+ $this->repository->mark_deployment_expired((int) $deployment['id'], $stop_error);
+ }
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-http-proxmox-client.php b/support-provisioning-portal/includes/class-spp-http-proxmox-client.php
new file mode 100644
index 0000000..bde6a36
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-http-proxmox-client.php
@@ -0,0 +1,124 @@
+options = $options;
+ }
+
+ public function clone_vm(array $input): array
+ {
+ $vm_id = (int) $this->request('/cluster/nextid', 'GET');
+ $template_id = (int) $input['template_vm_id'];
+
+ $this->request("/nodes/{$this->options['node']}/qemu/{$template_id}/clone", 'POST', [
+ 'newid' => $vm_id,
+ 'name' => (string) $input['name'],
+ 'full' => 1,
+ ]);
+
+ $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/config", 'PUT', [
+ 'cores' => (int) $input['cpu_cores'],
+ 'memory' => (int) $input['memory_mb'],
+ ]);
+
+ return ['vm_id' => $vm_id];
+ }
+
+ public function start_vm(int $vm_id): void
+ {
+ $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/start", 'POST');
+ }
+
+ public function stop_vm(int $vm_id): void
+ {
+ $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/stop", 'POST');
+ }
+
+ public function delete_vm(int $vm_id): void
+ {
+ $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}", 'DELETE');
+ }
+
+ public function get_status(int $vm_id): string
+ {
+ $data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/status/current", 'GET');
+
+ return is_array($data) && isset($data['status']) ? (string) $data['status'] : 'unknown';
+ }
+
+ public function get_ip_addresses(int $vm_id): array
+ {
+ $data = $this->request("/nodes/{$this->options['node']}/qemu/{$vm_id}/agent/network-get-interfaces", 'GET');
+ $interfaces = is_array($data) && isset($data['result']) && is_array($data['result']) ? $data['result'] : [];
+ $ips = [];
+
+ foreach ($interfaces as $interface) {
+ if (!is_array($interface) || empty($interface['ip-addresses']) || !is_array($interface['ip-addresses'])) {
+ continue;
+ }
+
+ foreach ($interface['ip-addresses'] as $address) {
+ if (!is_array($address) || empty($address['ip-address'])) {
+ continue;
+ }
+
+ $ip = (string) $address['ip-address'];
+ if ($ip === '127.0.0.1' || $ip === '::1' || str_starts_with($ip, 'fe80:')) {
+ continue;
+ }
+
+ $ips[] = $ip;
+ }
+ }
+
+ return array_values(array_unique($ips));
+ }
+
+ /**
+ * @param array $body
+ * @return mixed
+ */
+ private function request(string $path, string $method, array $body = [])
+ {
+ foreach (['base_url', 'token_id', 'token_secret', 'node'] as $key) {
+ if ($this->options[$key] === '') {
+ throw new RuntimeException('Missing Proxmox HTTP configuration.');
+ }
+ }
+
+ $url = trailingslashit($this->options['base_url']) . 'api2/json' . $path;
+ $response = wp_remote_request($url, [
+ 'method' => $method,
+ 'timeout' => 30,
+ 'headers' => [
+ 'Authorization' => 'PVEAPIToken=' . $this->options['token_id'] . '=' . $this->options['token_secret'],
+ ],
+ 'body' => $body,
+ ]);
+
+ if (is_wp_error($response)) {
+ throw new RuntimeException($response->get_error_message());
+ }
+
+ $status = (int) wp_remote_retrieve_response_code($response);
+ if ($status < 200 || $status >= 300) {
+ throw new RuntimeException('Proxmox request failed with HTTP ' . $status);
+ }
+
+ $payload = json_decode(wp_remote_retrieve_body($response), true);
+
+ return is_array($payload) && array_key_exists('data', $payload) ? $payload['data'] : null;
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php b/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php
new file mode 100644
index 0000000..11a6f97
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-mock-proxmox-client.php
@@ -0,0 +1,56 @@
+ $next_id];
+ }
+
+ public function start_vm(int $vm_id): void
+ {
+ $this->ensure_vm($vm_id);
+ update_option('spp_mock_vm_status_' . $vm_id, 'running', false);
+ }
+
+ public function stop_vm(int $vm_id): void
+ {
+ $this->ensure_vm($vm_id);
+ update_option('spp_mock_vm_status_' . $vm_id, 'stopped', false);
+ }
+
+ public function delete_vm(int $vm_id): void
+ {
+ $this->ensure_vm($vm_id);
+ delete_option('spp_mock_vm_status_' . $vm_id);
+ delete_option('spp_mock_vm_ips_' . $vm_id);
+ }
+
+ public function get_status(int $vm_id): string
+ {
+ return (string) get_option('spp_mock_vm_status_' . $vm_id, 'unknown');
+ }
+
+ public function get_ip_addresses(int $vm_id): array
+ {
+ $ips = get_option('spp_mock_vm_ips_' . $vm_id, []);
+
+ return is_array($ips) ? array_values(array_map('strval', $ips)) : [];
+ }
+
+ private function ensure_vm(int $vm_id): void
+ {
+ if (get_option('spp_mock_vm_status_' . $vm_id, null) === null) {
+ throw new RuntimeException('Mock Proxmox VM does not exist.');
+ }
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-plugin.php b/support-provisioning-portal/includes/class-spp-plugin.php
new file mode 100644
index 0000000..282d4ef
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-plugin.php
@@ -0,0 +1,50 @@
+make_proxmox_client();
+ $expiration_service = new SPP_Expiration_Service($repository, $proxmox);
+
+ add_action('spp_expire_deployments', [$expiration_service, 'expire_due_deployments']);
+
+ (new SPP_REST_Controller($repository, $proxmox, $expiration_service))->register_hooks();
+ (new SPP_Admin_Page())->register_hooks();
+ (new SPP_Shortcode())->register_hooks();
+ }
+
+ private function make_proxmox_client(): SPP_Proxmox_Client
+ {
+ $mode = get_option('spp_proxmox_mode', 'mock');
+
+ if ($mode === 'http') {
+ return new SPP_Http_Proxmox_Client([
+ 'base_url' => (string) get_option('spp_proxmox_base_url', ''),
+ 'token_id' => (string) get_option('spp_proxmox_token_id', ''),
+ 'token_secret' => (string) get_option('spp_proxmox_token_secret', ''),
+ 'node' => (string) get_option('spp_proxmox_node', ''),
+ ]);
+ }
+
+ return new SPP_Mock_Proxmox_Client();
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-repository.php b/support-provisioning-portal/includes/class-spp-repository.php
new file mode 100644
index 0000000..d11b2a9
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-repository.php
@@ -0,0 +1,378 @@
+>
+ */
+ public function templates(): array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('templates');
+ $rows = $wpdb->get_results("SELECT * FROM {$table} WHERE is_active = 1 ORDER BY name ASC", ARRAY_A);
+
+ return array_map([$this, 'template_dto'], is_array($rows) ? $rows : []);
+ }
+
+ /**
+ * @return array|null
+ */
+ public function template(int $id): ?array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('templates');
+ $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d AND is_active = 1", $id), ARRAY_A);
+
+ return is_array($row) ? $row : null;
+ }
+
+ /**
+ * @return array>
+ */
+ public function deployments(): array
+ {
+ global $wpdb;
+
+ $deployments = SPP_Activator::table('deployments');
+ $templates = SPP_Activator::table('templates');
+ $users = $wpdb->users;
+
+ $rows = $wpdb->get_results(
+ "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name
+ FROM {$deployments} d
+ INNER JOIN {$templates} t ON t.id = d.template_id
+ INNER JOIN {$users} u ON u.ID = d.requested_by
+ WHERE d.status <> 'DELETED'
+ ORDER BY d.created_at DESC",
+ ARRAY_A
+ );
+
+ return array_map([$this, 'deployment_summary_dto'], is_array($rows) ? $rows : []);
+ }
+
+ /**
+ * @return array|null
+ */
+ public function deployment(int $id): ?array
+ {
+ global $wpdb;
+
+ $deployments = SPP_Activator::table('deployments');
+ $templates = SPP_Activator::table('templates');
+ $users = $wpdb->users;
+
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT d.*, t.name AS template_name, t.cpu_cores, t.memory_mb, t.disk_gb, u.display_name AS requested_by_name
+ FROM {$deployments} d
+ INNER JOIN {$templates} t ON t.id = d.template_id
+ INNER JOIN {$users} u ON u.ID = d.requested_by
+ WHERE d.id = %d",
+ $id
+ ),
+ ARRAY_A
+ );
+
+ return is_array($row) ? $this->deployment_detail_dto($row) : null;
+ }
+
+ /**
+ * @param array $template
+ * @return array
+ */
+ public function create_deployment(array $template, string $name, ?int $ttl_hours, int $vm_id, array $ip_addresses, int $actor_id): array
+ {
+ global $wpdb;
+
+ $now = current_time('mysql');
+ $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS));
+ $table = SPP_Activator::table('deployments');
+
+ $wpdb->insert($table, [
+ 'name' => $name,
+ 'status' => 'STOPPED',
+ 'proxmox_vm_id' => $vm_id,
+ 'ip_addresses' => wp_json_encode(array_values($ip_addresses)),
+ 'expires_at' => $expires_at,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ 'template_id' => (int) $template['id'],
+ 'requested_by' => $actor_id,
+ ]);
+
+ $deployment_id = (int) $wpdb->insert_id;
+ $this->audit('DEPLOYMENT_CREATED', 'deployment', $deployment_id, $actor_id, [
+ 'template_id' => (int) $template['id'],
+ 'proxmox_vm_id' => $vm_id,
+ 'ip_addresses' => $ip_addresses,
+ 'ttl_hours' => $ttl_hours,
+ 'never_expire' => $ttl_hours === null,
+ ]);
+
+ return $this->deployment($deployment_id);
+ }
+
+ public function update_deployment_status(int $id, string $status, string $action, int $actor_id): ?array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $wpdb->update($table, [
+ 'status' => $status,
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $id]);
+
+ $this->audit($action, 'deployment', $id, $actor_id, ['status' => $status]);
+
+ return $this->deployment($id);
+ }
+
+ public function update_deployment_status_and_ips(int $id, string $status, array $ip_addresses, string $action, int $actor_id): ?array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $wpdb->update($table, [
+ 'status' => $status,
+ 'ip_addresses' => wp_json_encode(array_values($ip_addresses)),
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $id]);
+
+ $this->audit($action, 'deployment', $id, $actor_id, [
+ 'status' => $status,
+ 'ip_addresses' => $ip_addresses,
+ ]);
+
+ return $this->deployment($id);
+ }
+
+ public function update_deployment_ips(int $id, array $ip_addresses, int $actor_id): ?array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $wpdb->update($table, [
+ 'ip_addresses' => wp_json_encode(array_values($ip_addresses)),
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $id]);
+
+ $this->audit('DEPLOYMENT_IPS_REFRESHED', 'deployment', $id, $actor_id, [
+ 'ip_addresses' => $ip_addresses,
+ ]);
+
+ return $this->deployment($id);
+ }
+
+ public function prolong_deployment(int $id, ?int $ttl_hours, int $actor_id): ?array
+ {
+ global $wpdb;
+
+ $expires_at = $ttl_hours === null ? null : date('Y-m-d H:i:s', current_time('timestamp') + ($ttl_hours * HOUR_IN_SECONDS));
+ $table = SPP_Activator::table('deployments');
+
+ $wpdb->update($table, [
+ 'status' => 'STOPPED',
+ 'expires_at' => $expires_at,
+ 'error_message' => null,
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $id]);
+
+ $this->audit('DEPLOYMENT_PROLONGED', 'deployment', $id, $actor_id, [
+ 'ttl_hours' => $ttl_hours,
+ 'never_expire' => $ttl_hours === null,
+ ]);
+
+ return $this->deployment($id);
+ }
+
+ /**
+ * @return array|null
+ */
+ public function deployment_record(int $id): ?array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
+
+ return is_array($row) ? $row : null;
+ }
+
+ /**
+ * @return array>
+ */
+ public function due_for_expiration(): array
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $now = current_time('mysql');
+ $rows = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT * FROM {$table}
+ WHERE expires_at IS NOT NULL
+ AND expires_at <= %s
+ AND status IN ('PROVISIONING', 'STOPPED', 'RUNNING')",
+ $now
+ ),
+ ARRAY_A
+ );
+
+ return is_array($rows) ? $rows : [];
+ }
+
+ public function mark_deployment_expired(int $id, ?string $stop_error): void
+ {
+ global $wpdb;
+
+ $table = SPP_Activator::table('deployments');
+ $wpdb->update($table, [
+ 'status' => 'EXPIRED',
+ 'error_message' => $stop_error,
+ 'updated_at' => current_time('mysql'),
+ ], ['id' => $id]);
+
+ $this->audit('DEPLOYMENT_EXPIRED', 'deployment', $id, 0, [
+ 'stopped' => $stop_error === null,
+ 'stop_error' => $stop_error,
+ ]);
+ }
+
+ /**
+ * @return array{userUsedMb:int,userLimitMb:int,globalUsedMb:int,globalLimitMb:int}
+ */
+ public function quota(int $actor_id): array
+ {
+ return [
+ 'userUsedMb' => $this->allocated_memory_mb($actor_id),
+ 'userLimitMb' => $this->user_memory_limit_mb($actor_id),
+ 'globalUsedMb' => $this->allocated_memory_mb(null),
+ 'globalLimitMb' => max(0, (int) get_option('spp_quota_global_memory_mb', 0)),
+ ];
+ }
+
+ private function user_memory_limit_mb(int $actor_id): int
+ {
+ $user_limit = get_user_meta($actor_id, 'spp_memory_quota_mb', true);
+
+ if ($user_limit !== '') {
+ return max(0, (int) $user_limit);
+ }
+
+ return max(0, (int) get_option('spp_quota_user_memory_mb', 0));
+ }
+
+ private function allocated_memory_mb(?int $actor_id): int
+ {
+ global $wpdb;
+
+ $deployments = SPP_Activator::table('deployments');
+ $templates = SPP_Activator::table('templates');
+ $active_statuses = ['PROVISIONING', 'STOPPED', 'RUNNING', 'DELETING'];
+ $placeholders = implode(',', array_fill(0, count($active_statuses), '%s'));
+
+ $sql = "SELECT COALESCE(SUM(t.memory_mb), 0)
+ FROM {$deployments} d
+ INNER JOIN {$templates} t ON t.id = d.template_id
+ WHERE d.status IN ({$placeholders})";
+ $params = $active_statuses;
+
+ if ($actor_id !== null) {
+ $sql .= ' AND d.requested_by = %d';
+ $params[] = $actor_id;
+ }
+
+ return (int) $wpdb->get_var($wpdb->prepare($sql, $params));
+ }
+
+ /**
+ * @param array $metadata
+ */
+ private function audit(string $action, string $entity_type, int $entity_id, int $actor_id, array $metadata): void
+ {
+ global $wpdb;
+
+ $wpdb->insert(SPP_Activator::table('audit_logs'), [
+ 'action' => $action,
+ 'entity_type' => $entity_type,
+ 'entity_id' => $entity_id,
+ 'actor_id' => $actor_id,
+ 'metadata' => wp_json_encode($metadata),
+ 'created_at' => current_time('mysql'),
+ ]);
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ private function template_dto(array $row): array
+ {
+ return [
+ 'id' => (int) $row['id'],
+ 'key' => (string) $row['template_key'],
+ 'name' => (string) $row['name'],
+ 'description' => (string) $row['description'],
+ 'osType' => (string) $row['os_type'],
+ 'cpuCores' => (int) $row['cpu_cores'],
+ 'memoryMb' => (int) $row['memory_mb'],
+ 'diskGb' => (int) $row['disk_gb'],
+ 'defaultTtlHours' => (int) $row['default_ttl_hours'],
+ ];
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ private function deployment_summary_dto(array $row): array
+ {
+ return [
+ 'id' => (int) $row['id'],
+ 'name' => (string) $row['name'],
+ 'status' => (string) $row['status'],
+ 'templateName' => (string) $row['template_name'],
+ 'requestedByName' => (string) $row['requested_by_name'],
+ 'ipAddresses' => $this->ip_addresses_from_row($row),
+ 'expiresAt' => $row['expires_at'] === null ? null : (string) $row['expires_at'],
+ 'createdAt' => (string) $row['created_at'],
+ ];
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ private function deployment_detail_dto(array $row): array
+ {
+ return array_merge($this->deployment_summary_dto($row), [
+ 'templateId' => (int) $row['template_id'],
+ 'proxmoxVmId' => isset($row['proxmox_vm_id']) ? (int) $row['proxmox_vm_id'] : null,
+ 'cpuCores' => (int) $row['cpu_cores'],
+ 'memoryMb' => (int) $row['memory_mb'],
+ 'diskGb' => (int) $row['disk_gb'],
+ 'errorMessage' => $row['error_message'] === null ? null : (string) $row['error_message'],
+ ]);
+ }
+
+ /**
+ * @param array $row
+ * @return array
+ */
+ private function ip_addresses_from_row(array $row): array
+ {
+ if (empty($row['ip_addresses'])) {
+ return [];
+ }
+
+ $decoded = json_decode((string) $row['ip_addresses'], true);
+
+ return is_array($decoded) ? array_values(array_map('strval', $decoded)) : [];
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-rest-controller.php b/support-provisioning-portal/includes/class-spp-rest-controller.php
new file mode 100644
index 0000000..354621b
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-rest-controller.php
@@ -0,0 +1,348 @@
+ WP_REST_Server::READABLE,
+ 'callback' => [$this, 'list_templates'],
+ 'permission_callback' => [$this, 'can_read'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/quota', [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [$this, 'get_quota'],
+ 'permission_callback' => [$this, 'can_read'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments', [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [$this, 'list_deployments'],
+ 'permission_callback' => [$this, 'can_read'],
+ ],
+ [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [$this, 'create_deployment'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)', [
+ [
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => [$this, 'get_deployment'],
+ 'permission_callback' => [$this, 'can_read'],
+ ],
+ [
+ 'methods' => WP_REST_Server::DELETABLE,
+ 'callback' => [$this, 'delete_deployment'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/start', [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [$this, 'start_deployment'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/stop', [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [$this, 'stop_deployment'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/prolong', [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [$this, 'prolong_deployment'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ]);
+
+ register_rest_route(self::NAMESPACE, '/deployments/(?P\d+)/refresh-ips', [
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => [$this, 'refresh_deployment_ips'],
+ 'permission_callback' => [$this, 'can_mutate'],
+ ]);
+ }
+
+ public function can_read(): bool
+ {
+ return is_user_logged_in() && current_user_can('read');
+ }
+
+ public function can_mutate(): bool
+ {
+ return is_user_logged_in() && current_user_can('edit_posts');
+ }
+
+ public function list_templates(): WP_REST_Response
+ {
+ $this->sync_expirations();
+
+ return rest_ensure_response($this->repository->templates());
+ }
+
+ public function get_quota(): WP_REST_Response
+ {
+ $this->sync_expirations();
+
+ return rest_ensure_response($this->repository->quota(get_current_user_id()));
+ }
+
+ public function list_deployments(): WP_REST_Response
+ {
+ $this->sync_expirations();
+
+ return rest_ensure_response($this->repository->deployments());
+ }
+
+ public function get_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ $this->sync_expirations();
+ $deployment = $this->repository->deployment((int) $request['id']);
+
+ if ($deployment === null) {
+ return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
+ }
+
+ return rest_ensure_response($deployment);
+ }
+
+ public function create_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ $this->sync_expirations();
+ $template_id = (int) $request->get_param('templateId');
+ $name = sanitize_text_field((string) $request->get_param('name'));
+ $ttl_hours = (int) $request->get_param('ttlHours');
+ $never_expire = (bool) $request->get_param('neverExpire');
+
+ if ($template_id < 1 || strlen($name) < 3 || strlen($name) > 160) {
+ return new WP_Error('spp_invalid_request', 'Template and a deployment name of 3-160 characters are required.', ['status' => 400]);
+ }
+
+ $template = $this->repository->template($template_id);
+ if ($template === null) {
+ return new WP_Error('spp_template_not_found', 'Template not found.', ['status' => 404]);
+ }
+
+ if ($never_expire) {
+ $ttl_hours = null;
+ } elseif ($ttl_hours < 1) {
+ $ttl_hours = (int) $template['default_ttl_hours'];
+ }
+
+ if ($ttl_hours !== null && $ttl_hours > 720) {
+ return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]);
+ }
+
+ $quota_error = $this->validate_memory_quota((int) $template['memory_mb'], get_current_user_id());
+ if ($quota_error instanceof WP_Error) {
+ return $quota_error;
+ }
+
+ try {
+ $clone = $this->proxmox->clone_vm([
+ 'template_vm_id' => (int) $template['proxmox_template_id'],
+ 'name' => $name,
+ 'cpu_cores' => (int) $template['cpu_cores'],
+ 'memory_mb' => (int) $template['memory_mb'],
+ 'disk_gb' => (int) $template['disk_gb'],
+ ]);
+
+ $vm_id = (int) $clone['vm_id'];
+ $deployment = $this->repository->create_deployment(
+ $template,
+ $name,
+ $ttl_hours,
+ $vm_id,
+ $this->safe_ip_addresses($vm_id),
+ get_current_user_id()
+ );
+ } catch (Throwable $error) {
+ return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
+ }
+
+ return new WP_REST_Response($deployment, 201);
+ }
+
+ private function validate_memory_quota(int $requested_memory_mb, int $actor_id): ?WP_Error
+ {
+ $quota = $this->repository->quota($actor_id);
+
+ if ($quota['userLimitMb'] > 0 && ($quota['userUsedMb'] + $requested_memory_mb) > $quota['userLimitMb']) {
+ return new WP_Error(
+ 'spp_user_quota_exceeded',
+ 'This deployment would exceed your RAM contingent.',
+ ['status' => 403, 'quota' => $quota]
+ );
+ }
+
+ if ($quota['globalLimitMb'] > 0 && ($quota['globalUsedMb'] + $requested_memory_mb) > $quota['globalLimitMb']) {
+ return new WP_Error(
+ 'spp_global_quota_exceeded',
+ 'This deployment would exceed the global RAM contingent.',
+ ['status' => 403, 'quota' => $quota]
+ );
+ }
+
+ return null;
+ }
+
+ public function start_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ return $this->apply_lifecycle_action((int) $request['id'], 'RUNNING', 'DEPLOYMENT_STARTED', 'start_vm');
+ }
+
+ public function stop_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ return $this->apply_lifecycle_action((int) $request['id'], 'STOPPED', 'DEPLOYMENT_STOPPED', 'stop_vm');
+ }
+
+ public function delete_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ return $this->apply_lifecycle_action((int) $request['id'], 'DELETED', 'DEPLOYMENT_DELETED', 'delete_vm');
+ }
+
+ public function prolong_deployment(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ $this->sync_expirations();
+ $id = (int) $request['id'];
+ $record = $this->repository->deployment_record($id);
+
+ if ($record === null || $record['status'] === 'DELETED') {
+ return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
+ }
+
+ $ttl_hours = (int) $request->get_param('ttlHours');
+ $never_expire = (bool) $request->get_param('neverExpire');
+ $deployment = $this->repository->deployment($id);
+
+ if ($deployment === null) {
+ return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
+ }
+
+ if ($never_expire) {
+ $ttl_hours = null;
+ } elseif ($ttl_hours < 1) {
+ return new WP_Error('spp_invalid_ttl', 'Choose a TTL in hours or select never expire.', ['status' => 400]);
+ }
+
+ if ($ttl_hours !== null && $ttl_hours > 720) {
+ return new WP_Error('spp_ttl_too_long', 'TTL cannot exceed 720 hours.', ['status' => 400]);
+ }
+
+ if ($record['status'] === 'EXPIRED') {
+ $quota_error = $this->validate_memory_quota((int) $deployment['memoryMb'], get_current_user_id());
+ if ($quota_error instanceof WP_Error) {
+ return $quota_error;
+ }
+ }
+
+ return rest_ensure_response($this->repository->prolong_deployment($id, $ttl_hours, get_current_user_id()));
+ }
+
+ public function refresh_deployment_ips(WP_REST_Request $request): WP_REST_Response|WP_Error
+ {
+ $this->sync_expirations();
+ $id = (int) $request['id'];
+ $record = $this->repository->deployment_record($id);
+
+ if ($record === null || $record['status'] === 'DELETED') {
+ return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
+ }
+
+ if (empty($record['proxmox_vm_id'])) {
+ return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
+ }
+
+ try {
+ $deployment = $this->repository->update_deployment_ips(
+ $id,
+ $this->proxmox->get_ip_addresses((int) $record['proxmox_vm_id']),
+ get_current_user_id()
+ );
+ } catch (Throwable $error) {
+ return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
+ }
+
+ return rest_ensure_response($deployment);
+ }
+
+ private function apply_lifecycle_action(int $id, string $status, string $audit_action, string $method): WP_REST_Response|WP_Error
+ {
+ $this->sync_expirations();
+ $record = $this->repository->deployment_record($id);
+
+ if ($record === null || $record['status'] === 'DELETED') {
+ return new WP_Error('spp_not_found', 'Deployment not found.', ['status' => 404]);
+ }
+
+ if ($method === 'start_vm' && $record['status'] === 'EXPIRED') {
+ return new WP_Error(
+ 'spp_expired_deployment',
+ 'This deployment is expired. Prolong its TTL before starting it again.',
+ ['status' => 409]
+ );
+ }
+
+ if (empty($record['proxmox_vm_id'])) {
+ return new WP_Error('spp_missing_vm_id', 'Deployment is missing a Proxmox VM id.', ['status' => 409]);
+ }
+
+ try {
+ $this->proxmox->{$method}((int) $record['proxmox_vm_id']);
+ if ($method === 'start_vm') {
+ $deployment = $this->repository->update_deployment_status_and_ips(
+ $id,
+ $status,
+ $this->safe_ip_addresses((int) $record['proxmox_vm_id']),
+ $audit_action,
+ get_current_user_id()
+ );
+ } else {
+ $deployment = $this->repository->update_deployment_status($id, $status, $audit_action, get_current_user_id());
+ }
+ } catch (Throwable $error) {
+ return new WP_Error('spp_proxmox_error', $error->getMessage(), ['status' => 502]);
+ }
+
+ return rest_ensure_response($deployment);
+ }
+
+ private function sync_expirations(): void
+ {
+ $this->expiration_service->expire_due_deployments();
+ }
+
+ /**
+ * @return array
+ */
+ private function safe_ip_addresses(int $vm_id): array
+ {
+ try {
+ return $this->proxmox->get_ip_addresses($vm_id);
+ } catch (Throwable) {
+ return [];
+ }
+ }
+}
diff --git a/support-provisioning-portal/includes/class-spp-shortcode.php b/support-provisioning-portal/includes/class-spp-shortcode.php
new file mode 100644
index 0000000..ea6294f
--- /dev/null
+++ b/support-provisioning-portal/includes/class-spp-shortcode.php
@@ -0,0 +1,32 @@
+ $atts
+ */
+ public function render(array $atts = []): string
+ {
+ if (!is_user_logged_in()) {
+ return 'Please sign in to access the provisioning portal.
';
+ }
+
+ wp_enqueue_style('spp-portal', SPP_PLUGIN_URL . 'assets/portal.css', [], SPP_VERSION);
+ wp_enqueue_script('spp-portal', SPP_PLUGIN_URL . 'assets/portal.js', [], SPP_VERSION, true);
+
+ return sprintf(
+ '
',
+ esc_url_raw(rest_url('support-provisioning/v1')),
+ esc_attr(wp_create_nonce('wp_rest'))
+ );
+ }
+}
diff --git a/support-provisioning-portal/includes/interface-spp-proxmox-client.php b/support-provisioning-portal/includes/interface-spp-proxmox-client.php
new file mode 100644
index 0000000..6a88fab
--- /dev/null
+++ b/support-provisioning-portal/includes/interface-spp-proxmox-client.php
@@ -0,0 +1,27 @@
+ $input
+ * @return array{vm_id:int}
+ */
+ public function clone_vm(array $input): array;
+
+ public function start_vm(int $vm_id): void;
+
+ public function stop_vm(int $vm_id): void;
+
+ public function delete_vm(int $vm_id): void;
+
+ public function get_status(int $vm_id): string;
+
+ /**
+ * @return array
+ */
+ public function get_ip_addresses(int $vm_id): array;
+}
diff --git a/support-provisioning-portal/support-provisioning-portal.php b/support-provisioning-portal/support-provisioning-portal.php
new file mode 100644
index 0000000..38c52cc
--- /dev/null
+++ b/support-provisioning-portal/support-provisioning-portal.php
@@ -0,0 +1,37 @@
+boot();
+});