Files
Virtnet.bond/extensions/Servers/Proxmox/Livewire/Component.php
Joseph.Rawlings 1a96bfdee0 fix: make server icon customization available on all pages
- Moved icon modal properties and methods from Overview.php to base Component.php
- All Proxmox Livewire components now inherit icon customization functionality
- Removed view restriction so modal works on all server pages (not just overview)
- Icon button is now clickable and functional on Network, Storage, Backups, etc.
2025-12-06 09:16:58 -05:00

334 lines
10 KiB
PHP

<?php
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;
class Component extends BaseComponent
{
/**
* The server instance.
*/
public Server $server;
/**
* Latest VM status snapshot shared with all Proxmox views.
*/
public ?object $status = null;
/**
* Controls dynamic polling interval (shared with layout header).
*/
public bool $fastPolling = false;
/**
* Server icon customization properties
*/
public bool $showIconModal = false;
public string $selectedIcon = '';
public string $selectedIconColor = '';
public string $iconSearchQuery = ''; // Legacy property for stale sessions
/**
* Mount the component with the server.
*/
public function mount(Server $server): void
{
$this->server = $server;
if ($server->user_id != auth()->id()) {
abort(403);
}
// Load icon customization
$this->selectedIcon = $server->user_set_icon ?? '';
$this->selectedIconColor = $server->user_set_icon_color ?? '';
$this->loadVmStatus();
}
/**
* Open the icon customization modal.
*/
public function openIconModal(): void
{
$this->showIconModal = true;
}
/**
* Close the icon customization modal.
*/
public function closeIconModal(): void
{
$this->showIconModal = false;
}
/**
* Save the selected icon and color.
*/
public function saveIcon(string $icon, string $color): void
{
$this->server->update([
'user_set_icon' => $icon ?: null,
'user_set_icon_color' => $color ?: null,
]);
$this->selectedIcon = $icon;
$this->selectedIconColor = $color;
$this->showIconModal = false;
$this->notify('Server icon updated successfully', 'success');
}
/**
* Reset the server icon to default.
*/
public function resetIcon(): void
{
$this->server->update([
'user_set_icon' => null,
'user_set_icon_color' => null,
]);
$this->selectedIcon = '';
$this->selectedIconColor = '';
$this->showIconModal = false;
$this->notify('Server icon reset to default', 'success');
}
/**
* Perform a VM lifecycle action (start/stop/etc).
*/
public function do(string $action): void
{
if (!in_array($action, ['start', 'shutdown', 'reboot', 'stop', 'reset'], true)) {
$this->notify('Invalid action', 'error');
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(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/' . $action,
'post',
['vmid' => $this->server->vm_id]
);
if ($response->successful()) {
$this->notify('Action performed successfully', 'success');
if (property_exists($this, 'fastPolling')) {
$this->fastPolling = true;
}
} else {
$message = $response->json()['errors'][0]['message'] ?? 'Unknown error';
$this->notify('Action failed: ' . $message, 'error');
}
} catch (\Throwable $e) {
$this->notify('Action failed: ' . $e->getMessage(), 'error');
} finally {
$this->loadVmStatus();
}
}
/**
* 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).
* Uses the same calculation method as the Storage page.
*/
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).
* Uses regex patterns matching the Storage page implementation.
*/
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;
}
/**
* Extract disk size in GB from a disk configuration string.
* Matches the Storage page parseDiskString logic.
*/
protected function extractDiskSize(string $value): float
{
// Example: cephpool:vm-100-disk-0,size=32G,cache=none,ssd=1
$parts = explode(',', $value);
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':
return $size / (1024 * 1024);
case 'M':
return $size / 1024;
case 'G':
case '':
return $size;
case 'T':
return $size * 1024;
}
}
}
return 0;
}
/**
* Extract size from unused disk by querying Proxmox storage content.
* Matches the Storage page parseUnusedDiskString logic.
*/
protected function extractUnusedDiskSize(string $value): float
{
// Unused disks typically just have the volume reference
// Example: cephpool:vm-100-disk-1
$volume = trim($value);
// Extract storage name from volume (format: storage:volume-name)
$storageName = 'cephpool';
if (str_contains($volume, ':')) {
[$storageName, ] = explode(':', $volume, 2);
}
try {
$proxmox = new Proxmox;
$storageContent = $proxmox->request(
'/nodes/' . $this->server->node->name . '/storage/' . $storageName . '/content',
data: ['vmid' => $this->server->vm_id]
);
if (isset($storageContent['data']) && is_array($storageContent['data'])) {
foreach ($storageContent['data'] as $item) {
if (isset($item['volid']) && $item['volid'] === $volume) {
// Size is in bytes
if (isset($item['size'])) {
return round($item['size'] / (1024 * 1024 * 1024), 2);
}
break;
}
}
}
} catch (\Throwable $e) {
// If we can't fetch size, return 0
}
return 0;
}
/**
* Refresh the cached VM status from Proxmox.
*/
public function loadVmStatus(): void
{
try {
$proxmox = new Proxmox;
$data = $proxmox->request(
'/nodes/' . $this->server->node->name . '/qemu/' . $this->server->vm_id . '/status/current'
)->json()['data'] ?? null;
$this->status = $data ? (object) $data : null;
} catch (\Throwable $e) {
$this->status = null;
}
}
}