Add storage management functionality to Proxmox extension
- Registered Livewire component for storage management. - Updated routes to include storage management endpoint. - Modified layout to add storage management link. - Created storage management view with features to add, edit, and manage drives. - Implemented modals for adding and editing drives with appropriate validations and options.
This commit is contained in:
869
extensions/Servers/Proxmox/Livewire/Storage.php
Normal file
869
extensions/Servers/Proxmox/Livewire/Storage.php
Normal file
@@ -0,0 +1,869 @@
|
||||
<?php
|
||||
|
||||
namespace Paymenter\Extensions\Servers\Proxmox\Livewire;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
|
||||
|
||||
class Storage extends Component
|
||||
{
|
||||
/**
|
||||
* List of disk drives attached to the VM.
|
||||
*/
|
||||
public array $drives = [];
|
||||
|
||||
/**
|
||||
* Total allocated storage size in GB.
|
||||
*/
|
||||
public float $totalAllocatedStorage = 0;
|
||||
|
||||
/**
|
||||
* Maximum allowed storage in GB from plan configuration.
|
||||
*/
|
||||
#[Locked]
|
||||
public float $maxAllowedStorage = 0;
|
||||
|
||||
/**
|
||||
* Current SCSI controller type.
|
||||
*/
|
||||
public string $scsiControllerType = 'virtio-scsi-pci';
|
||||
|
||||
/**
|
||||
* Original SCSI controller type for tracking changes.
|
||||
*/
|
||||
#[Locked]
|
||||
public string $originalScsiControllerType = 'virtio-scsi-pci';
|
||||
|
||||
/**
|
||||
* Available SCSI controller types.
|
||||
*/
|
||||
public array $scsiControllerOptions = [
|
||||
['value' => 'lsi', 'label' => 'LSI 53C895A'],
|
||||
['value' => 'lsi53c810', 'label' => 'LSI 53C810'],
|
||||
['value' => 'virtio-scsi-pci', 'label' => 'VirtIO SCSI'],
|
||||
['value' => 'virtio-scsi-single', 'label' => 'VirtIO SCSI Single'],
|
||||
['value' => 'megasas', 'label' => 'MegaRAID SAS 8708EM2'],
|
||||
['value' => 'pvscsi', 'label' => 'VMware PVSCSI'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Available bus/device types with their configurations.
|
||||
*/
|
||||
public array $busTypes = [
|
||||
'ide' => ['label' => 'IDE', 'max' => 4, 'ssd_support' => true, 'iothread_support' => false],
|
||||
'sata' => ['label' => 'SATA', 'max' => 6, 'ssd_support' => true, 'iothread_support' => false],
|
||||
'scsi' => ['label' => 'SCSI', 'max' => 31, 'ssd_support' => true, 'iothread_support' => true],
|
||||
'virtio' => ['label' => 'VirtIO Block', 'max' => 16, 'ssd_support' => false, 'iothread_support' => true],
|
||||
];
|
||||
|
||||
/**
|
||||
* Available cache modes.
|
||||
*/
|
||||
public array $cacheOptions = [
|
||||
['value' => 'none', 'label' => 'No Cache (Default)'],
|
||||
['value' => 'directsync', 'label' => 'Direct Sync'],
|
||||
['value' => 'writethrough', 'label' => 'Write Through'],
|
||||
['value' => 'writeback', 'label' => 'Write Back'],
|
||||
['value' => 'unsafe', 'label' => 'Write Back (Unsafe)'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Available Async IO types.
|
||||
*/
|
||||
public array $aioOptions = [
|
||||
['value' => 'io_uring', 'label' => 'io_uring (Default)'],
|
||||
['value' => 'native', 'label' => 'Native'],
|
||||
['value' => 'threads', 'label' => 'Threads'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Form state for adding a new drive.
|
||||
*/
|
||||
public bool $showAddDriveModal = false;
|
||||
public string $newDriveBus = 'scsi';
|
||||
public int $newDriveSize = 10;
|
||||
public string $newDriveCache = 'none';
|
||||
public string $newDriveAio = 'io_uring';
|
||||
public bool $newDriveSsd = false;
|
||||
public bool $newDriveIothread = true;
|
||||
public bool $newDriveDiscard = true;
|
||||
|
||||
/**
|
||||
* Form state for editing drive settings.
|
||||
*/
|
||||
public bool $showEditDriveModal = false;
|
||||
public ?string $editingDriveKey = null;
|
||||
public string $editDriveCache = 'none';
|
||||
public string $editDriveAio = 'io_uring';
|
||||
public bool $editDriveSsd = false;
|
||||
public bool $editDriveIothread = false;
|
||||
public bool $editDriveDiscard = true;
|
||||
|
||||
/**
|
||||
* Form state for moving/changing bus type.
|
||||
*/
|
||||
public bool $showMoveDriveModal = false;
|
||||
public ?string $movingDriveKey = null;
|
||||
public string $moveDriveTargetBus = 'scsi';
|
||||
|
||||
/**
|
||||
* Storage name for cephpool.
|
||||
*/
|
||||
protected string $storagePool = 'cephpool';
|
||||
|
||||
public function mount(Server $server): void
|
||||
{
|
||||
parent::mount($server);
|
||||
|
||||
$this->loadMaxAllowedStorage();
|
||||
$this->loadStorageConfiguration(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the maximum allowed storage from the plan configuration.
|
||||
*/
|
||||
protected function loadMaxAllowedStorage(): void
|
||||
{
|
||||
$diskSetting = $this->server->service->product->settings()
|
||||
->where('key', 'disk')
|
||||
->first();
|
||||
|
||||
$this->maxAllowedStorage = $diskSetting ? (float) $diskSetting->value : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the current storage configuration from Proxmox.
|
||||
*/
|
||||
public function loadStorageConfiguration(bool $notify = true): void
|
||||
{
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$config = $proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config'
|
||||
)->json()['data'] ?? [];
|
||||
|
||||
$this->parseStorageConfiguration($config);
|
||||
$this->scsiControllerType = $config['scsihw'] ?? 'virtio-scsi-pci';
|
||||
$this->originalScsiControllerType = $this->scsiControllerType;
|
||||
|
||||
if ($notify) {
|
||||
$this->notify('Storage configuration refreshed.', 'success');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
if ($notify) {
|
||||
$this->notify('Failed to load storage configuration: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the VM configuration to extract disk information.
|
||||
*/
|
||||
protected function parseStorageConfiguration(array $config): void
|
||||
{
|
||||
$drives = [];
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($config as $key => $value) {
|
||||
// Skip non-disk entries
|
||||
if (!preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $key, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip cloud-init, EFI, and TPM disks
|
||||
if ($this->isExcludedDisk($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip CD-ROM drives
|
||||
if (str_contains($value, 'media=cdrom')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$busType = $matches[1];
|
||||
$deviceIndex = (int) $matches[2];
|
||||
|
||||
$diskInfo = $this->parseDiskString($value, $busType, $deviceIndex);
|
||||
if ($diskInfo) {
|
||||
$drives[$key] = $diskInfo;
|
||||
$totalSize += $diskInfo['size'];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort drives by bus type and index
|
||||
uksort($drives, function ($a, $b) {
|
||||
preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $a, $matchA);
|
||||
preg_match('/^(ide|sata|scsi|virtio)(\d+)$/', $b, $matchB);
|
||||
|
||||
$busOrder = ['ide' => 0, 'sata' => 1, 'scsi' => 2, 'virtio' => 3];
|
||||
$busCompare = ($busOrder[$matchA[1]] ?? 4) <=> ($busOrder[$matchB[1]] ?? 4);
|
||||
|
||||
if ($busCompare !== 0) {
|
||||
return $busCompare;
|
||||
}
|
||||
|
||||
return (int) $matchA[2] <=> (int) $matchB[2];
|
||||
});
|
||||
|
||||
$this->drives = $drives;
|
||||
$this->totalAllocatedStorage = $totalSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a disk should be excluded (cloud-init, EFI, TPM).
|
||||
*/
|
||||
protected function isExcludedDisk(string $value): bool
|
||||
{
|
||||
$excludePatterns = [
|
||||
'cloudinit',
|
||||
'vm-\d+-cloudinit',
|
||||
'efidisk',
|
||||
'tpmstate',
|
||||
];
|
||||
|
||||
foreach ($excludePatterns as $pattern) {
|
||||
if (preg_match('/' . $pattern . '/i', $value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a disk configuration string into structured data.
|
||||
*/
|
||||
protected function parseDiskString(string $value, string $busType, int $deviceIndex): ?array
|
||||
{
|
||||
// Example: cephpool:vm-100-disk-0,size=32G,cache=none,ssd=1
|
||||
$parts = explode(',', $value);
|
||||
$volumePart = $parts[0] ?? '';
|
||||
|
||||
// Extract size
|
||||
$sizeGb = 0;
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^size=(\d+)([KMGT]?)$/i', $part, $sizeMatch)) {
|
||||
$size = (float) $sizeMatch[1];
|
||||
$unit = strtoupper($sizeMatch[2] ?? 'G');
|
||||
|
||||
switch ($unit) {
|
||||
case 'K':
|
||||
$sizeGb = $size / (1024 * 1024);
|
||||
break;
|
||||
case 'M':
|
||||
$sizeGb = $size / 1024;
|
||||
break;
|
||||
case 'G':
|
||||
case '':
|
||||
$sizeGb = $size;
|
||||
break;
|
||||
case 'T':
|
||||
$sizeGb = $size * 1024;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract cache mode
|
||||
$cache = 'none';
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^cache=(\w+)$/', $part, $cacheMatch)) {
|
||||
$cache = $cacheMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract AIO type
|
||||
$aio = 'io_uring';
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^aio=(\w+)$/', $part, $aioMatch)) {
|
||||
$aio = $aioMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract SSD emulation
|
||||
$ssd = false;
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^ssd=(\d)$/', $part, $ssdMatch)) {
|
||||
$ssd = (bool) $ssdMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract IO thread
|
||||
$iothread = false;
|
||||
foreach ($parts as $part) {
|
||||
if (preg_match('/^iothread=(\d)$/', $part, $iothreadMatch)) {
|
||||
$iothread = (bool) $iothreadMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract discard
|
||||
$discard = false;
|
||||
foreach ($parts as $part) {
|
||||
if ($part === 'discard=on') {
|
||||
$discard = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if attached (not detached/unused)
|
||||
$isAttached = !str_starts_with($volumePart, 'unused');
|
||||
|
||||
return [
|
||||
'key' => $busType . $deviceIndex,
|
||||
'bus' => $busType,
|
||||
'index' => $deviceIndex,
|
||||
'volume' => $volumePart,
|
||||
'size' => $sizeGb,
|
||||
'cache' => $cache,
|
||||
'aio' => $aio,
|
||||
'ssd' => $ssd,
|
||||
'iothread' => $iothread,
|
||||
'discard' => $discard,
|
||||
'attached' => $isAttached,
|
||||
'raw' => $value,
|
||||
'label' => $this->getDiskLabel($busType, $deviceIndex),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for a disk.
|
||||
*/
|
||||
protected function getDiskLabel(string $busType, int $index): string
|
||||
{
|
||||
$busLabels = [
|
||||
'ide' => 'IDE',
|
||||
'sata' => 'SATA',
|
||||
'scsi' => 'SCSI',
|
||||
'virtio' => 'VirtIO',
|
||||
];
|
||||
|
||||
return ($busLabels[$busType] ?? strtoupper($busType)) . ' ' . $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available device index for a bus type.
|
||||
*/
|
||||
protected function getNextAvailableIndex(string $busType): ?int
|
||||
{
|
||||
$maxIndex = $this->busTypes[$busType]['max'] ?? 4;
|
||||
$usedIndices = [];
|
||||
|
||||
foreach ($this->drives as $drive) {
|
||||
if ($drive['bus'] === $busType) {
|
||||
$usedIndices[] = $drive['index'];
|
||||
}
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $maxIndex; $i++) {
|
||||
if (!in_array($i, $usedIndices)) {
|
||||
return $i;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the add drive modal.
|
||||
*/
|
||||
public function openAddDriveModal(): void
|
||||
{
|
||||
$this->resetAddDriveForm();
|
||||
$this->showAddDriveModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the add drive modal.
|
||||
*/
|
||||
public function closeAddDriveModal(): void
|
||||
{
|
||||
$this->showAddDriveModal = false;
|
||||
$this->resetAddDriveForm();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the add drive form.
|
||||
*/
|
||||
protected function resetAddDriveForm(): void
|
||||
{
|
||||
$this->newDriveBus = 'scsi';
|
||||
$this->newDriveSize = 10;
|
||||
$this->newDriveCache = 'none';
|
||||
$this->newDriveAio = 'io_uring';
|
||||
$this->newDriveSsd = false;
|
||||
$this->newDriveIothread = true;
|
||||
$this->newDriveDiscard = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new drive to the VM.
|
||||
*/
|
||||
public function addDrive(): void
|
||||
{
|
||||
// Validate size
|
||||
if ($this->newDriveSize < 1) {
|
||||
$this->notify('Drive size must be at least 1 GB.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check storage quota
|
||||
$newTotal = $this->totalAllocatedStorage + $this->newDriveSize;
|
||||
if ($newTotal > $this->maxAllowedStorage) {
|
||||
$remaining = $this->maxAllowedStorage - $this->totalAllocatedStorage;
|
||||
$this->notify("Adding this drive would exceed your storage quota. You have {$remaining} GB remaining.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get next available index
|
||||
$index = $this->getNextAvailableIndex($this->newDriveBus);
|
||||
if ($index === null) {
|
||||
$this->notify("No available slots for {$this->busTypes[$this->newDriveBus]['label']} drives.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build drive configuration string
|
||||
$driveConfig = $this->buildDriveConfig(
|
||||
$this->newDriveSize,
|
||||
$this->newDriveCache,
|
||||
$this->newDriveAio,
|
||||
$this->newDriveSsd,
|
||||
$this->newDriveIothread,
|
||||
$this->newDriveDiscard,
|
||||
$this->newDriveBus
|
||||
);
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$driveKey = $this->newDriveBus . $index;
|
||||
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
$driveKey => $this->storagePool . ':' . $this->newDriveSize . ',' . $driveConfig,
|
||||
]
|
||||
);
|
||||
|
||||
$this->notify("Drive {$driveKey} created successfully. A reboot may be required.", 'success');
|
||||
$this->closeAddDriveModal();
|
||||
$this->loadStorageConfiguration(false);
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to add drive: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build drive configuration string.
|
||||
*/
|
||||
protected function buildDriveConfig(
|
||||
int $size,
|
||||
string $cache,
|
||||
string $aio,
|
||||
bool $ssd,
|
||||
bool $iothread,
|
||||
bool $discard,
|
||||
string $busType
|
||||
): string {
|
||||
$config = [];
|
||||
|
||||
if ($cache !== 'none') {
|
||||
$config[] = 'cache=' . $cache;
|
||||
}
|
||||
|
||||
if ($aio !== 'io_uring') {
|
||||
$config[] = 'aio=' . $aio;
|
||||
}
|
||||
|
||||
// SSD emulation - only available for IDE, SATA, SCSI (not VirtIO Block)
|
||||
if ($ssd && $this->busTypes[$busType]['ssd_support']) {
|
||||
$config[] = 'ssd=1';
|
||||
}
|
||||
|
||||
// IO thread - only available for SCSI and VirtIO
|
||||
if ($iothread && $this->busTypes[$busType]['iothread_support']) {
|
||||
$config[] = 'iothread=1';
|
||||
}
|
||||
|
||||
if ($discard) {
|
||||
$config[] = 'discard=on';
|
||||
}
|
||||
|
||||
return implode(',', $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the edit drive modal.
|
||||
*/
|
||||
public function openEditDriveModal(string $driveKey): void
|
||||
{
|
||||
if (!isset($this->drives[$driveKey])) {
|
||||
$this->notify('Drive not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$drive = $this->drives[$driveKey];
|
||||
$this->editingDriveKey = $driveKey;
|
||||
$this->editDriveCache = $drive['cache'];
|
||||
$this->editDriveAio = $drive['aio'];
|
||||
$this->editDriveSsd = $drive['ssd'];
|
||||
$this->editDriveIothread = $drive['iothread'];
|
||||
$this->editDriveDiscard = $drive['discard'];
|
||||
$this->showEditDriveModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the edit drive modal.
|
||||
*/
|
||||
public function closeEditDriveModal(): void
|
||||
{
|
||||
$this->showEditDriveModal = false;
|
||||
$this->editingDriveKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save drive settings.
|
||||
*/
|
||||
public function saveDriveSettings(): void
|
||||
{
|
||||
if (!$this->editingDriveKey || !isset($this->drives[$this->editingDriveKey])) {
|
||||
$this->notify('No drive selected.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$drive = $this->drives[$this->editingDriveKey];
|
||||
$busType = $drive['bus'];
|
||||
|
||||
// Build the new configuration
|
||||
$configParts = [];
|
||||
|
||||
// Keep the volume reference
|
||||
$configParts[] = $drive['volume'];
|
||||
|
||||
// Add size
|
||||
if ($drive['size'] > 0) {
|
||||
$configParts[] = 'size=' . (int)$drive['size'] . 'G';
|
||||
}
|
||||
|
||||
// Add cache if not default
|
||||
if ($this->editDriveCache !== 'none') {
|
||||
$configParts[] = 'cache=' . $this->editDriveCache;
|
||||
}
|
||||
|
||||
// Add AIO if not default
|
||||
if ($this->editDriveAio !== 'io_uring') {
|
||||
$configParts[] = 'aio=' . $this->editDriveAio;
|
||||
}
|
||||
|
||||
// Add SSD emulation if supported and enabled
|
||||
if ($this->editDriveSsd && $this->busTypes[$busType]['ssd_support']) {
|
||||
$configParts[] = 'ssd=1';
|
||||
}
|
||||
|
||||
// Add IO thread if supported and enabled
|
||||
if ($this->editDriveIothread && $this->busTypes[$busType]['iothread_support']) {
|
||||
$configParts[] = 'iothread=1';
|
||||
}
|
||||
|
||||
// Add discard
|
||||
if ($this->editDriveDiscard) {
|
||||
$configParts[] = 'discard=on';
|
||||
}
|
||||
|
||||
$newConfig = implode(',', $configParts);
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
$this->editingDriveKey => $newConfig,
|
||||
]
|
||||
);
|
||||
|
||||
$this->notify('Drive settings updated successfully. A reboot may be required.', 'success');
|
||||
$this->closeEditDriveModal();
|
||||
$this->loadStorageConfiguration(false);
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to update drive settings: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the move drive modal (change bus/device type).
|
||||
*/
|
||||
public function openMoveDriveModal(string $driveKey): void
|
||||
{
|
||||
if (!isset($this->drives[$driveKey])) {
|
||||
$this->notify('Drive not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->movingDriveKey = $driveKey;
|
||||
$this->moveDriveTargetBus = $this->drives[$driveKey]['bus'];
|
||||
$this->showMoveDriveModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the move drive modal.
|
||||
*/
|
||||
public function closeMoveDriveModal(): void
|
||||
{
|
||||
$this->showMoveDriveModal = false;
|
||||
$this->movingDriveKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach a drive (move to unused).
|
||||
*/
|
||||
public function detachDrive(string $driveKey): void
|
||||
{
|
||||
if (!isset($this->drives[$driveKey])) {
|
||||
$this->notify('Drive not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check VM status - must be stopped to detach
|
||||
if ($this->status && $this->status->status !== 'stopped') {
|
||||
$this->notify('VM must be stopped to detach drives.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
|
||||
// Use the delete parameter to remove the disk from the config (keeps the volume as unused)
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
'delete' => $driveKey,
|
||||
]
|
||||
);
|
||||
|
||||
$this->notify("Drive {$driveKey} detached successfully.", 'success');
|
||||
$this->loadStorageConfiguration(false);
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to detach drive: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move/reattach a drive to a new bus type.
|
||||
*/
|
||||
public function moveDrive(): void
|
||||
{
|
||||
if (!$this->movingDriveKey || !isset($this->drives[$this->movingDriveKey])) {
|
||||
$this->notify('No drive selected.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check VM status
|
||||
if ($this->status && $this->status->status !== 'stopped') {
|
||||
$this->notify('VM must be stopped to move drives.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
$drive = $this->drives[$this->movingDriveKey];
|
||||
$sourceBus = $drive['bus'];
|
||||
$targetBus = $this->moveDriveTargetBus;
|
||||
|
||||
// Get next available index on target bus
|
||||
$targetIndex = $this->getNextAvailableIndex($targetBus);
|
||||
if ($targetIndex === null) {
|
||||
$this->notify("No available slots for {$this->busTypes[$targetBus]['label']} drives.", 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$targetKey = $targetBus . $targetIndex;
|
||||
|
||||
// Use Proxmox move_disk API
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/move_disk',
|
||||
'post',
|
||||
[
|
||||
'disk' => $this->movingDriveKey,
|
||||
'storage' => $this->storagePool,
|
||||
'target-disk' => $targetKey,
|
||||
]
|
||||
);
|
||||
|
||||
$this->notify("Drive moved from {$this->movingDriveKey} to {$targetKey} successfully.", 'success');
|
||||
$this->closeMoveDriveModal();
|
||||
$this->loadStorageConfiguration(false);
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to move drive: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a drive permanently.
|
||||
*/
|
||||
public function deleteDrive(string $driveKey): void
|
||||
{
|
||||
if (!isset($this->drives[$driveKey])) {
|
||||
$this->notify('Drive not found.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check VM status
|
||||
if ($this->status && $this->status->status !== 'stopped') {
|
||||
$this->notify('VM must be stopped to delete drives.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
|
||||
// First detach the disk, then delete the volume
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
'delete' => $driveKey,
|
||||
]
|
||||
);
|
||||
|
||||
// Now find and delete the unused disk
|
||||
// Reload config to get the unused disk reference
|
||||
$config = $proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config'
|
||||
)->json()['data'] ?? [];
|
||||
|
||||
// Find unused disks and delete the one we just detached
|
||||
foreach ($config as $key => $value) {
|
||||
if (str_starts_with($key, 'unused') && str_contains($value, $this->drives[$driveKey]['volume'])) {
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
'delete' => $key,
|
||||
]
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->notify("Drive {$driveKey} deleted successfully.", 'success');
|
||||
$this->loadStorageConfiguration(false);
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to delete drive: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the SCSI controller type.
|
||||
*/
|
||||
public function saveScsiController(): void
|
||||
{
|
||||
if ($this->scsiControllerType === $this->originalScsiControllerType) {
|
||||
$this->notify('No changes to save.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check VM status
|
||||
if ($this->status && $this->status->status !== 'stopped') {
|
||||
$this->notify('VM must be stopped to change SCSI controller type.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$proxmox = new Proxmox;
|
||||
$proxmox->request(
|
||||
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/config',
|
||||
'put',
|
||||
[
|
||||
'scsihw' => $this->scsiControllerType,
|
||||
]
|
||||
);
|
||||
|
||||
$this->originalScsiControllerType = $this->scsiControllerType;
|
||||
$this->notify('SCSI controller type updated successfully. Please restart the VM for changes to take effect.', 'success');
|
||||
} catch (\Exception $e) {
|
||||
$this->notify('Failed to update SCSI controller type: ' . $e->getMessage(), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available storage remaining.
|
||||
*/
|
||||
public function getRemainingStorageProperty(): float
|
||||
{
|
||||
return max(0, $this->maxAllowedStorage - $this->totalAllocatedStorage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SCSI controller settings are synced.
|
||||
*/
|
||||
public function getScsiControllerSyncedProperty(): bool
|
||||
{
|
||||
return $this->scsiControllerType === $this->originalScsiControllerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SSD emulation is supported for the current bus type being edited.
|
||||
*/
|
||||
public function getEditDriveSsdSupportedProperty(): bool
|
||||
{
|
||||
if (!$this->editingDriveKey || !isset($this->drives[$this->editingDriveKey])) {
|
||||
return false;
|
||||
}
|
||||
$busType = $this->drives[$this->editingDriveKey]['bus'];
|
||||
return $this->busTypes[$busType]['ssd_support'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IO thread is supported for the current bus type being edited.
|
||||
*/
|
||||
public function getEditDriveIothreadSupportedProperty(): bool
|
||||
{
|
||||
if (!$this->editingDriveKey || !isset($this->drives[$this->editingDriveKey])) {
|
||||
return false;
|
||||
}
|
||||
$busType = $this->drives[$this->editingDriveKey]['bus'];
|
||||
return $this->busTypes[$busType]['iothread_support'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if SSD emulation is supported for the new drive bus type.
|
||||
*/
|
||||
public function getNewDriveSsdSupportedProperty(): bool
|
||||
{
|
||||
return $this->busTypes[$this->newDriveBus]['ssd_support'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if IO thread is supported for the new drive bus type.
|
||||
*/
|
||||
public function getNewDriveIothreadSupportedProperty(): bool
|
||||
{
|
||||
return $this->busTypes[$this->newDriveBus]['iothread_support'] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle bus type change for new drive form.
|
||||
*/
|
||||
public function updatedNewDriveBus(): void
|
||||
{
|
||||
// Reset SSD and IO thread options based on bus type support
|
||||
if (!$this->newDriveSsdSupported) {
|
||||
$this->newDriveSsd = false;
|
||||
}
|
||||
if (!$this->newDriveIothreadSupported) {
|
||||
$this->newDriveIothread = false;
|
||||
} else {
|
||||
// Enable IO thread by default for supported bus types
|
||||
$this->newDriveIothread = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('proxmox::storage')->layoutData([
|
||||
'sidebar' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class Proxmox extends ClassServer
|
||||
\Livewire\Livewire::component('proxmox.boot-order', \Paymenter\Extensions\Servers\Proxmox\Livewire\BootOrder::class);
|
||||
\Livewire\Livewire::component('proxmox.display', \Paymenter\Extensions\Servers\Proxmox\Livewire\Display::class);
|
||||
\Livewire\Livewire::component('proxmox.system', \Paymenter\Extensions\Servers\Proxmox\Livewire\System::class);
|
||||
\Livewire\Livewire::component('proxmox.storage', \Paymenter\Extensions\Servers\Proxmox\Livewire\Storage::class);
|
||||
\Livewire\Livewire::component('proxmox.cloud-init', \Paymenter\Extensions\Servers\Proxmox\Livewire\CloudInit::class);
|
||||
\Livewire\Livewire::component('proxmox.subdomain', \Paymenter\Extensions\Servers\Proxmox\Livewire\Subdomain::class);
|
||||
\Livewire\Livewire::component('proxmox.reinstall', \Paymenter\Extensions\Servers\Proxmox\Livewire\Reinstall::class);
|
||||
|
||||
@@ -10,6 +10,7 @@ use Paymenter\Extensions\Servers\Proxmox\Http\Controllers\LiveTrafficController;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Firewall;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Network;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Overview;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Storage;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Subdomain;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\System;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\VNC;
|
||||
@@ -34,6 +35,7 @@ Route::group([
|
||||
Route::get('{server}/subdomain', Subdomain::class)->name('server.subdomain');
|
||||
Route::get('{server}/backups', BackupsIndex::class)->name('server.backups');
|
||||
Route::get('{server}/statistics', Statistics::class)->name('server.statistics');
|
||||
Route::get('{server}/storage', Storage::class)->name('server.storage');
|
||||
Route::get('{server}/vnc', VNC::class)->name('server.vnc');
|
||||
Route::get('{server}/xterm', XTerm::class)->name('server.xterm');
|
||||
Route::get('{server}/help', Help::class)->name('server.help');
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
'items' => [
|
||||
['label' => 'Boot Settings', 'view' => 'boot-order', 'route' => route('proxmox.server.boot-order', ['server' => $server->id]), 'advanced' => true],
|
||||
['label' => 'Display', 'view' => 'display', 'route' => route('proxmox.server.display', ['server' => $server->id]), 'advanced' => true],
|
||||
['label' => 'Storage', 'view' => 'storage', 'route' => route('proxmox.server.storage', ['server' => $server->id]), 'advanced' => true],
|
||||
['label' => 'System', 'view' => 'system', 'route' => route('proxmox.server.system', ['server' => $server->id]), 'advanced' => true],
|
||||
]
|
||||
],
|
||||
|
||||
594
extensions/Servers/Proxmox/views/storage.blade.php
Normal file
594
extensions/Servers/Proxmox/views/storage.blade.php
Normal file
@@ -0,0 +1,594 @@
|
||||
<x-proxmox::layout :server="$server" view="storage" :status="$status" :status-polling="true" :status-polling-interval="'2s'">
|
||||
<style>
|
||||
/* Hide sidebar in problematic resolution range (768px - 1023px) */
|
||||
@media screen and (min-width: 768px) and (max-width: 1023px) {
|
||||
html body > div.w-full.flex.flex-grow > aside.w-64.h-screen,
|
||||
html body > div.w-full.flex > aside.w-64.h-screen,
|
||||
html body aside.w-64.h-screen.mt-14,
|
||||
html body aside.w-64.h-screen.mt-24,
|
||||
body aside.w-64[class*="md:flex"] {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
position: absolute !important;
|
||||
left: -9999px !important;
|
||||
}
|
||||
html body > div.w-full.flex.flex-grow > div[class*="md:ml-64"],
|
||||
html body > div.w-full.flex > div[class*="md:ml-64"],
|
||||
body > div > div[class*="md:ml-64"],
|
||||
div[class*="md:ml-64"] {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
html body > div.w-full.flex.flex-grow > div[class*="rtl:md:mr-64"],
|
||||
html body > div.w-full.flex > div[class*="rtl:md:mr-64"],
|
||||
body > div > div[class*="rtl:md:mr-64"],
|
||||
div[class*="rtl:md:mr-64"] {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<h2 class="text-xl font-semibold break-words">Storage Management</h2>
|
||||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||||
<x-ri-question-line class="size-4" />
|
||||
</button>
|
||||
<!-- Modal Backdrop -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
style="display: none;">
|
||||
</div>
|
||||
<!-- Modal -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.away="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="display: none;">
|
||||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<x-ri-hard-drive-2-line class="size-5 text-primary" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight text-base">
|
||||
Storage Management
|
||||
</h6>
|
||||
</div>
|
||||
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
||||
Manage the storage drives attached to your VPS. You can add, remove, and configure drives with different bus types, cache modes, and performance options.
|
||||
</p>
|
||||
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Bus Types:</span> SCSI (VirtIO SCSI) offers the best performance with IO thread support. VirtIO Block is also high-performance. IDE and SATA are available for compatibility.
|
||||
</p>
|
||||
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
||||
<span class="font-semibold text-base/90">Important:</span> The VM must be stopped to add, remove, or move drives. Total storage cannot exceed your plan allocation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-base/60">Manage virtual hard disks attached to your VPS. Add, configure, or remove storage drives.</p>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Storage Quota</h3>
|
||||
<p class="text-sm text-base/60 mt-1">
|
||||
{{ number_format($totalAllocatedStorage, 1) }} GB used of {{ number_format($maxAllowedStorage, 1) }} GB allocated
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button wire:click="loadStorageConfiguration"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
<x-ri-refresh-line class="size-4" wire:loading.class="animate-spin" wire:target="loadStorageConfiguration" />
|
||||
Refresh
|
||||
</button>
|
||||
@if($this->remainingStorage > 0)
|
||||
<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>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-neutral/30 rounded-full h-3 overflow-hidden">
|
||||
@php
|
||||
$usagePercent = $maxAllowedStorage > 0 ? min(100, ($totalAllocatedStorage / $maxAllowedStorage) * 100) : 0;
|
||||
$colorClass = $usagePercent < 70 ? 'bg-success' : ($usagePercent < 90 ? 'bg-warning' : 'bg-danger');
|
||||
@endphp
|
||||
<div class="{{ $colorClass }} h-3 rounded-full transition-all duration-500" style="width: {{ $usagePercent }}%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-xs text-base/50">
|
||||
<span>{{ number_format($this->remainingStorage, 1) }} GB remaining</span>
|
||||
<span>{{ number_format($usagePercent, 1) }}% used</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCSI Controller Configuration -->
|
||||
<div class="bg-background-secondary border border-neutral rounded-lg p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-lg font-semibold">SCSI Controller</h3>
|
||||
<div class="relative" x-data="{ open: false }" x-cloak>
|
||||
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
||||
<x-ri-question-line class="size-4" />
|
||||
</button>
|
||||
<!-- Modal Backdrop -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
@click="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
style="display: none;">
|
||||
</div>
|
||||
<!-- Modal -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.away="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
style="display: none;">
|
||||
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
||||
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-3 mb-4 pr-8">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
||||
<x-ri-cpu-line class="size-5 text-primary" />
|
||||
</div>
|
||||
<h6 class="font-sans text-lg font-semibold leading-tight text-base">
|
||||
SCSI Controller Types
|
||||
</h6>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-base/90">
|
||||
<p><strong>VirtIO SCSI / VirtIO SCSI Single:</strong> Best performance with paravirtualized drivers. Recommended for Linux and modern Windows.</p>
|
||||
<p><strong>LSI 53C895A / LSI 53C810:</strong> Emulated LSI controllers with wide compatibility.</p>
|
||||
<p><strong>MegaRAID SAS 8708EM2:</strong> Emulated MegaRAID controller.</p>
|
||||
<p><strong>VMware PVSCSI:</strong> VMware paravirtualized controller, useful for VMs migrated from VMware.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-base/60 mt-1">Select the SCSI controller type for SCSI drives.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<select wire:model.live="scsiControllerType"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($scsiControllerOptions as $option)
|
||||
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<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)
|
||||
<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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Drives List -->
|
||||
<div class="bg-background-secondary border border-neutral rounded-lg overflow-hidden">
|
||||
<div class="p-5 border-b border-neutral">
|
||||
<h3 class="text-lg font-semibold">Attached Drives</h3>
|
||||
<p class="text-sm text-base/60 mt-1">Manage the virtual hard disks connected to your VPS.</p>
|
||||
</div>
|
||||
|
||||
@if(count($drives) === 0)
|
||||
<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)
|
||||
<button wire:click="openAddDriveModal" class="mt-4 text-primary hover:text-primary/80 text-sm font-medium">
|
||||
Add your first drive →
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="divide-y divide-neutral">
|
||||
@foreach($drives as $driveKey => $drive)
|
||||
<div class="p-4 hover:bg-background/50 transition-colors">
|
||||
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<x-ri-hard-drive-2-fill class="size-5 text-primary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h4 class="font-semibold text-base">{{ $drive['label'] }}</h4>
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded bg-neutral/30 text-base/70">{{ $driveKey }}</span>
|
||||
@if($drive['ssd'])
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded bg-success/20 text-success">SSD</span>
|
||||
@endif
|
||||
@if($drive['iothread'])
|
||||
<span class="px-2 py-0.5 text-xs font-medium rounded bg-info/20 text-info">IO Thread</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-xs text-base/60">
|
||||
<span>{{ number_format($drive['size'], 1) }} GB</span>
|
||||
<span>Cache: {{ ucfirst($drive['cache']) }}</span>
|
||||
<span>AIO: {{ $drive['aio'] }}</span>
|
||||
@if($drive['discard'])
|
||||
<span class="text-success">Discard enabled</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
Move
|
||||
</button>
|
||||
<button wire:click="detachDrive('{{ $driveKey }}')"
|
||||
wire:confirm="Are you sure you want to detach this drive? The data will be preserved but the drive will no longer be accessible."
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-warning/50 bg-warning/10 text-warning hover:bg-warning/20 transition-colors">
|
||||
<x-ri-eject-line class="size-3.5" />
|
||||
Detach
|
||||
</button>
|
||||
<button wire:click="deleteDrive('{{ $driveKey }}')"
|
||||
wire:confirm="Are you sure you want to DELETE this drive? This action cannot be undone and all data will be permanently lost!"
|
||||
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>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Drive Modal -->
|
||||
@if($showAddDriveModal)
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto" x-data x-init="$el.querySelector('input')?.focus()">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" wire:click="closeAddDriveModal"></div>
|
||||
<div class="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl">
|
||||
<div class="flex items-center justify-between p-5 border-b border-neutral">
|
||||
<h3 class="text-lg font-semibold">Add New Drive</h3>
|
||||
<button wire:click="closeAddDriveModal" class="text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Bus Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Bus/Device Type</label>
|
||||
<select wire:model.live="newDriveBus"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($busTypes as $busKey => $busConfig)
|
||||
<option value="{{ $busKey }}">{{ $busConfig['label'] }} (max {{ $busConfig['max'] }} drives)</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="text-xs text-base/50 mt-1">
|
||||
@if($newDriveBus === 'scsi')
|
||||
SCSI provides best performance with IO thread support. Recommended for most use cases.
|
||||
@elseif($newDriveBus === 'virtio')
|
||||
VirtIO Block offers high performance but does not support SSD emulation.
|
||||
@elseif($newDriveBus === 'sata')
|
||||
SATA provides good compatibility with most operating systems.
|
||||
@else
|
||||
IDE is primarily for legacy compatibility.
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Size (GB)</label>
|
||||
<input type="number"
|
||||
wire:model="newDriveSize"
|
||||
min="1"
|
||||
max="{{ $this->remainingStorage }}"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
<p class="text-xs text-base/50 mt-1">Maximum available: {{ number_format($this->remainingStorage, 1) }} GB</p>
|
||||
</div>
|
||||
|
||||
<!-- Cache Mode -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Cache Mode</label>
|
||||
<select wire:model="newDriveCache"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($cacheOptions as $option)
|
||||
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Async IO -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Async IO Type</label>
|
||||
<select wire:model="newDriveAio"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($aioOptions as $option)
|
||||
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Options Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<!-- SSD Emulation -->
|
||||
<label class="flex items-center gap-2 cursor-pointer {{ !$this->newDriveSsdSupported ? 'opacity-50' : '' }}">
|
||||
<input type="checkbox"
|
||||
wire:model="newDriveSsd"
|
||||
class="rounded border-neutral text-primary focus:ring-primary"
|
||||
@if(!$this->newDriveSsdSupported) disabled @endif>
|
||||
<span class="text-sm">SSD Emulation</span>
|
||||
</label>
|
||||
|
||||
<!-- IO Thread -->
|
||||
<label class="flex items-center gap-2 cursor-pointer {{ !$this->newDriveIothreadSupported ? 'opacity-50' : '' }}">
|
||||
<input type="checkbox"
|
||||
wire:model="newDriveIothread"
|
||||
class="rounded border-neutral text-primary focus:ring-primary"
|
||||
@if(!$this->newDriveIothreadSupported) disabled @endif>
|
||||
<span class="text-sm">IO Thread</span>
|
||||
</label>
|
||||
|
||||
<!-- Discard/TRIM -->
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
wire:model="newDriveDiscard"
|
||||
class="rounded border-neutral text-primary focus:ring-primary">
|
||||
<span class="text-sm">Discard (TRIM)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if($status && $status->status !== 'stopped')
|
||||
<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 add new drives.</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 p-5 border-t border-neutral">
|
||||
<button wire:click="closeAddDriveModal"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="addDrive"
|
||||
wire:loading.attr="disabled"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
@if($status && $status->status !== 'stopped') disabled @endif>
|
||||
<x-loading target="addDrive" />
|
||||
<span wire:loading.remove wire:target="addDrive">Add Drive</span>
|
||||
<span wire:loading wire:target="addDrive">Adding...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Edit Drive Modal -->
|
||||
@if($showEditDriveModal && $editingDriveKey && isset($drives[$editingDriveKey]))
|
||||
@php $editDrive = $drives[$editingDriveKey]; @endphp
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" wire:click="closeEditDriveModal"></div>
|
||||
<div class="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl">
|
||||
<div class="flex items-center justify-between p-5 border-b border-neutral">
|
||||
<h3 class="text-lg font-semibold">Edit Drive Settings - {{ $editDrive['label'] }}</h3>
|
||||
<button wire:click="closeEditDriveModal" class="text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="p-3 rounded-lg bg-neutral/20 text-sm">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div><span class="text-base/50">Device:</span> {{ $editingDriveKey }}</div>
|
||||
<div><span class="text-base/50">Size:</span> {{ number_format($editDrive['size'], 1) }} GB</div>
|
||||
<div><span class="text-base/50">Bus Type:</span> {{ $busTypes[$editDrive['bus']]['label'] ?? ucfirst($editDrive['bus']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Mode -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Cache Mode</label>
|
||||
<select wire:model="editDriveCache"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($cacheOptions as $option)
|
||||
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Async IO -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Async IO Type</label>
|
||||
<select wire:model="editDriveAio"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($aioOptions as $option)
|
||||
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Options Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<!-- SSD Emulation -->
|
||||
<label class="flex items-center gap-2 cursor-pointer {{ !$this->editDriveSsdSupported ? 'opacity-50' : '' }}">
|
||||
<input type="checkbox"
|
||||
wire:model="editDriveSsd"
|
||||
class="rounded border-neutral text-primary focus:ring-primary"
|
||||
@if(!$this->editDriveSsdSupported) disabled @endif>
|
||||
<span class="text-sm">SSD Emulation</span>
|
||||
</label>
|
||||
|
||||
<!-- IO Thread -->
|
||||
<label class="flex items-center gap-2 cursor-pointer {{ !$this->editDriveIothreadSupported ? 'opacity-50' : '' }}">
|
||||
<input type="checkbox"
|
||||
wire:model="editDriveIothread"
|
||||
class="rounded border-neutral text-primary focus:ring-primary"
|
||||
@if(!$this->editDriveIothreadSupported) disabled @endif>
|
||||
<span class="text-sm">IO Thread</span>
|
||||
</label>
|
||||
|
||||
<!-- Discard/TRIM -->
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
wire:model="editDriveDiscard"
|
||||
class="rounded border-neutral text-primary focus:ring-primary">
|
||||
<span class="text-sm">Discard (TRIM)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if(!$this->editDriveSsdSupported)
|
||||
<p class="text-xs text-base/50">Note: SSD emulation is not available for {{ $busTypes[$editDrive['bus']]['label'] ?? ucfirst($editDrive['bus']) }} drives.</p>
|
||||
@endif
|
||||
|
||||
@if(!$this->editDriveIothreadSupported)
|
||||
<p class="text-xs text-base/50">Note: IO Thread is not available for {{ $busTypes[$editDrive['bus']]['label'] ?? ucfirst($editDrive['bus']) }} drives.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 p-5 border-t border-neutral">
|
||||
<button wire:click="closeEditDriveModal"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="saveDriveSettings"
|
||||
wire:loading.attr="disabled"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
||||
<x-loading target="saveDriveSettings" />
|
||||
<span wire:loading.remove wire:target="saveDriveSettings">Save Changes</span>
|
||||
<span wire:loading wire:target="saveDriveSettings">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Move Drive Modal -->
|
||||
@if($showMoveDriveModal && $movingDriveKey && isset($drives[$movingDriveKey]))
|
||||
@php $moveDrive = $drives[$movingDriveKey]; @endphp
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" wire:click="closeMoveDriveModal"></div>
|
||||
<div class="relative min-h-screen flex items-center justify-center p-4">
|
||||
<div class="relative w-full max-w-lg bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl">
|
||||
<div class="flex items-center justify-between p-5 border-b border-neutral">
|
||||
<h3 class="text-lg font-semibold">Move Drive - {{ $moveDrive['label'] }}</h3>
|
||||
<button wire:click="closeMoveDriveModal" class="text-base/50 hover:text-base transition-colors">
|
||||
<x-ri-close-line class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="p-3 rounded-lg bg-neutral/20 text-sm">
|
||||
<p><span class="text-base/50">Current Device:</span> {{ $movingDriveKey }} ({{ $busTypes[$moveDrive['bus']]['label'] ?? ucfirst($moveDrive['bus']) }})</p>
|
||||
<p class="mt-1"><span class="text-base/50">Size:</span> {{ number_format($moveDrive['size'], 1) }} GB</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 p-3 rounded-lg bg-info/10 border border-info/30 text-info text-sm">
|
||||
<x-ri-information-line class="size-4 flex-shrink-0" />
|
||||
<span>Moving a drive changes its bus type. This may require reinstalling drivers in the guest OS.</span>
|
||||
</div>
|
||||
|
||||
<!-- Target Bus Type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Target Bus Type</label>
|
||||
<select wire:model="moveDriveTargetBus"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-neutral bg-background focus:border-primary focus:ring-1 focus:ring-primary transition-colors">
|
||||
@foreach($busTypes as $busKey => $busConfig)
|
||||
<option value="{{ $busKey }}">{{ $busConfig['label'] }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if($status && $status->status !== 'stopped')
|
||||
<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 move drives.</span>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 p-5 border-t border-neutral">
|
||||
<button wire:click="closeMoveDriveModal"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg border border-neutral bg-background hover:bg-background-secondary transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button wire:click="moveDrive"
|
||||
wire:loading.attr="disabled"
|
||||
class="px-4 py-2 text-sm font-medium rounded-lg bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
@if($status && $status->status !== 'stopped') disabled @endif>
|
||||
<x-loading target="moveDrive" />
|
||||
<span wire:loading.remove wire:target="moveDrive">Move Drive</span>
|
||||
<span wire:loading wire:target="moveDrive">Moving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-proxmox::layout>
|
||||
Reference in New Issue
Block a user