Enhance Proxmox views with improved loading states and UI updates

- Updated serverplaceholder.blade.php to enhance loading skeletons for server details.
- Added navigation breadcrumbs to statistics, subdomain, and system views for better user experience.
- Improved loading indicators for CPU, network, memory, and disk charts in statistics.blade.php.
- Refined storage.blade.php layout with section dividers and improved drive display, including bus type indicators and quota usage.
- Enhanced VNC and Xterm popups with a new header design, loading animations, and reconnect functionality.
- Added error handling for VNC and Xterm connections to inform users when unable to connect.
This commit is contained in:
2026-02-24 19:51:46 -05:00
parent af62a16354
commit 16cd5f05de
40 changed files with 1423 additions and 475 deletions

View File

@@ -53,9 +53,26 @@
<x-ri-pencil-fill class="size-2.5 text-white" />
</div>
</div>
<span class="text-2xl font-bold">
{{ $server->hostname }}
</span>
<div class="flex items-center gap-3">
<span class="text-2xl font-bold">
{{ $server->hostname }}
</span>
@if($vmStatus === 'running')
<span class="relative flex h-2.5 w-2.5" title="Server is running">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-success"></span>
</span>
@elseif($vmStatus === 'stopped')
<span class="relative flex h-2.5 w-2.5" title="Server is stopped">
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-warning"></span>
</span>
@elseif($vmStatus)
<span class="relative flex h-2.5 w-2.5" title="Status: {{ $vmStatus }}">
<span class="animate-pulse absolute inline-flex h-full w-full rounded-full bg-info opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-info"></span>
</span>
@endif
</div>
</button>
<!-- Modal -->
@@ -1214,12 +1231,20 @@
},
body: JSON.stringify({ icon: this.icon, color: this.color, prefix: this.iconPrefix })
})
.then(r => r.json())
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
if (data.success) {
this.message = 'Icon saved!';
setTimeout(() => { this.closeModal(); location.reload(); }, 800);
} else {
this.message = 'Failed to save icon.';
}
})
.catch(() => {
this.message = 'Network error. Please try again.';
});
}
}
@@ -1453,7 +1478,8 @@
<div class="hidden lg:flex flex-row gap-2 ml-auto justify-end items-center">
<x-button.primary wire:click="do('start')" class="disabled:cursor-not-allowed"
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'stopped'">
:disabled="$vmStatus !== 'stopped'"
title="Start the virtual machine">
<x-loading target="do('start')" />
<x-ri-play-fill class="size-4" />
<div wire:loading.remove wire:target="do('start')">
@@ -1463,7 +1489,8 @@
<div x-show="isAdvancedView" x-transition>
<x-button.secondary wire:click="do('shutdown')" class="text-nowrap disabled:cursor-not-allowed"
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
:disabled="$vmStatus !== 'running'"
title="Graceful shutdown via ACPI signal">
<x-loading target="do('shutdown')" />
<x-ri-stop-fill class="size-4" />
<div wire:loading.remove wire:target="do('shutdown')">
@@ -1473,7 +1500,9 @@
</div>
<div x-show="isAdvancedView" x-transition>
<x-button.secondary wire:click="do('reboot')" class="text-nowrap disabled:cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'"
title="Graceful reboot via ACPI signal">
<x-loading target="do('reboot')" />
<x-ri-loop-left-line class="size-4" />
<div wire:loading.remove wire:target="do('reboot')">
@@ -1482,7 +1511,9 @@
</x-button.secondary>
</div>
<x-button.danger wire:click="do('stop')" class="text-nowrap disabled:cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'"
title="Force power off — unsaved data may be lost">
<x-loading target="do('stop')" />
<x-ri-shut-down-line class="size-4" />
<div wire:loading.remove wire:target="do('stop')">
@@ -1495,7 +1526,8 @@
<div class="flex flex-col gap-2 w-full">
<x-button.primary wire:click="do('start')" class="w-full disabled:cursor-not-allowed"
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'stopped'">
:disabled="$vmStatus !== 'stopped'"
title="Start the virtual machine">
<x-loading target="do('start')" />
<x-ri-play-fill class="size-4" />
<div wire:loading.remove wire:target="do('start')">
@@ -1505,7 +1537,8 @@
<div x-show="isAdvancedView" x-transition>
<x-button.secondary wire:click="do('shutdown')" class="w-full text-nowrap disabled:cursor-not-allowed"
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
:disabled="$vmStatus !== 'running'"
title="Graceful shutdown via ACPI signal">
<x-loading target="do('shutdown')" />
<x-ri-stop-fill class="size-4" />
<div wire:loading.remove wire:target="do('shutdown')">
@@ -1515,7 +1548,9 @@
</div>
<div x-show="isAdvancedView" x-transition>
<x-button.secondary wire:click="do('reboot')" class="w-full text-nowrap disabled:cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'"
title="Graceful reboot via ACPI signal">
<x-loading target="do('reboot')" />
<x-ri-loop-left-line class="size-4" />
<div wire:loading.remove wire:target="do('reboot')">
@@ -1525,7 +1560,9 @@
</div>
<x-button.danger wire:click="do('stop')"
class="text-nowrap w-full disabled:cursor-not-allowed"
:disabled="$vmStatus !== 'running'">
wire:loading.attr="disabled" wire:loading.class="opacity-50 cursor-not-allowed"
:disabled="$vmStatus !== 'running'"
title="Force power off — unsaved data may be lost">
<x-loading target="do('stop')" />
<x-ri-shut-down-line class="size-4" />
<div wire:loading.remove wire:target="do('stop')">
@@ -1948,6 +1985,52 @@
background: color-mix(in srgb, var(--color-neutral) 30%, transparent);
margin: 4px 0;
}
/* 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;
}
}
/* Loading skeleton animation */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-loading {
background: linear-gradient(90deg,
transparent 25%,
color-mix(in srgb, var(--color-base) 8%, transparent) 50%,
transparent 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
</style>
<div class="hidden lg:block">
@@ -3300,4 +3383,108 @@ window.saveServerIcon = function() {
}
});
})();
// Smooth scroll to top on Livewire navigation (only for Proxmox pages)
if (!window._proxmoxScrollListenerAdded) {
window._proxmoxScrollListenerAdded = true;
document.addEventListener('livewire:navigated', () => {
// Only scroll to top if still on a Proxmox page
if (document.querySelector('.proxmox-page-wrapper')) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
}
// Keyboard shortcuts for power actions (deduplicated)
if (!window._proxmoxKeyboardListenerAdded) {
window._proxmoxKeyboardListenerAdded = true;
document.addEventListener('keydown', (e) => {
// Only if on a Proxmox page and no input is focused
if (!document.querySelector('.proxmox-page-wrapper')) return;
if (document.activeElement.tagName === 'INPUT' ||
document.activeElement.tagName === 'TEXTAREA' ||
document.activeElement.tagName === 'SELECT' ||
document.activeElement.isContentEditable) return;
// Ctrl+Shift+S → Start server
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
e.preventDefault();
const startBtn = document.querySelector('[wire\\:click="start"]');
if (startBtn && !startBtn.disabled) startBtn.click();
}
// Ctrl+Shift+K → Power off (force stop) — avoids Ctrl+Shift+P browser conflict
if (e.ctrlKey && e.shiftKey && e.key === 'K') {
e.preventDefault();
const stopBtn = document.querySelector('[wire\\:click="kill"]');
if (stopBtn && !stopBtn.disabled) stopBtn.click();
}
});
}
</script>
<!-- Back to Top Button -->
<div x-data="{ showBackToTop: false, _scrollHandler: null }"
x-init="_scrollHandler = () => { showBackToTop = window.scrollY > 400 }; window.addEventListener('scroll', _scrollHandler, { passive: true })"
x-destroy="window.removeEventListener('scroll', _scrollHandler)"
class="fixed bottom-20 right-4 z-50">
<button x-show="showBackToTop"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
@click="window.scrollTo({ top: 0, behavior: 'smooth' })"
class="flex items-center justify-center w-10 h-10 rounded-full bg-primary/90 text-white shadow-lg hover:bg-primary hover:shadow-xl transition-all duration-200 backdrop-blur-sm"
aria-label="Back to top">
<svg class="size-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/>
</svg>
</button>
</div>
<!-- Toast Notification System -->
<div x-data="{
toasts: [],
addToast(message, type = 'success') {
const id = Date.now();
this.toasts.push({ id, message, type });
setTimeout(() => this.removeToast(id), 3000);
},
removeToast(id) {
this.toasts = this.toasts.filter(t => t.id !== id);
}
}"
@toast.window="addToast($event.detail.message, $event.detail.type || 'success')"
class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<template x-for="toast in toasts" :key="toast.id">
<div x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-2 scale-95"
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
x-transition:leave-end="opacity-0 translate-y-2 scale-95"
class="pointer-events-auto flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg border backdrop-blur-sm text-sm font-medium"
:class="{
'bg-success/10 border-success/30 text-success': toast.type === 'success',
'bg-error/10 border-error/30 text-error': toast.type === 'error',
'bg-warning/10 border-warning/30 text-warning': toast.type === 'warning',
'bg-info/10 border-info/30 text-info': toast.type === 'info'
}"
@click="removeToast(toast.id)">
<template x-if="toast.type === 'success'">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</template>
<template x-if="toast.type === 'error'">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</template>
<template x-if="toast.type === 'warning'">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
</template>
<template x-if="toast.type === 'info'">
<svg class="size-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</template>
<span x-text="toast.message"></span>
</div>
</template>
</div>