feat: implement storage quota checks and VM state validation for drive management
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
|
||||
|
||||
use App\Helpers\ExtensionHelper;
|
||||
use App\Livewire\Component as BaseComponent;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
|
||||
@@ -47,6 +48,15 @@ class Component extends BaseComponent
|
||||
return;
|
||||
}
|
||||
|
||||
// Check storage quota before starting the VM
|
||||
if ($action === 'start') {
|
||||
$quotaCheck = $this->checkStorageQuota();
|
||||
if ($quotaCheck !== true) {
|
||||
$this->notify($quotaCheck, 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$response = $proxmox->request(
|
||||
@@ -71,6 +81,149 @@ class Component extends BaseComponent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if storage quota allows VM to start.
|
||||
* Returns true if OK, or an error message string if over quota.
|
||||
*/
|
||||
protected function checkStorageQuota(): bool|string
|
||||
{
|
||||
try {
|
||||
// Get max allowed storage from package
|
||||
$settings = array_merge(
|
||||
ExtensionHelper::settingsToArray($this->server->service->product->settings),
|
||||
ExtensionHelper::getServiceProperties($this->server->service)
|
||||
);
|
||||
$maxAllowedStorage = isset($settings['disk']) ? (float) $settings['disk'] : 0;
|
||||
|
||||
// If no limit is set, allow start
|
||||
if ($maxAllowedStorage <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get VM config to calculate total storage
|
||||
$proxmox = new Proxmox;
|
||||
$configResponse = $proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config'
|
||||
);
|
||||
|
||||
if (!$configResponse->successful()) {
|
||||
// If we can't check, allow the action (fail open)
|
||||
return true;
|
||||
}
|
||||
|
||||
$config = $configResponse->json()['data'] ?? [];
|
||||
$totalStorage = $this->calculateTotalStorage($config);
|
||||
|
||||
if ($totalStorage > $maxAllowedStorage) {
|
||||
return 'Cannot start VM: Storage quota exceeded. Your server has ' . number_format($totalStorage, 1) . ' GB of storage configured, but your plan only allows ' . number_format($maxAllowedStorage, 1) . ' GB. Please configure your server storage within your package limits or upgrade your service to allow this configuration.';
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
// If check fails, allow the action (fail open)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total storage from VM config (including unused/detached drives).
|
||||
*/
|
||||
protected function calculateTotalStorage(array $config): float
|
||||
{
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($config as $key => $value) {
|
||||
// Check for attached drives (ide, sata, scsi, virtio)
|
||||
if (preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $key)) {
|
||||
// Skip excluded disks (cloud-init, EFI, TPM)
|
||||
if ($this->isExcludedDisk($value)) {
|
||||
continue;
|
||||
}
|
||||
// Skip CD-ROM drives
|
||||
if (str_contains($value, 'media=cdrom')) {
|
||||
continue;
|
||||
}
|
||||
$totalSize += $this->extractDiskSize($value);
|
||||
}
|
||||
// Check for unused/detached drives
|
||||
elseif (preg_match('/^unused(\d+)$/', $key)) {
|
||||
if ($this->isExcludedDisk($value)) {
|
||||
continue;
|
||||
}
|
||||
$totalSize += $this->extractUnusedDiskSize($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a disk should be excluded (cloud-init, EFI, TPM).
|
||||
*/
|
||||
protected function isExcludedDisk(string $value): bool
|
||||
{
|
||||
$excludePatterns = ['cloudinit', 'efidisk', 'tpmstate'];
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
if (str_contains(strtolower($value), $pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract disk size in GB from a disk configuration string.
|
||||
*/
|
||||
protected function extractDiskSize(string $value): float
|
||||
{
|
||||
// Example: cephpool:vm-100-disk-0,cache=writeback,size=50G
|
||||
if (preg_match('/size=(\d+)([GMTK])?/i', $value, $matches)) {
|
||||
$size = (float) $matches[1];
|
||||
$unit = strtoupper($matches[2] ?? 'G');
|
||||
|
||||
return match ($unit) {
|
||||
'T' => $size * 1024,
|
||||
'G' => $size,
|
||||
'M' => $size / 1024,
|
||||
'K' => $size / 1024 / 1024,
|
||||
default => $size,
|
||||
};
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract size from unused disk by querying Proxmox.
|
||||
*/
|
||||
protected function extractUnusedDiskSize(string $value): float
|
||||
{
|
||||
// For unused disks, we need to query the volume info
|
||||
// Format: storage:volume (e.g., cephpool:vm-100-disk-1)
|
||||
try {
|
||||
if (!preg_match('/^([^:]+):(.+)$/', $value, $matches)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$storage = $matches[1];
|
||||
$volume = $matches[2];
|
||||
|
||||
$proxmox = new Proxmox;
|
||||
$response = $proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/storage/' . $storage . '/content/' . $storage . ':' . $volume
|
||||
);
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json()['data'] ?? [];
|
||||
$sizeBytes = $data['size'] ?? 0;
|
||||
return $sizeBytes / 1024 / 1024 / 1024; // Convert to GB
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Ignore errors, return 0
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the cached VM status from Proxmox.
|
||||
*/
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
@php
|
||||
$vmRunning = $status && $status->status !== 'stopped';
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -96,6 +100,21 @@
|
||||
<p class="text-sm text-base/60">Manage virtual hard disks attached to your VPS. Add, configure, or remove storage drives.</p>
|
||||
</div>
|
||||
|
||||
<!-- VM Running Warning -->
|
||||
@if($status && $status->status !== 'stopped')
|
||||
<div class="bg-danger/10 border border-danger/30 rounded-lg p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<x-ri-error-warning-line class="size-5 text-danger" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-danger">Server Must Be Powered Off</h4>
|
||||
<p class="text-sm text-danger/80 mt-0.5">Storage changes can only be made when the server is in a powered off state. Please shut down your server before making any storage modifications.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Storage Usage Overview -->
|
||||
<div class="bg-background-secondary border border-neutral rounded-lg p-5">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
@@ -114,12 +133,19 @@
|
||||
<x-ri-refresh-line class="size-4" wire:loading.class="animate-spin" wire:target="loadStorageConfiguration" />
|
||||
Refresh
|
||||
</button>
|
||||
@if($this->remainingStorage > 0)
|
||||
@if($this->remainingStorage > 0 && !$vmRunning)
|
||||
<button wire:click="openAddDriveModal"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors">
|
||||
<x-ri-add-line class="size-4" />
|
||||
Add Drive
|
||||
</button>
|
||||
@elseif($this->remainingStorage > 0 && $vmRunning)
|
||||
<button disabled
|
||||
title="Server must be powered off to add drives"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg bg-neutral/50 text-base/50 cursor-not-allowed">
|
||||
<x-ri-add-line class="size-4" />
|
||||
Add Drive
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,14 +237,14 @@
|
||||
<button wire:click="saveScsiController"
|
||||
wire:loading.attr="disabled"
|
||||
class="inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
@if($this->scsiControllerSynced) disabled @endif>
|
||||
@if($this->scsiControllerSynced || $vmRunning) disabled @endif>
|
||||
<x-loading target="saveScsiController" />
|
||||
<span wire:loading.remove wire:target="saveScsiController">Save Changes</span>
|
||||
<span wire:loading wire:target="saveScsiController">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($status && $status->status !== 'stopped' && !$this->scsiControllerSynced)
|
||||
@if($vmRunning && !$this->scsiControllerSynced)
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg bg-warning/10 border border-warning/30 text-warning text-sm">
|
||||
<x-ri-alert-line class="size-4 flex-shrink-0" />
|
||||
<span>VM must be stopped to change the SCSI controller type.</span>
|
||||
@@ -237,10 +263,12 @@
|
||||
<div class="p-8 text-center">
|
||||
<x-ri-hard-drive-2-line class="size-12 mx-auto text-base/30 mb-3" />
|
||||
<p class="text-base/60">No drives attached to this VM.</p>
|
||||
@if($this->remainingStorage > 0)
|
||||
@if($this->remainingStorage > 0 && !$vmRunning)
|
||||
<button wire:click="openAddDriveModal" class="mt-4 text-primary hover:text-primary/80 text-sm font-medium">
|
||||
Add your first drive →
|
||||
</button>
|
||||
@elseif($this->remainingStorage > 0 && $vmRunning)
|
||||
<p class="mt-4 text-base/50 text-sm">Power off server to add drives</p>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@@ -275,37 +303,64 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<button wire:click="openEditDriveModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
<x-ri-settings-3-line class="size-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
<button wire:click="openMoveDriveModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
<x-ri-arrow-left-right-line class="size-3.5" />
|
||||
Device Bus
|
||||
</button>
|
||||
@if(count($unusedDrives) >= 2)
|
||||
@if($vmRunning)
|
||||
<button disabled
|
||||
title="Maximum of 2 detached drives allowed"
|
||||
title="Server must be powered off"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral/30 bg-neutral/10 text-base/50 cursor-not-allowed">
|
||||
<x-ri-settings-3-line class="size-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
<button disabled
|
||||
title="Server must be powered off"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral/30 bg-neutral/10 text-base/50 cursor-not-allowed">
|
||||
<x-ri-arrow-left-right-line class="size-3.5" />
|
||||
Device Bus
|
||||
</button>
|
||||
<button disabled
|
||||
title="Server must be powered off"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(150 150 150 / 0.3); background-color: rgb(150 150 150 / 0.1); color: rgb(150 150 150); cursor: not-allowed; opacity: 0.6;">
|
||||
<x-ri-eject-line class="size-3.5" />
|
||||
Detach
|
||||
</button>
|
||||
<button disabled
|
||||
title="Server must be powered off"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral/30 bg-neutral/10 text-base/50 cursor-not-allowed">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="openDetachModal('{{ $driveKey }}')"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(234 179 8 / 0.5); background-color: rgb(234 179 8 / 0.1); color: rgb(234 179 8); transition: background-color 0.15s ease;"
|
||||
onmouseover="this.style.backgroundColor='rgb(234 179 8 / 0.2)'"
|
||||
onmouseout="this.style.backgroundColor='rgb(234 179 8 / 0.1)'">
|
||||
<x-ri-eject-line class="size-3.5" />
|
||||
Detach
|
||||
<button wire:click="openEditDriveModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
<x-ri-settings-3-line class="size-3.5" />
|
||||
Settings
|
||||
</button>
|
||||
<button wire:click="openMoveDriveModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
<x-ri-arrow-left-right-line class="size-3.5" />
|
||||
Device Bus
|
||||
</button>
|
||||
@if(count($unusedDrives) >= 2)
|
||||
<button disabled
|
||||
title="Maximum of 2 detached drives allowed"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(150 150 150 / 0.3); background-color: rgb(150 150 150 / 0.1); color: rgb(150 150 150); cursor: not-allowed; opacity: 0.6;">
|
||||
<x-ri-eject-line class="size-3.5" />
|
||||
Detach
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="openDetachModal('{{ $driveKey }}')"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(234 179 8 / 0.5); background-color: rgb(234 179 8 / 0.1); color: rgb(234 179 8); transition: background-color 0.15s ease;"
|
||||
onmouseover="this.style.backgroundColor='rgb(234 179 8 / 0.2)'"
|
||||
onmouseout="this.style.backgroundColor='rgb(234 179 8 / 0.1)'">
|
||||
<x-ri-eject-line class="size-3.5" />
|
||||
Detach
|
||||
</button>
|
||||
@endif
|
||||
<button wire:click="openDeleteModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-danger/50 bg-danger/10 text-danger hover:bg-danger/20 transition-colors">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
<button wire:click="openDeleteModal('{{ $driveKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-danger/50 bg-danger/10 text-danger hover:bg-danger/20 transition-colors">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,28 +427,49 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
@if($totalAllocatedStorage >= $maxAllowedStorage && $maxAllowedStorage > 0)
|
||||
@if($vmRunning)
|
||||
<button disabled
|
||||
title="Server must be powered off"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(150 150 150 / 0.3); background-color: rgb(150 150 150 / 0.1); color: rgb(150 150 150); cursor: not-allowed; opacity: 0.6;">
|
||||
<x-ri-link class="size-3.5" />
|
||||
Reattach
|
||||
</button>
|
||||
<button disabled
|
||||
title="Server must be powered off"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(150 150 150 / 0.3); background-color: rgb(150 150 150 / 0.1); color: rgb(150 150 150); cursor: not-allowed; opacity: 0.6;">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
@elseif($totalAllocatedStorage >= $maxAllowedStorage && $maxAllowedStorage > 0)
|
||||
<button disabled
|
||||
title="Storage quota reached - please upgrade your service or delete drives"
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(150 150 150 / 0.3); background-color: rgb(150 150 150 / 0.1); color: rgb(150 150 150); cursor: not-allowed; opacity: 0.6;">
|
||||
<x-ri-link class="size-3.5" />
|
||||
Reattach
|
||||
</button>
|
||||
<button wire:click="deleteUnusedDrive('{{ $unusedKey }}')"
|
||||
wire:confirm="Are you sure you want to permanently delete this detached drive? This action cannot be undone."
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(239 68 68 / 0.5); background-color: rgb(239 68 68 / 0.1); color: rgb(239 68 68); transition: background-color 0.15s ease;"
|
||||
onmouseover="this.style.backgroundColor='rgb(239 68 68 / 0.2)'"
|
||||
onmouseout="this.style.backgroundColor='rgb(239 68 68 / 0.1)'">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
@else
|
||||
<button wire:click="openReattachModal('{{ $unusedKey }}')"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-success/50 bg-success/10 text-success hover:bg-success/20 transition-colors">
|
||||
<x-ri-link class="size-3.5" />
|
||||
Reattach
|
||||
</button>
|
||||
<button wire:click="deleteUnusedDrive('{{ $unusedKey }}')"
|
||||
wire:confirm="Are you sure you want to permanently delete this detached drive? This action cannot be undone."
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(239 68 68 / 0.5); background-color: rgb(239 68 68 / 0.1); color: rgb(239 68 68); transition: background-color 0.15s ease;"
|
||||
onmouseover="this.style.backgroundColor='rgb(239 68 68 / 0.2)'"
|
||||
onmouseout="this.style.backgroundColor='rgb(239 68 68 / 0.1)'">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
<button wire:click="deleteUnusedDrive('{{ $unusedKey }}')"
|
||||
wire:confirm="Are you sure you want to permanently delete this detached drive? This action cannot be undone."
|
||||
style="display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 500; border-radius: 0.5rem; border: 1px solid rgb(239 68 68 / 0.5); background-color: rgb(239 68 68 / 0.1); color: rgb(239 68 68); transition: background-color 0.15s ease;"
|
||||
onmouseover="this.style.backgroundColor='rgb(239 68 68 / 0.2)'"
|
||||
onmouseout="this.style.backgroundColor='rgb(239 68 68 / 0.1)'">
|
||||
<x-ri-delete-bin-line class="size-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user