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

@@ -8,23 +8,19 @@
<script data-navigate-track>
// Define copyToClipboard only if not already defined (prevent duplicates on navigation)
if (typeof window.copyToClipboard === 'undefined') {
window.copyToClipboard = function(text, buttonEl) {
window.copyToClipboard = function(text, el) {
navigator.clipboard.writeText(text).then(() => {
// Change button style & text
const originalText = buttonEl.innerText;
buttonEl.innerText = "Copied!";
buttonEl.classList.remove("bg-blue-600", "hover:bg-blue-700");
buttonEl.classList.add("bg-green-600", "hover:bg-green-700");
// Revert after 2s
setTimeout(() => {
buttonEl.innerText = originalText;
buttonEl.classList.remove("bg-green-600", "hover:bg-green-700");
buttonEl.classList.add("bg-blue-600", "hover:bg-blue-700");
}, 2000);
}).catch(err => {
console.error("Failed to copy: ", err);
});
// Show brief visual feedback on the button
const icon = el.querySelector('svg');
if (icon) {
icon.classList.add('text-success');
setTimeout(() => icon.classList.remove('text-success'), 1500);
}
// Dispatch a custom event for toast notification
window.dispatchEvent(new CustomEvent('toast', {
detail: { message: 'Copied to clipboard', type: 'success' }
}));
}).catch(() => {});
};
}
</script>
@@ -187,10 +183,10 @@
</div>
@php
$cpuCores = max(1, (int)($status->cpus ?? 1));
$cpuUsagePercent = round(max(0, ($status->cpu ?? 0) * 100), 1);
$memoryTotal = (int)($status->maxmem ?? 0);
$memoryUsed = (int)($status->mem ?? 0);
$cpuCores = max(1, (int)($status?->cpus ?? 1));
$cpuUsagePercent = round(max(0, ($status?->cpu ?? 0) * 100), 1);
$memoryTotal = (int)($status?->maxmem ?? 0);
$memoryUsed = (int)($status?->mem ?? 0);
$memoryPercent = $memoryTotal > 0 ? round(($memoryUsed / $memoryTotal) * 100, 1) : 0;
$manualSnapshotLimit = 5;
$manualSnapshotUsed = $server->statistics()->where('is_automatic', false)->count();
@@ -228,7 +224,7 @@
[
'label' => 'CPU Allocation',
'used_percent' => $cpuUsagePercent,
'usage_summary' => number_format(($status->cpu ?? 0) * $cpuCores, 2) . ' / ' . $cpuCores . ' vCPU',
'usage_summary' => number_format(($status?->cpu ?? 0) * $cpuCores, 2) . ' / ' . $cpuCores . ' vCPU',
'entitlement_label' => $cpuCores . ' vCPU entitlement',
'description' => 'Live hypervisor usage sampled directly from the cluster.',
'gradient' => 'linear-gradient(90deg, #38bdf8 0%, #6366f1 100%)',
@@ -274,7 +270,7 @@
};
$rateScale = 5 * 1024 * 1024; // 5 MiB/s caps the bar width
$aggregateRate = ($networkRates['in_rate'] ?? 0) + ($networkRates['out_rate'] ?? 0);
$totalNetworkBytes = (int) (($status->netin ?? 0) + ($status->netout ?? 0));
$totalNetworkBytes = (int) (($status?->netin ?? 0) + ($status?->netout ?? 0));
$totalNetworkFormatted = number_format($totalNetworkBytes / 1024 / 1024, 2);
$lastSampleTimestamp = now()->toIso8601String();
@endphp
@@ -388,7 +384,15 @@
<div class="">
<div>
<div style="background-image: url('https://billing.virtnet.bond/assets/images/main.png'); background-size: 100%; height: 5.5vh;" class="flex flex-col p-4 rounded-t-lg border-t border-x border-neutral"></div>
<div class="flex items-center gap-3 p-4 rounded-t-lg border-t border-x border-neutral bg-gradient-to-r from-primary/10 via-primary/5 to-transparent">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/15">
<x-ri-server-line class="size-5 text-primary" />
</div>
<div>
<h3 class="text-base font-semibold">Server Details</h3>
<p class="text-xs text-base/50">Configuration and access information</p>
</div>
</div>
<!-- Status Section -->
<div class="bg-background-secondary border-x border-neutral p-4 space-y-4 rounded-m-lg">
@@ -430,7 +434,7 @@
<div class="space-y-2">
<div class="flex items-center gap-2 border border-neutral rounded-md bg-background px-3 py-2 text-sm text-base/80">
<span class="truncate flex-1">
{{ $server->hostname ?? $status->name ?? 'N/A' }}
{{ $server->hostname ?? $status?->name ?? 'N/A' }}
</span>
<button wire:click="startEditingHostname" type="button"
wire:loading.attr="disabled"
@@ -514,28 +518,14 @@
<div class="space-y-2">
<label class="text-sm font-semibold text-base/80">Primary IPv4 Address</label>
<div class="space-y-2">
<div class="flex items-center gap-2 border border-neutral rounded-md bg-background px-3 py-2 text-sm text-base/80"
x-data="{
copied: false,
copyText: '{{ $server->primaryIpv4->ip }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}">
<div class="flex items-center gap-2 border border-neutral rounded-md bg-background px-3 py-2 text-sm text-base/80 group">
<span class="truncate flex-1">
{{ $server->primaryIpv4->ip }}
</span>
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 ml-2 relative inline-flex items-center justify-center w-4 h-4"
<button @click="copyToClipboard('{{ $server->primaryIpv4->ip }}', $el)" type="button"
class="text-base/50 hover:text-primary transition-all p-0.5 rounded"
aria-label="Copy IPv4 Address">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
<x-ri-file-copy-line class="size-4" />
</button>
</div>
</div>
@@ -547,28 +537,14 @@
<div class="space-y-2">
<label class="text-sm font-semibold text-base/80">Primary IPv6 Address</label>
<div class="space-y-2">
<div class="flex items-center gap-2 border border-neutral rounded-md bg-background px-3 py-2 text-sm text-base/80"
x-data="{
copied: false,
copyText: '{{ $server->primaryIpv6->ip }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}">
<div class="flex items-center gap-2 border border-neutral rounded-md bg-background px-3 py-2 text-sm text-base/80 group">
<span class="truncate flex-1">
{{ $server->primaryIpv6->ip }}
</span>
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 ml-2 relative inline-flex items-center justify-center w-4 h-4"
<button @click="copyToClipboard('{{ $server->primaryIpv6->ip }}', $el)" type="button"
class="text-base/50 hover:text-primary transition-all p-0.5 rounded"
aria-label="Copy IPv6 Address">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
<x-ri-file-copy-line class="size-4" />
</button>
</div>
</div>
@@ -856,14 +832,20 @@
<div class="space-y-6">
<!-- DNS Section Wrapper -->
@if($server->primaryIpv6)
<div>
<div style="background-image: url('https://billing.virtnet.bond/assets/images/dns.jpeg'); background-size: 100%; height: 5.5vh;" class="flex flex-col p-4 rounded-t-lg border-t border-x border-neutral "></div>
<div class="flex items-center gap-3 p-4 rounded-t-lg border-t border-x border-neutral bg-gradient-to-r from-info/10 via-info/5 to-transparent">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-info/15">
<x-ri-global-line class="size-5 text-info" />
</div>
<div>
<h3 class="text-base font-semibold">Domain Name System (DNS)</h3>
<p class="text-xs text-base/50">IPv6 DNS records for your server</p>
</div>
</div>
<div class="flex flex-col gap-4 bg-background-secondary hover:bg-background-secondary/80 border-x border-neutral p-4 ">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-lg font-semibold break-words">
Domain Name System (DNS)
</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" />
@@ -917,126 +899,38 @@
</div>
</div>
<!-- Explanation -->
<!-- DNS Records -->
<p class="text-sm text-base/60 mb-1">IPv6-based DNS records for <span class="font-mono text-xs">{{ $server->primaryIpv6->ip }}</span></p>
<div class="flex items-center gap-2">
<div class="text-md font-semibold text-base/60">
<p>IPv6 DNS: {{ $server->primaryIpv6->ip }}</p>
<!-- Virtnet -->
<div class="flex items-center space-x-2 ml-8">
<span>
{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host' }}
<small>(Internally Provided)</small>
</span>
<div x-data="{
copied: false,
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host' }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}" class="flex items-center">
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 relative inline-flex items-center justify-center ml-2">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
</button>
<div class="divide-y divide-neutral rounded-lg border border-neutral overflow-hidden bg-background">
@foreach([
['label' => 'Virtnet DNS', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host', 'provider' => 'Internally Provided'],
['label' => 'nip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io', 'provider' => 'nip.io', 'url' => 'https://nip.io'],
['label' => 'sslip.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io', 'provider' => 'sslip.io', 'url' => 'https://sslip.io'],
['label' => 'backname.io', 'host' => str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io', 'provider' => 'backname.io', 'url' => 'https://backname.io'],
] as $dns)
<div class="flex items-center justify-between px-3 py-2.5 group hover:bg-background-secondary/50 transition-colors">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium font-mono truncate">{{ $dns['host'] }}</span>
</div>
<span class="text-xs text-base/50">
@if(isset($dns['url']))
<a href="{{ $dns['url'] }}" target="_blank" class="hover:text-primary transition-colors">{{ $dns['provider'] }} &nearr;</a>
@else
{{ $dns['provider'] }}
@endif
</span>
</div>
<button type="button" @click="copyToClipboard('{{ $dns['host'] }}', $el)"
class="opacity-0 group-hover:opacity-100 text-base/50 hover:text-primary transition-all p-1.5 rounded-md hover:bg-primary/10"
aria-label="Copy {{ $dns['label'] }}">
<x-ri-file-copy-line class="size-4" />
</button>
</div>
<!-- nip.io -->
<div class="flex items-center space-x-2 ml-8">
<span>
{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io' }}
<small>(Hosted by <a href="https://nip.io" target="_blank">nip.io</a>)</small>
</span>
<div x-data="{
copied: false,
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io' }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}" class="flex items-center">
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 relative inline-flex items-center justify-center ml-2">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
</button>
</div>
</div>
<!-- sslip.io -->
<div class="flex items-center space-x-2 ml-8">
<span>
{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io' }}
<small>(Hosted by <a href="https://sslip.io" target="_blank">sslip.io</a>)</small>
</span>
<div x-data="{
copied: false,
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io' }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}" class="flex items-center">
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 relative inline-flex items-center justify-center ml-2">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
</button>
</div>
</div>
<!-- backname.io -->
<div class="flex items-center space-x-2 ml-8">
<span>
{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io' }}
<small>(Hosted by <a href="https://backname.io" target="_blank">backname.io</a>)</small>
</span>
<div x-data="{
copied: false,
copyText: '{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io' }}',
copyToClipboard() {
navigator.clipboard.writeText(this.copyText);
this.copied = true;
setTimeout(() => this.copied = false, 3000);
}
}" class="flex items-center">
<button @click="copyToClipboard()" type="button"
class="text-base/70 hover:text-base/60 relative inline-flex items-center justify-center ml-2">
<span x-show="copied" x-transition class="absolute">
<x-ri-check-fill class="inline size-4 text-success stroke-current" />
</span>
<span x-show="!copied" x-transition class="absolute">
<x-ri-file-copy-fill class="inline size-4" />
</span>
</button>
</div>
</div>
</div>
</div>
</div></div>
@endforeach
</div>
</div></div>
@@ -1059,6 +953,7 @@
</p></div></div>
<!-- End DNS Section Wrapper -->
@endif
<!-- Notes Section -->
<div id="notes-section" class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral rounded-xl p-6 flex flex-col" x-data="{ showExportErrorModal: false }" x-cloak>
@@ -1232,13 +1127,18 @@
@if(stripos($server->os?->name ?? '', 'Windows') !== false)
<div class="">
<div style="background-image: url('https://billing.virtnet.bond/assets/images/windows.jpg'); background-size: 100%; height: 5.5vh;" class="flex flex-col p-4 rounded-t-lg border border-neutral"></div>
<div class="flex items-center gap-3 p-4 rounded-t-lg border-t border-x border-neutral bg-gradient-to-r from-info/10 via-info/5 to-transparent">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-info/15">
<x-ri-windows-line class="size-5 text-info" />
</div>
<div>
<h3 class="text-base font-semibold">Windows Server</h3>
<p class="text-xs text-base/50">Installation and configuration notes</p>
</div>
</div>
<div class="bg-info/10 border border-info/30 p-4 rounded-b-lg">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-lg font-semibold break-words">
Windows Server Installations
</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" />
@@ -1470,11 +1370,12 @@
});
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 B';
if (!bytes || bytes <= 0 || !isFinite(bytes)) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
@@ -2259,6 +2160,7 @@
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
window.downloadText = function(textContent, filename) {
@@ -2271,6 +2173,7 @@
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const sanitizeCellText = (value) => {
@@ -2286,10 +2189,11 @@
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
if (!bytes || bytes <= 0 || !isFinite(bytes)) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
if (i < 0 || i >= sizes.length) return '0 B';
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};