modified: README.md
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*.log
|
||||||
|
*.zip
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
393
README.md
393
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:
|
Typical resources:
|
||||||
|
|
||||||
- turnkey PBX/test appliances
|
- turnkey PBX/test appliances
|
||||||
- Windows support clients
|
- Windows support clients
|
||||||
- Linux utility VMs
|
- Linux utility VMs
|
||||||
|
|
||||||
The system must enforce:
|
The system must enforce:
|
||||||
|
|
||||||
- template-based provisioning only
|
- template-based provisioning only
|
||||||
- policy-controlled resource limits
|
- policy-controlled resource limits
|
||||||
- audit logging
|
- audit logging
|
||||||
- lifecycle actions
|
- lifecycle actions
|
||||||
- TTL / expiration support
|
- 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.
|
|
||||||
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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
188
support-provisioning-portal/includes/class-spp-activator.php
Normal file
188
support-provisioning-portal/includes/class-spp-activator.php
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Activator
|
||||||
|
{
|
||||||
|
private const DB_VERSION = '0.3.0';
|
||||||
|
|
||||||
|
public static function activate(): void
|
||||||
|
{
|
||||||
|
self::install_schema();
|
||||||
|
self::seed_templates();
|
||||||
|
|
||||||
|
add_option('spp_proxmox_mode', 'mock');
|
||||||
|
add_option('spp_proxmox_node', 'pve-01');
|
||||||
|
add_option('spp_mock_next_vm_id', 10000);
|
||||||
|
add_option('spp_quota_user_memory_mb', 0);
|
||||||
|
add_option('spp_quota_global_memory_mb', 0);
|
||||||
|
self::schedule_expiration_check();
|
||||||
|
update_option('spp_db_version', self::DB_VERSION, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deactivate(): void
|
||||||
|
{
|
||||||
|
wp_clear_scheduled_hook('spp_expire_deployments');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function maybe_upgrade(): void
|
||||||
|
{
|
||||||
|
if (get_option('spp_db_version') === self::DB_VERSION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::install_schema();
|
||||||
|
self::seed_templates();
|
||||||
|
add_option('spp_quota_user_memory_mb', 0);
|
||||||
|
add_option('spp_quota_global_memory_mb', 0);
|
||||||
|
self::schedule_expiration_check();
|
||||||
|
update_option('spp_db_version', self::DB_VERSION, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function install_schema(): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
186
support-provisioning-portal/includes/class-spp-admin-page.php
Normal file
186
support-provisioning-portal/includes/class-spp-admin-page.php
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Admin_Page
|
||||||
|
{
|
||||||
|
public function register_hooks(): void
|
||||||
|
{
|
||||||
|
add_action('admin_menu', [$this, 'register_menu']);
|
||||||
|
add_action('admin_init', [$this, 'register_settings']);
|
||||||
|
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||||
|
add_action('show_user_profile', [$this, 'render_user_quota_field']);
|
||||||
|
add_action('edit_user_profile', [$this, 'render_user_quota_field']);
|
||||||
|
add_action('personal_options_update', [$this, 'save_user_quota_field']);
|
||||||
|
add_action('edit_user_profile_update', [$this, 'save_user_quota_field']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_menu(): void
|
||||||
|
{
|
||||||
|
add_menu_page(
|
||||||
|
'Support Provisioning',
|
||||||
|
'Support Provisioning',
|
||||||
|
'edit_posts',
|
||||||
|
'support-provisioning-portal',
|
||||||
|
[$this, 'render'],
|
||||||
|
'dashicons-cloud',
|
||||||
|
58
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_settings(): void
|
||||||
|
{
|
||||||
|
register_setting('spp_settings', 'spp_proxmox_mode', [
|
||||||
|
'type' => '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'));
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
<div class="wrap spp-admin-wrap">
|
||||||
|
<h1><?php echo esc_html__('Support Provisioning Portal', 'support-provisioning-portal'); ?></h1>
|
||||||
|
<div class="spp-admin-grid">
|
||||||
|
<?php $this->render_app_root(); ?>
|
||||||
|
<?php $this->render_settings(); ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render_app_root(): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<div
|
||||||
|
id="spp-portal-root"
|
||||||
|
class="spp-portal"
|
||||||
|
data-rest-url="<?php echo esc_url_raw(rest_url('support-provisioning/v1')); ?>"
|
||||||
|
data-nonce="<?php echo esc_attr(wp_create_nonce('wp_rest')); ?>"
|
||||||
|
></div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render_settings(): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<form class="spp-settings" method="post" action="options.php">
|
||||||
|
<h2><?php echo esc_html__('Proxmox Settings', 'support-provisioning-portal'); ?></h2>
|
||||||
|
<?php settings_fields('spp_settings'); ?>
|
||||||
|
<label>
|
||||||
|
<span>Mode</span>
|
||||||
|
<select name="spp_proxmox_mode">
|
||||||
|
<option value="mock" <?php selected(get_option('spp_proxmox_mode', 'mock'), 'mock'); ?>>Mock</option>
|
||||||
|
<option value="http" <?php selected(get_option('spp_proxmox_mode', 'mock'), 'http'); ?>>HTTP token auth</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Base URL</span>
|
||||||
|
<input name="spp_proxmox_base_url" type="url" value="<?php echo esc_attr(get_option('spp_proxmox_base_url', '')); ?>" placeholder="https://proxmox.example.internal:8006">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Token ID</span>
|
||||||
|
<input name="spp_proxmox_token_id" type="text" value="<?php echo esc_attr(get_option('spp_proxmox_token_id', '')); ?>" placeholder="user@realm!token-name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Token Secret</span>
|
||||||
|
<input name="spp_proxmox_token_secret" type="password" value="<?php echo esc_attr(get_option('spp_proxmox_token_secret', '')); ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Node</span>
|
||||||
|
<input name="spp_proxmox_node" type="text" value="<?php echo esc_attr(get_option('spp_proxmox_node', 'pve-01')); ?>">
|
||||||
|
</label>
|
||||||
|
<h2><?php echo esc_html__('RAM Contingents', 'support-provisioning-portal'); ?></h2>
|
||||||
|
<p class="description"><?php echo esc_html__('Set 0 for unlimited. Active allocations include provisioning, stopped, running, and deleting deployments.', 'support-provisioning-portal'); ?></p>
|
||||||
|
<label>
|
||||||
|
<span>Default per-user RAM limit (MB)</span>
|
||||||
|
<input name="spp_quota_user_memory_mb" type="number" min="0" step="256" value="<?php echo esc_attr((string) get_option('spp_quota_user_memory_mb', 0)); ?>">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Global RAM limit (MB)</span>
|
||||||
|
<input name="spp_quota_global_memory_mb" type="number" min="0" step="256" value="<?php echo esc_attr((string) get_option('spp_quota_global_memory_mb', 0)); ?>">
|
||||||
|
</label>
|
||||||
|
<?php submit_button('Save Settings'); ?>
|
||||||
|
</form>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render_user_quota_field(WP_User $user): void
|
||||||
|
{
|
||||||
|
if (!current_user_can('edit_users')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$quota = get_user_meta($user->ID, 'spp_memory_quota_mb', true);
|
||||||
|
?>
|
||||||
|
<h2><?php echo esc_html__('Support Provisioning Contingent', 'support-provisioning-portal'); ?></h2>
|
||||||
|
<table class="form-table" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<th><label for="spp_memory_quota_mb">RAM limit override (MB)</label></th>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
id="spp_memory_quota_mb"
|
||||||
|
name="spp_memory_quota_mb"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="256"
|
||||||
|
value="<?php echo esc_attr((string) $quota); ?>"
|
||||||
|
class="regular-text"
|
||||||
|
>
|
||||||
|
<p class="description"><?php echo esc_html__('Leave empty to use the default per-user limit. Set 0 for unlimited.', 'support-provisioning-portal'); ?></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save_user_quota_field(int $user_id): void
|
||||||
|
{
|
||||||
|
if (!current_user_can('edit_user', $user_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!array_key_exists('spp_memory_quota_mb', $_POST)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw_value = sanitize_text_field(wp_unslash($_POST['spp_memory_quota_mb']));
|
||||||
|
if ($raw_value === '') {
|
||||||
|
delete_user_meta($user_id, 'spp_memory_quota_mb');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_user_meta($user_id, 'spp_memory_quota_mb', max(0, absint($raw_value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Expiration_Service
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private SPP_Repository $repository,
|
||||||
|
private SPP_Proxmox_Client $proxmox
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire_due_deployments(): void
|
||||||
|
{
|
||||||
|
foreach ($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Http_Proxmox_Client implements SPP_Proxmox_Client
|
||||||
|
{
|
||||||
|
/** @var array{base_url:string, token_id:string, token_secret:string, node:string} */
|
||||||
|
private array $options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{base_url:string, token_id:string, token_secret:string, node:string} $options
|
||||||
|
*/
|
||||||
|
public function __construct(array $options)
|
||||||
|
{
|
||||||
|
$this->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<string, int|string> $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Mock_Proxmox_Client implements SPP_Proxmox_Client
|
||||||
|
{
|
||||||
|
public function clone_vm(array $input): array
|
||||||
|
{
|
||||||
|
$next_id = (int) get_option('spp_mock_next_vm_id', 10000);
|
||||||
|
update_option('spp_mock_next_vm_id', $next_id + 1, false);
|
||||||
|
update_option('spp_mock_vm_status_' . $next_id, 'stopped', false);
|
||||||
|
update_option('spp_mock_vm_ips_' . $next_id, [sprintf('192.0.2.%d', (($next_id - 10000) % 200) + 10)], false);
|
||||||
|
|
||||||
|
return ['vm_id' => $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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
support-provisioning-portal/includes/class-spp-plugin.php
Normal file
50
support-provisioning-portal/includes/class-spp-plugin.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Plugin
|
||||||
|
{
|
||||||
|
private static ?SPP_Plugin $instance = null;
|
||||||
|
|
||||||
|
public static function instance(): SPP_Plugin
|
||||||
|
{
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
SPP_Activator::maybe_upgrade();
|
||||||
|
|
||||||
|
$repository = new SPP_Repository();
|
||||||
|
$proxmox = $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
378
support-provisioning-portal/includes/class-spp-repository.php
Normal file
378
support-provisioning-portal/includes/class-spp-repository.php
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Repository
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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<string, mixed> $template
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>|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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $row
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
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)) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_REST_Controller
|
||||||
|
{
|
||||||
|
private const NAMESPACE = 'support-provisioning/v1';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SPP_Repository $repository,
|
||||||
|
private SPP_Proxmox_Client $proxmox,
|
||||||
|
private SPP_Expiration_Service $expiration_service
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_hooks(): void
|
||||||
|
{
|
||||||
|
add_action('rest_api_init', [$this, 'register_routes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register_routes(): void
|
||||||
|
{
|
||||||
|
register_rest_route(self::NAMESPACE, '/templates', [
|
||||||
|
'methods' => 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<id>\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<id>\d+)/start', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'start_deployment'],
|
||||||
|
'permission_callback' => [$this, 'can_mutate'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/stop', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'stop_deployment'],
|
||||||
|
'permission_callback' => [$this, 'can_mutate'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\d+)/prolong', [
|
||||||
|
'methods' => WP_REST_Server::CREATABLE,
|
||||||
|
'callback' => [$this, 'prolong_deployment'],
|
||||||
|
'permission_callback' => [$this, 'can_mutate'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
register_rest_route(self::NAMESPACE, '/deployments/(?P<id>\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<int, string>
|
||||||
|
*/
|
||||||
|
private function safe_ip_addresses(int $vm_id): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->proxmox->get_ip_addresses($vm_id);
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
support-provisioning-portal/includes/class-spp-shortcode.php
Normal file
32
support-provisioning-portal/includes/class-spp-shortcode.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SPP_Shortcode
|
||||||
|
{
|
||||||
|
public function register_hooks(): void
|
||||||
|
{
|
||||||
|
add_shortcode('support_provisioning_portal', [$this, 'render']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $atts
|
||||||
|
*/
|
||||||
|
public function render(array $atts = []): string
|
||||||
|
{
|
||||||
|
if (!is_user_logged_in()) {
|
||||||
|
return '<p class="spp-login-required">Please sign in to access the provisioning portal.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
'<div id="spp-portal-root" class="spp-portal" data-rest-url="%s" data-nonce="%s"></div>',
|
||||||
|
esc_url_raw(rest_url('support-provisioning/v1')),
|
||||||
|
esc_attr(wp_create_nonce('wp_rest'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SPP_Proxmox_Client
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, int|string> $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<int, string>
|
||||||
|
*/
|
||||||
|
public function get_ip_addresses(int $vm_id): array;
|
||||||
|
}
|
||||||
37
support-provisioning-portal/support-provisioning-portal.php
Normal file
37
support-provisioning-portal/support-provisioning-portal.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Support Provisioning Portal
|
||||||
|
* Description: Internal self-service portal for provisioning standardized Proxmox VE VMs.
|
||||||
|
* Version: 0.3.0
|
||||||
|
* Author: Internal Support
|
||||||
|
* Requires PHP: 8.0
|
||||||
|
* Requires at least: 6.2
|
||||||
|
* Text Domain: support-provisioning-portal
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
define('SPP_VERSION', '0.3.0');
|
||||||
|
define('SPP_PLUGIN_FILE', __FILE__);
|
||||||
|
define('SPP_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
|
define('SPP_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
|
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-activator.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-repository.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/interface-spp-proxmox-client.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-mock-proxmox-client.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-http-proxmox-client.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-expiration-service.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-rest-controller.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-admin-page.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-shortcode.php';
|
||||||
|
require_once SPP_PLUGIN_DIR . 'includes/class-spp-plugin.php';
|
||||||
|
|
||||||
|
register_activation_hook(__FILE__, ['SPP_Activator', 'activate']);
|
||||||
|
register_deactivation_hook(__FILE__, ['SPP_Activator', 'deactivate']);
|
||||||
|
|
||||||
|
add_action('plugins_loaded', static function (): void {
|
||||||
|
SPP_Plugin::instance()->boot();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user