Files
Virtnet.bond/extensions/Servers/Proxmox/views/overview.blade.php

1978 lines
121 KiB
PHP

<x-proxmox::layout
:server="$server"
view="overview"
:status="$status"
:status-polling="true"
:status-polling-interval="'2s'"
>
@if(!$status)
{{-- Error state when status cannot be loaded --}}
<x-proxmox::ui.alert variant="danger" icon="ri-error-warning-fill" class="mt-8">
<x-slot name="title">Unable to Load Server Status</x-slot>
<p class="text-gray-600 dark:text-gray-400 mb-3">Could not retrieve status from hypervisor:</p>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 mb-3 space-y-1 text-sm">
<li>Temporary connectivity issues with the Proxmox cluster</li>
<li>The VM may be in an intermediate state</li>
<li>Network timeout while fetching data</li>
</ul>
<x-proxmox::ui.button wire:click="loadVmStatus" icon="ri-refresh-line">
Retry
</x-proxmox::ui.button>
</x-proxmox::ui.alert>
@else
<script>
function copyToClipboard(text, buttonEl) {
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);
});
}
</script>
<div class="rounded-lg border-l-4 border-blue-500 bg-blue-50 p-3 mb-4 dark:bg-blue-950 dark:border-blue-400"
x-data="{
isCollapsed: localStorage.getItem('overview-notice-collapsed') === 'true',
isAdvancedView: localStorage.getItem('proxmox-view-mode') === 'advanced',
toggle() {
this.isCollapsed = !this.isCollapsed;
localStorage.setItem('overview-notice-collapsed', this.isCollapsed);
}
}"
x-init="
isAdvancedView = localStorage.getItem('proxmox-view-mode') === 'advanced';
window.addEventListener('proxmox-view-mode-changed', (e) => {
isAdvancedView = e.detail.isAdvancedView;
});
"
x-show="isAdvancedView"
x-transition>
<div class="flex items-start gap-2">
<x-ri-information-line class="size-4 text-blue-600 flex-shrink-0 mt-0.5" />
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Cloud-init Configuration</h3>
<button @click="toggle()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<x-ri-arrow-down-s-line x-bind:class="isCollapsed ? 'size-4 rotate-180' : 'size-4'" />
</button>
</div>
<div x-show="!isCollapsed" x-transition class="mt-1">
<p class="text-xs text-gray-600 dark:text-gray-300">
<strong>Hostname, password, and networking</strong> are configured via <strong>cloud-init</strong>. Manual config required for:
</p>
<ul class="text-xs text-gray-600 dark:text-gray-300 mt-1 ml-4 list-disc">
<li>ISO installations</li>
<li>Manual OS configuration</li>
<li>Windows (no cloud-init)</li>
</ul>
<p class="text-xs text-gray-600 dark:text-gray-300 mt-1">
You will need to <strong>manually configure networking, security settings, and hostname</strong> within the operating system, as cloud-init will not be available in these scenarios.
</p>
</div>
</div>
</div>
</div>
<div class="flex items-center" wire:poll.{{ $fastPolling ? '2s' : '30s' }}="checkStatus"
wire:key="{{ $fastPolling ? 'fast' : 'slow' }}">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-lg font-semibold">Server Details</h2>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:text-gray-500">
<x-ri-question-line class="size-4" />
</button>
<!-- Modal Backdrop -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
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-[60] bg-gray-900 bg-opacity-50"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-5">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:text-gray-500">
<x-ri-close-line class="size-5" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-100 dark:bg-blue-900">
<x-ri-server-line class="size-4 text-blue-600 dark:text-blue-400" />
</div>
<h6 class="text-base font-semibold text-gray-800 dark:text-gray-100">Server Details</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
VM information: status, resources, network config.
</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Status:</span> Power state with real-time updates.</p>
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Resources:</span> CPU, memory, disk, network stats.</p>
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Network:</span> IP addresses and interface info.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
/* TailAdmin Progress Bar Styles */
progress {
border-radius: 8px;
background-color: #f1f5f9;
border: none;
width: 100%;
overflow: hidden;
height: 8px;
}
.dark progress {
background-color: #334155;
}
progress[value]::-webkit-progress-bar {
background-color: #f1f5f9;
border-radius: 8px;
}
.dark progress[value]::-webkit-progress-bar {
background-color: #334155;
}
progress[value]#memory::-webkit-progress-value {
background: linear-gradient(90deg, #22c55e 0%, #10b981 100%);
border-radius: 8px;
}
progress[value]#memory::-moz-progress-bar {
background: linear-gradient(90deg, #22c55e 0%, #10b981 100%);
border-radius: 8px;
}
progress[value]#cpu::-webkit-progress-value {
background: linear-gradient(90deg, #465fff 0%, #818cf8 100%);
border-radius: 8px;
}
progress[value]#cpu::-moz-progress-bar {
background: linear-gradient(90deg, #465fff 0%, #818cf8 100%);
border-radius: 8px;
}
.formRight select {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 0.625rem 1rem;
font-size: 0.875rem;
line-height: 1.25rem;
outline: none;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
transition: all 0.15s ease;
}
.dark .formRight select {
background-color: #1e293b;
border-color: #334155;
}
.formRight select:focus {
outline: none;
border-color: #465fff;
box-shadow: 0 0 0 3px rgba(70, 95, 255, 0.1);
}
</style>
</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);
$memoryPercent = $memoryTotal > 0 ? round(($memoryUsed / $memoryTotal) * 100, 1) : 0;
$manualSnapshotLimit = 5;
$manualSnapshotUsed = $server->statistics()->where('is_automatic', false)->count();
$manualSnapshotPercent = $manualSnapshotLimit > 0
? round(($manualSnapshotUsed / $manualSnapshotLimit) * 100, 1)
: 0;
$badgeForPercent = function (float $value) {
if ($value >= 100) {
return ['label' => 'Over entitlement', 'class' => 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900'];
}
if ($value >= 85) {
return ['label' => 'Watchlist', 'class' => 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-900'];
}
return ['label' => 'Healthy', 'class' => 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-900'];
};
$cpuBadge = $badgeForPercent($cpuUsagePercent);
$memoryBadge = $badgeForPercent($memoryPercent);
$reportedUptime = $this->status && isset($this->status->uptime)
? $this->formatSeconds($this->status->uptime)
: 'N/A';
$powerStats = $this->powerEventStats ?? [
'starts' => 0,
'shutdowns' => 0,
'reboots' => 0,
'uptime_percent' => 0,
'samples' => 0,
];
$resourceAllocations = [
[
'label' => 'CPU Allocation',
'used_percent' => $cpuUsagePercent,
'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%)',
'badge' => $cpuBadge,
],
[
'label' => 'Memory Allocation',
'used_percent' => $memoryPercent,
'usage_summary' => ($memoryTotal > 0
? \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / ' . \Illuminate\Support\Number::fileSize(bytes: $memoryTotal)
: \Illuminate\Support\Number::fileSize(bytes: $memoryUsed) . ' / N/A'),
'entitlement_label' => $memoryTotal > 0
? \Illuminate\Support\Number::fileSize(bytes: $memoryTotal) . ' entitlement'
: 'Entitlement unavailable',
'description' => 'Calculated from the VPS\'s provisioned RAM.',
'gradient' => 'linear-gradient(90deg, #34d399 0%, #10b981 100%)',
'badge' => $memoryBadge,
],
[
'label' => 'Hypervisor Uptime',
'used_percent' => null,
'usage_summary' => $reportedUptime,
'entitlement_label' => ($powerStats['starts'] ?? 0) . ' starts • ' . ($powerStats['shutdowns'] ?? 0) . ' shutdowns • ' . ($powerStats['reboots'] ?? 0) . ' reboots',
'description' => 'Shows the live uptime reported by the hypervisor plus counts from the task history.',
'gradient' => 'linear-gradient(90deg, #a855f7 0%, #ec4899 100%)',
'badge' => ['label' => 'Live', 'class' => 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900'],
],
];
$networkRates = $this->networkRateStats ?? ['in_rate' => 0, 'out_rate' => 0];
$formatRate = function ($bytesPerSecond) {
if ($bytesPerSecond >= 1073741824) {
return number_format($bytesPerSecond / 1073741824, 2) . ' GiB/s';
}
if ($bytesPerSecond >= 1048576) {
return number_format($bytesPerSecond / 1048576, 2) . ' MiB/s';
}
if ($bytesPerSecond >= 1024) {
return number_format($bytesPerSecond / 1024, 2) . ' KiB/s';
}
return number_format($bytesPerSecond, 0) . ' B/s';
};
$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));
$totalNetworkFormatted = number_format($totalNetworkBytes / 1024 / 1024, 2);
$lastSampleTimestamp = now()->toIso8601String();
@endphp
<x-proxmox::ui.card class="mt-4">
<div class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between mb-4">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100">Resource Allocation</h3>
<x-proxmox::ui.help-modal title="Resource Allocation">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
VM resource consumption vs allocated entitlements.
</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
<p><span class="font-semibold text-gray-700 dark:text-gray-300">CPU:</span> Live usage % of vCPU entitlement.</p>
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Memory:</span> RAM usage vs provisioned.</p>
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Uptime:</span> Live uptime + power events.</p>
<p><span class="font-semibold text-gray-700 dark:text-gray-300">Badges:</span> Healthy (green), Watchlist (yellow 85%+), Over (red).</p>
</div>
</x-proxmox::ui.help-modal>
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2 hidden md:inline">Live refresh</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
@foreach($resourceAllocations as $resource)
<x-proxmox::ui.allocation-card
:label="$resource['label']"
:percent="$resource['used_percent']"
:usage-summary="$resource['usage_summary']"
:entitlement-label="$resource['entitlement_label']"
:description="$resource['description']"
:gradient="$resource['gradient']"
:badge="$resource['badge']"
/>
@endforeach
</div>
</x-proxmox::ui.card>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4 md:items-stretch">
<div class="grid gap-4 items-start">
<!-- Details -->
<div>
<div>
<div style="background-image: url('https://billing.virtnet.bond/assets/images/main.png'); background-size: cover; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
<!-- Status Section -->
<div class="bg-white dark:bg-gray-800 border-x border-gray-200 dark:border-gray-700 p-3 space-y-3">
<!-- Status -->
<div class="flex items-center justify-between">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Status</label>
<div class="flex items-center gap-1.5 text-sm">
<div class="relative inline-flex size-4">
@if ($status && $status->status === 'running')
<span class="absolute inset-0 rounded-full bg-green-500 animate-ping opacity-75"></span>
<span class="relative flex items-center justify-center size-4 rounded-full text-green-600 bg-green-100 dark:bg-green-900">
<x-ri-circle-fill class="size-2" />
</span>
@else
<span class="flex items-center justify-center size-4 rounded-full text-amber-600 bg-amber-100 dark:bg-amber-900">
<x-ri-error-warning-fill class="size-2" />
</span>
@endif
</div>
<span class="font-medium text-gray-800 dark:text-gray-200">
{{ $status && $status->status === 'running' ? 'Running' : 'Stopped' }}
</span>
</div>
</div>
<!-- Operating System -->
<div class="flex items-center justify-between">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">OS</label>
<span class="text-sm text-gray-800 dark:text-gray-200">{{ $server->os->name }}</span>
</div>
<!-- Hostname -->
<div class="flex items-center justify-between" x-data="{ showHostnameEditModal: @entangle('isEditingHostname') }">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Hostname</label>
<div class="flex items-center gap-2">
<span class="text-sm font-mono text-gray-800 dark:text-gray-200">{{ $server->hostname ?? $status->name ?? 'N/A' }}</span>
<button wire:click="startEditingHostname" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<x-ri-edit-fill class="size-3.5" />
</button>
</div>
<!-- Hostname Edit Modal -->
<div x-cloak x-show="showHostnameEditModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showHostnameEditModal = false; $wire.cancelEditingHostname()"
@keydown.escape.window="showHostnameEditModal = false; $wire.cancelEditingHostname()"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
style="display: none;">
</div>
<div x-cloak x-show="showHostnameEditModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="showHostnameEditModal = false; $wire.cancelEditingHostname()"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="display: none;">
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Edit Hostname</h4>
<button type="button" class="text-gray-400 hover:text-gray-600 p-1"
@click="showHostnameEditModal = false; $wire.cancelEditingHostname()">
<x-ri-close-line class="size-4" />
</button>
</div>
<div class="space-y-2">
<div>
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Hostname</label>
<input type="text" wire:model.defer="hostname" class="form-input w-full mt-1 text-sm" placeholder="Enter hostname" />
</div>
@if($hostnameError)
<div class="text-xs text-red-600">{{ $hostnameError }}</div>
@endif
<p class="text-xs text-gray-500 dark:text-gray-400">
<strong>Save:</strong> Database only. <strong>Save & Apply:</strong> + cloud-init on next boot.
</p>
</div>
<div class="flex justify-end gap-2">
<x-button.secondary type="button" @click="showHostnameEditModal = false; $wire.cancelEditingHostname()">
Cancel
</x-button.secondary>
<x-button.primary type="button" wire:click="updateHostname">
Save
</x-button.primary>
<x-button.primary type="button" wire:click="applyHostname">
Save & Apply
</x-button.primary>
</div>
</div>
</div>
</div>
<!-- Primary IPv4 Address -->
@if($server->primaryIpv4)
<div class="flex items-center justify-between" x-data="{ copied: false, copyText: '{{ $server->primaryIpv4->ip }}', copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); } }">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">IPv4</label>
<div class="flex items-center gap-1.5">
<span class="text-sm font-mono text-gray-800 dark:text-gray-200">{{ $server->primaryIpv4->ip }}</span>
<button @click="copy()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<x-ri-check-fill x-show="copied" class="size-3.5 text-green-600" />
<x-ri-file-copy-fill x-show="!copied" class="size-3.5" />
</button>
</div>
</div>
@endif
<!-- Primary IPv6 Address -->
@if($server->primaryIpv6)
<div class="flex items-center justify-between" x-data="{ copied: false, copyText: '{{ $server->primaryIpv6->ip }}', copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); } }">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">IPv6</label>
<div class="flex items-center gap-1.5">
<span class="text-xs font-mono text-gray-800 dark:text-gray-200 truncate max-w-[180px]">{{ $server->primaryIpv6->ip }}</span>
<button @click="copy()" type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<x-ri-check-fill x-show="copied" class="size-3.5 text-green-600" />
<x-ri-file-copy-fill x-show="!copied" class="size-3.5" />
</button>
</div>
</div>
@endif
<!-- Root Password -->
<div class="flex items-center justify-between" x-data="{ showPasswordViewModal: false, showPasswordEditModal: @entangle('isEditingPassword') }">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400">Password</label>
<div class="flex items-center gap-1.5">
<span class="text-sm text-gray-800 dark:text-gray-200">
@if(isset($server->password) && $server->password)
••••••••
@else
Emailed
@endif
</span>
@if(isset($server->password) && $server->password)
<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400" @click="showPasswordViewModal = true">
<x-ri-eye-line class="size-3.5" />
</button>
@endif
<button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400" wire:click="startEditingPassword">
<x-ri-edit-fill class="size-3.5" />
</button>
</div>
<!-- Password View Modal -->
<div x-cloak x-show="showPasswordViewModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showPasswordViewModal = false"
@keydown.escape.window="showPasswordViewModal = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
style="display: none;">
</div>
<div x-cloak x-show="showPasswordViewModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="showPasswordViewModal = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="display: none;"
x-data="{
copied: false,
copyText: {{ json_encode($server->password) }},
copy() { navigator.clipboard.writeText(this.copyText); this.copied = true; setTimeout(() => this.copied = false, 2000); }
}">
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Root Password</h4>
<button type="button" class="text-gray-400 hover:text-gray-600 p-1" @click="showPasswordViewModal = false">
<x-ri-close-line class="size-4" />
</button>
</div>
<div class="bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-2 text-sm text-gray-800 dark:text-gray-200 overflow-auto">
<code class="break-all block font-mono text-xs">{{ $server->password }}</code>
</div>
<div class="flex justify-end">
<button type="button" class="px-3 py-1.5 rounded bg-blue-600 text-white text-xs font-medium hover:bg-blue-700" @click="copy()">
<span x-show="!copied">Copy</span>
<span x-show="copied">Copied!</span>
</button>
</div>
</div>
</div>
<!-- Password Edit Modal -->
<div x-cloak x-show="showPasswordEditModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="showPasswordEditModal = false; $wire.cancelEditingPassword()"
@keydown.escape.window="showPasswordEditModal = false; $wire.cancelEditingPassword()"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50"
style="display: none;">
</div>
<div x-cloak x-show="showPasswordEditModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.away="showPasswordEditModal = false; $wire.cancelEditingPassword()"
class="fixed inset-0 z-[60] flex items-center justify-center p-4"
style="display: none;">
<div class="relative bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl w-full max-w-md p-4 space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-100">Edit Root Password</h4>
<button type="button" class="text-gray-400 hover:text-gray-600 p-1"
@click="showPasswordEditModal = false; $wire.cancelEditingPassword()">
<x-ri-close-line class="size-4" />
</button>
</div>
<div class="space-y-2">
<div>
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">New Password</label>
<input type="password" wire:model.defer="password" class="form-input w-full mt-1 text-sm" placeholder="Enter new password" />
</div>
<div>
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">Confirm Password</label>
<input type="password"
wire:model.defer="password_confirmation"
class="form-input w-full mt-1 text-sm"
placeholder="Confirm password" />
</div>
@if($passwordError)
<div class="text-xs text-red-600">{{ $passwordError }}</div>
@endif
<p class="text-xs text-gray-500 dark:text-gray-400">
<strong>Save:</strong> Database only. <strong>Save & Apply:</strong> + cloud-init (reboot required).
</p>
</div>
<div class="flex justify-end gap-2">
<x-button.secondary type="button" @click="showPasswordEditModal = false; $wire.cancelEditingPassword()">
Cancel
</x-button.secondary>
<x-button.primary type="button" wire:click="updatePassword">
Save
</x-button.primary>
<x-button.primary type="button" wire:click="applyPassword">
Save & Apply
</x-button.primary>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between bg-red-50 dark:bg-red-950 border-t border-x border-red-200 dark:border-red-800 px-3 py-2 rounded-b-lg">
<div>
<span class="text-sm font-semibold text-red-700 dark:text-red-300">Reinstall Server</span>
<p class="text-xs text-red-600 dark:text-red-400">Data permanently deleted. Irreversible.</p>
</div>
<livewire:proxmox.reinstall :server="$server" />
</div>
</div>
</div>
<div>
<div style="background-image: url('https://billing.virtnet.bond/assets/images/dns.jpeg'); background-size: cover; background-position: center; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
<div class="bg-white dark:bg-gray-800 border-x border-gray-200 dark:border-gray-700 p-3 space-y-3">
<div class="flex items-center gap-2 min-w-0">
<h2 class="text-base font-semibold">DNS</h2>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-question-line class="size-4" />
</button>
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50" style="display: none;"></div>
<div x-show="open" @click.away="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
<x-ri-global-line class="size-4 text-primary" />
</div>
<h6 class="text-base font-semibold">Domain Name System (DNS)</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
DNS info for your VM. Displays hostname and DNS configuration for the guest OS.
</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-600 dark:text-gray-400">
<strong>Note:</strong> DNS configured via cloud-init. For Windows/manual, configure within guest OS.
</p>
</div>
</div>
</div>
</div>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">IPv6 DNS: {{ $server->primaryIpv6->ip }}</p>
<!-- Virtnet -->
<div class="flex items-center gap-1 ml-4 text-xs">
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.virtnet.host' }} <small class="text-gray-500">(Internal)</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, 2000); }
}">
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
</button>
</div>
</div>
<!-- nip.io -->
<div class="flex items-center gap-1 ml-4 text-xs">
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.nip.io' }} <small class="text-gray-500">(<a href="https://nip.io" target="_blank" class="underline">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, 2000); }
}">
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
</button>
</div>
</div>
<!-- sslip.io -->
<div class="flex items-center gap-1 ml-4 text-xs">
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.sslip.io' }} <small class="text-gray-500">(<a href="https://sslip.io" target="_blank" class="underline">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, 2000); }
}">
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
</button>
</div>
</div>
<!-- backname.io -->
<div class="flex items-center gap-1 ml-4 text-xs">
<span>{{ str_replace(':', '-', $server->primaryIpv6->ip) . '.backname.io' }} <small class="text-gray-500">(<a href="https://backname.io" target="_blank" class="underline">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, 2000); }
}">
<button @click="copyToClipboard()" type="button" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 relative inline-flex items-center justify-center">
<span x-show="copied" x-transition class="absolute"><x-ri-check-fill class="size-3 text-success" /></span>
<span x-show="!copied" x-transition class="absolute"><x-ri-file-copy-fill class="size-3" /></span>
</button>
</div>
</div>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-b-lg text-xs text-gray-600 dark:text-gray-400">
<p class="font-semibold"><code>@</code> = root domain → {{ $server->primaryIpv6->ip }} | <code>*.@</code> = wildcard subdomains → same IP</p>
<p class="mt-1 text-gray-500 dark:text-gray-500">rDNS PTR: No PTR records via RIR systems except direct contract customers.</p>
</div>
</div>
@if(stripos($server->os->name, 'Windows') !== false)
<div>
<div style="background-image: url('https://billing.virtnet.bond/assets/images/windows.jpg'); background-size: cover; background-position: center; height: 3rem;" class="rounded-t-lg border-t border-x border-gray-200 dark:border-gray-700"></div>
<div class="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 p-3 rounded-b-lg">
<div class="flex items-center gap-2">
<h2 class="text-base font-semibold">Windows Server</h2>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-question-line class="size-4" />
</button>
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm" style="display: none;"></div>
<div x-show="open" @click.away="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
<x-ri-windows-line class="size-4 text-primary" />
</div>
<h6 class="text-base font-semibold">Windows Server</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">Windows doesn't support cloud-init. Manual config required.</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
<p><strong>Manual Config:</strong> Configure networking, hostname, and users during/after install.</p>
<p><strong>Network:</strong> Use static IPs or DHCP via Windows Network Configuration.</p>
<p><strong>Remote Access:</strong> Enable RDP within Windows for remote server access.</p>
</div>
</div>
</div>
</div>
</div>
<div class="mt-2 space-y-2 text-xs">
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
<p class="font-semibold text-gray-700 dark:text-gray-200">IPv4/IPv6 Setup:</p>
<p class="text-gray-600 dark:text-gray-400">Cloud-init doesn't configure networking on Windows. Configure addresses manually.</p>
</div>
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
<p class="font-semibold text-gray-700 dark:text-gray-200">Licensing:</p>
<p class="text-gray-600 dark:text-gray-400">No license key provided. User must maintain compliant licensing.</p>
</div>
<div class="border-l-4 border-blue-400 bg-blue-100 dark:bg-blue-900 p-2 rounded-r">
<p class="font-semibold text-gray-700 dark:text-gray-200">Audits:</p>
<p class="text-gray-600 dark:text-gray-400">Periodic audits may occur. Non-compliant installations subject to suspension.</p>
</div>
</div>
</div>
</div>
@endif
<!-- Live Network Traffic -->
<div wire:ignore class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 h-full w-full mt-0">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold flex items-center gap-2">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
Live Network Traffic
</h3>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-question-line class="size-4" />
</button>
<div x-show="open" @click="open = false"
@keydown.escape.window="open = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open" @click.away="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h6 class="text-base font-semibold">Live Network Traffic</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Real-time bandwidth stats per interface with current rates, peaks, and historical graphs.
</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
<p><strong>Current:</strong> Download/upload speeds (bytes/s), updated every 30s.</p>
<p><strong>Peak:</strong> Highest observed rates since page load.</p>
<p><strong>Graph:</strong> View 10-min traffic trends (download=green, upload=blue).</p>
<p><strong>Totals:</strong> Cumulative bytes in/out since VM start.</p>
</div>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Updates every 30 seconds</p>
</div>
<div class="flex items-center gap-2">
<button type="button" @click="exportLiveNetworkTrafficToCSV()"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-1 rounded text-xs font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-1"
title="Export to CSV">
<x-ri-download-line class="size-3" />
Export
</button>
</div>
</div>
<!-- Network Traffic Sections -->
<div id="network-traffic-container" data-live-traffic-endpoint="{{ route('proxmox.server.live-traffic', $server) }}">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<div class="w-4 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-24 h-4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
</div>
<div class="space-y-2">
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-3/4"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-full"></div>
<div class="h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-2/3"></div>
<div class="h-2 bg-gray-200 dark:bg-gray-700 rounded animate-pulse w-full"></div>
</div>
</div>
</div>
</div>
<!-- Notes Section -->
<div id="notes-section" class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 h-full flex flex-col mt-0" x-data="{ showExportErrorModal: false }" x-cloak>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h2 class="text-base font-semibold">Notes</h2>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-question-line class="size-4" />
</button>
<div x-show="open" @click="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm" style="display: none;"></div>
<div x-show="open" @click.away="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
<x-ri-file-text-line class="size-4 text-primary" />
</div>
<h6 class="text-base font-semibold">Notes</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">Store notes about this VM with Markdown support.</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
<p><strong>Markdown:</strong> Headers, lists, links, code blocks supported.</p>
<p><strong>Persistence:</strong> Notes saved to DB but deleted on reinstall.</p>
<p><strong>Uses:</strong> Config docs, IPs, credentials, install steps.</p>
</div>
</div>
</div>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">(Markdown • Cleared on reinstall)</span>
</div>
<div class="flex items-center gap-2">
<button type="button" @click="exportNotesToCSV()"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-1 rounded text-xs font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-1"
title="Export notes">
<x-ri-download-line class="size-3" />
Export
</button>
</div>
</div>
<!-- Export Error Modal -->
<div x-show="showExportErrorModal"
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="showExportErrorModal = false"
@keydown.escape.window="showExportErrorModal = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm"
style="display: none;">
</div>
<div x-show="showExportErrorModal" @click.away="showExportErrorModal = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-sm bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="showExportErrorModal = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-red-50 dark:bg-red-900">
<x-ri-error-warning-line class="size-4 text-error" />
</div>
<h6 class="text-base font-semibold">Export Error</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">No notes available to export.</p>
<div class="flex justify-end">
<x-button.secondary type="button" @click="showExportErrorModal = false">Close</x-button.secondary>
</div>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
@if($isEditingNotes)
<div class="space-y-2 flex-1 flex flex-col">
<textarea wire:model="notes"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 text-sm w-full resize-y flex-1 focus:outline-none focus:ring-2 focus:ring-secondary"
placeholder="Enter notes..."></textarea>
@if($notesError)
<div class="text-xs text-error">{{ $notesError }}</div>
@endif
<div class="flex gap-2">
<x-button.primary wire:click="saveNotes" wire:loading.attr="disabled">
<x-loading target="saveNotes" />
<div wire:loading.remove wire:target="saveNotes">Save</div>
<div wire:loading wire:target="saveNotes">Saving...</div>
</x-button.primary>
<x-button.secondary wire:click="cancelEditingNotes" wire:loading.attr="disabled">Cancel</x-button.secondary>
</div>
</div>
@else
<div class="space-y-2 flex-1 flex flex-col">
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded px-2 py-1 text-sm flex-1 overflow-auto prose prose-sm dark:prose-invert max-w-none text-gray-700 dark:text-gray-300">
@if($notes)
{!! \Illuminate\Support\Str::markdown(e($notes), [
'allow_unsafe_links' => false,
'renderer' => ['soft_break' => "<br>"]
]) !!}
@else
<p class="text-gray-500 dark:text-gray-400">No notes. Click Edit to add.</p>
@endif
</div>
<div class="flex gap-2">
<x-button.secondary wire:click="startEditingNotes">
<x-ri-edit-fill class="size-3" /> Edit
</x-button.secondary>
</div>
</div>
@endif
</div>
</div>
@once
<style>
.network-flow-bar { position: relative; height: 6px; background: rgba(128, 128, 128, 0.1); border-radius: 9999px; overflow: hidden; }
.network-flow-fill { position: absolute; top: 0; left: 0; height: 100%; border-radius: 9999px; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
.network-flow-fill.download { background: linear-gradient(90deg, rgba(34, 197, 94, 0.3), rgba(34, 197, 94, 0.8), rgba(34, 197, 94, 0.3)); }
.network-flow-fill.upload { background: linear-gradient(90deg, rgba(59, 130, 246, 0.3), rgba(59, 130, 246, 0.8), rgba(59, 130, 246, 0.3)); }
.sparkline-chart { width: 100%; height: 32px; }
.chart-dropdown { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
.chart-dropdown.open { max-height: 350px; transition: max-height 0.5s ease-in; }
#network-traffic-container { min-height: 80px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
(() => {
let pollInFlight = false;
let pollingEndpoint = null;
let networkData = null;
let charts = {};
let peakRates = {};
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) 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));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const formatRate = (bytesPerSec) => formatBytes(bytesPerSec) + '/s';
const createInterfaceCard = (interfaceData) => {
const interfaceId = interfaceData.id.replace(/[^a-z0-9]/gi, '-');
return `
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 w-full">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold flex items-center gap-1">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
${interfaceData.name}
</h3>
<span class="text-xs text-gray-500">30s refresh</span>
</div>
<button class="flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 chart-toggle text-xs" data-interface="${interfaceId}" title="Toggle graph">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Graph
</button>
</div>
<!-- Download Speed -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<svg class="w-3 h-3 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">Down</span>
<span class="text-sm font-bold text-success" data-traffic="${interfaceId}-download-rate">0 B/s</span>
</div>
<div class="text-right">
<span class="text-xs text-gray-400">Peak: </span>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" data-traffic="${interfaceId}-download-peak">0 B/s</span>
</div>
</div>
<div class="network-flow-bar">
<div class="network-flow-fill download" data-traffic="${interfaceId}-download-bar"></div>
</div>
</div>
<!-- Upload Speed -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<svg class="w-3 h-3 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">Up</span>
<span class="text-sm font-bold text-info" data-traffic="${interfaceId}-upload-rate">0 B/s</span>
</div>
<div class="text-right">
<span class="text-xs text-gray-400">Peak: </span>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" data-traffic="${interfaceId}-upload-peak">0 B/s</span>
</div>
</div>
<div class="network-flow-bar">
<div class="network-flow-fill upload" data-traffic="${interfaceId}-upload-bar"></div>
</div>
</div>
<!-- Network Graph Dropdown -->
<div class="chart-dropdown" data-chart-container="${interfaceId}">
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-2">
<canvas id="chart-${interfaceId}" class="w-full" style="height: 150px;"></canvas>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-3 gap-2 text-center mt-2">
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">Combined</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-combined">0 B/s</p>
</div>
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">In</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-total-in">0 MB</p>
</div>
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">Out</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-total-out">0 MB</p>
</div>
</div>
</div>
`;
};
const updateInterfaceData = (interfaceData) => {
const interfaceId = interfaceData.id.replace(/[^a-z0-9]/gi, '-');
const scale = networkData.scale || (100 * 1024 * 1024);
// Use only per-interface rates (no fallback to aggregate totals)
// This ensures each adapter shows its own individual statistics
let downloadRate = interfaceData.downloadRate ?? 0;
let uploadRate = interfaceData.uploadRate ?? 0;
// Debug: Log interface data to verify each interface gets unique data
console.log(`Updating interface ${interfaceId} (${interfaceData.id}):`, {
downloadRate,
uploadRate,
totalIn: interfaceData.totalIn,
totalOut: interfaceData.totalOut,
hasHistory: !!interfaceData.history,
historyLength: interfaceData.history?.download?.length || 0
});
// Update peak rates
if (!peakRates[interfaceId]) {
peakRates[interfaceId] = { download: 0, upload: 0 };
}
peakRates[interfaceId].download = Math.max(peakRates[interfaceId].download, downloadRate);
peakRates[interfaceId].upload = Math.max(peakRates[interfaceId].upload, uploadRate);
// Update text elements
const setText = (key, value) => {
const el = document.querySelector(`[data-traffic="${interfaceId}-${key}"]`);
if (el) el.textContent = value;
};
setText('download-rate', formatRate(downloadRate));
setText('upload-rate', formatRate(uploadRate));
setText('download-peak', formatRate(peakRates[interfaceId].download));
setText('upload-peak', formatRate(peakRates[interfaceId].upload));
setText('combined', formatRate(downloadRate + uploadRate));
// Use per-interface totals (unique for each adapter)
const totalIn = interfaceData.totalIn ?? 0;
const totalOut = interfaceData.totalOut ?? 0;
setText('total-in', formatBytes(totalIn));
setText('total-out', formatBytes(totalOut));
// Update progress bars using individual interface rates
const downloadPercent = Math.min(100, (downloadRate / scale) * 100);
const uploadPercent = Math.min(100, (uploadRate / scale) * 100);
const downloadBar = document.querySelector(`[data-traffic="${interfaceId}-download-bar"]`);
const uploadBar = document.querySelector(`[data-traffic="${interfaceId}-upload-bar"]`);
if (downloadBar) {
downloadBar.style.width = `${downloadPercent}%`;
}
if (uploadBar) {
uploadBar.style.width = `${uploadPercent}%`;
}
// Check if chart container is open but chart doesn't exist - create it
const chartContainer = document.querySelector(`[data-chart-container="${interfaceId}"]`);
if (chartContainer && chartContainer.classList.contains('open') && !charts[interfaceId]) {
// Chart container is open but chart wasn't created - try to create it now
if (interfaceData.history) {
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
console.log('Creating chart for', interfaceId, 'on data update (container was open)');
setTimeout(() => createChart(interfaceId, interfaceData), 100);
}
}
}
// Update chart if visible - get latest data from networkData
if (charts[interfaceId]) {
const chart = charts[interfaceId];
// Get the latest interface data from networkData
const latestInterfaceData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
if (latestInterfaceData && latestInterfaceData.history) {
const historyLength = Math.max(
latestInterfaceData.history.download?.length || 0,
latestInterfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
// Generate labels based on data length (assuming ~30 second intervals for hour timeframe)
const labels = Array.from({ length: historyLength }, (_, i) => {
const minutesAgo = Math.round((historyLength - i - 1) * 0.5);
return minutesAgo === 0 ? 'Now' : `${minutesAgo}m`;
});
// Ensure all values are proper numbers (handle null, undefined, NaN)
const downloadData = (latestInterfaceData.history.download || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
const uploadData = (latestInterfaceData.history.upload || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
chart.data.labels = labels.slice(-20);
chart.data.datasets[0].data = downloadData;
chart.data.datasets[1].data = uploadData;
chart.update('none');
}
}
}
};
const renderInterfaces = () => {
const container = document.getElementById('network-traffic-container');
if (!container || !networkData) return;
if (networkData.interfaces.length === 0) {
container.innerHTML = `
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
<svg class="w-10 h-10 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400">No network data available</p>
</div>
`;
return;
}
// Destroy existing charts before re-rendering (important after tab switches)
Object.keys(charts).forEach(interfaceId => {
try {
if (charts[interfaceId] && typeof charts[interfaceId].destroy === 'function') {
charts[interfaceId].destroy();
}
} catch (e) {
console.warn('Error destroying chart for', interfaceId, e);
}
});
charts = {}; // Clear charts object
// Use flex column layout with spacing between separate sections
container.className = 'flex flex-col gap-6';
container.innerHTML = networkData.interfaces
.map((iface) => createInterfaceCard(iface))
.join('');
// Set up chart toggles - use event delegation or ensure fresh listeners
document.querySelectorAll('.chart-toggle').forEach(button => {
button.addEventListener('click', (e) => {
const interfaceId = e.currentTarget.dataset.interface;
const container = document.querySelector(`[data-chart-container="${interfaceId}"]`);
if (container) {
container.classList.toggle('open');
// Create chart on first open
if (container.classList.contains('open') && !charts[interfaceId]) {
const attemptCreateChart = (retries = 5, delay = 200) => {
const interfaceData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
const canvas = document.getElementById(`chart-${interfaceId}`);
if (canvas && interfaceData && interfaceData.history) {
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
console.log('Creating chart for interface:', interfaceId, 'with data:', {
id: interfaceData.id,
name: interfaceData.name,
historyLength: historyLength
});
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
setTimeout(() => {
if (!charts[interfaceId]) {
createChart(interfaceId, interfaceData);
}
}, 50);
});
return;
}
}
// Retry if data not ready yet
if (retries > 0) {
console.log(`Chart data not ready for ${interfaceId}, retrying... (${retries} attempts left)`);
setTimeout(() => attemptCreateChart(retries - 1, delay), delay);
} else {
console.warn('Failed to create chart for', interfaceId, 'after multiple attempts. Available interfaces:', networkData?.interfaces?.map(i => i.id));
// Try one more time with latest data from next poll
const checkOnNextUpdate = () => {
const latestData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
if (latestData && latestData.history && container.classList.contains('open')) {
const historyLength = Math.max(
latestData.history.download?.length || 0,
latestData.history.upload?.length || 0
);
if (historyLength > 0 && !charts[interfaceId]) {
console.log('Creating chart for', interfaceId, 'with data from next update');
createChart(interfaceId, latestData);
}
}
};
// Check on next data update
const originalUpdate = updateInterfaceData;
const checkOnce = (interfaceData) => {
if (interfaceData.id.replace(/[^a-z0-9]/gi, '-') === interfaceId && container.classList.contains('open')) {
checkOnNextUpdate();
}
};
// Set up a one-time check
setTimeout(() => {
if (container.classList.contains('open') && !charts[interfaceId]) {
checkOnNextUpdate();
}
}, 500);
}
};
// Start attempting to create chart
attemptCreateChart();
}
}
});
});
};
const createChart = (interfaceId, interfaceData) => {
const canvas = document.getElementById(`chart-${interfaceId}`);
if (!canvas || charts[interfaceId]) return;
if (typeof Chart === 'undefined') {
console.error('Chart.js is not loaded');
return;
}
if (!interfaceData || !interfaceData.history) {
console.error('No history data available for chart');
return;
}
const ctx = canvas.getContext('2d');
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength === 0) {
console.warn('History data is empty');
return;
}
// Generate labels based on data length (assuming ~30 second intervals for hour timeframe)
const labels = Array.from({ length: historyLength }, (_, i) => {
const minutesAgo = Math.round((historyLength - i - 1) * 0.5);
return minutesAgo === 0 ? 'Now' : `${minutesAgo}m`;
});
// Ensure all values are proper numbers (handle null, undefined, NaN)
const downloadData = (interfaceData.history.download || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
const uploadData = (interfaceData.history.upload || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
// Debug logging
console.log('Chart data for', interfaceId, {
interfaceName: interfaceData.name,
downloadData: downloadData,
uploadData: uploadData,
downloadMax: downloadData.length > 0 ? Math.max(...downloadData, 0) : 0,
uploadMax: uploadData.length > 0 ? Math.max(...uploadData, 0) : 0,
hasData: downloadData.some(v => v > 0) || uploadData.some(v => v > 0)
});
// Create a local formatRate function that's guaranteed to be in scope
const formatRateForChart = (bytesPerSec) => {
if (typeof bytesPerSec !== 'number' || isNaN(bytesPerSec)) {
return '0 B/s';
}
return formatRate(bytesPerSec);
};
charts[interfaceId] = new Chart(ctx, {
type: 'line',
data: {
labels: labels.slice(-20),
datasets: [
{
label: 'Download',
data: downloadData,
borderColor: 'rgb(34, 197, 94)',
backgroundColor: 'rgba(34, 197, 94, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 4,
},
{
label: 'Upload',
data: uploadData,
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 4,
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
padding: 10,
font: {
size: 11,
}
}
},
tooltip: {
callbacks: {
label: (context) => {
const value = context.parsed.y;
return context.dataset.label + ': ' + formatRateForChart(value);
}
}
}
},
scales: {
y: {
beginAtZero: true,
min: 0,
ticks: {
callback: function(value) {
const numValue = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(numValue) || numValue === null || numValue === undefined) {
return '0 B/s';
}
return formatRateForChart(numValue);
},
font: {
size: 10,
}
},
grid: {
color: 'rgba(128, 128, 128, 0.1)',
}
},
x: {
grid: {
display: false,
},
ticks: {
font: {
size: 10,
}
}
}
}
}
});
};
const poll = async () => {
if (pollInFlight || !pollingEndpoint) return;
pollInFlight = true;
console.log('Polling network data from:', pollingEndpoint);
try {
const csrfElement = document.querySelector('meta[name="csrf-token"]');
const csrfToken = csrfElement ? csrfElement.content : '';
const response = await fetch(pollingEndpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-TOKEN': csrfToken,
},
credentials: 'same-origin',
});
console.log('Network Traffic Response status:', response.status);
if (response.ok) {
const data = await response.json();
console.log('Network Traffic Data received:', data);
const container = document.getElementById('network-traffic-container');
const wasEmpty = !networkData || !networkData.interfaces || networkData.interfaces.length === 0;
const hasExistingInterfaces = container && container.querySelectorAll('.chart-toggle').length > 0;
// Check if container shows the loading placeholder (skeleton loader)
const showsPlaceholder = container && (
container.querySelector('.animate-pulse') !== null ||
container.querySelector('.border-dashed') !== null
);
networkData = data;
// Store globally for export access
window.networkTrafficData = data;
// Render interfaces if:
// 1. This is the first load (wasEmpty && no existing interfaces), OR
// 2. Container shows placeholder (was reset by tab switch), OR
// 3. No existing interfaces found in container
if (data.interfaces && data.interfaces.length > 0) {
if (showsPlaceholder || !hasExistingInterfaces || wasEmpty) {
console.log('Rendering interfaces - showsPlaceholder:', showsPlaceholder, 'hasExistingInterfaces:', hasExistingInterfaces, 'wasEmpty:', wasEmpty);
// Render immediately - no delay
renderInterfaces();
// Also update data immediately after render to ensure values are set
requestAnimationFrame(() => {
data.interfaces.forEach(updateInterfaceData);
});
} else {
// Update existing interfaces immediately (just numbers, no re-render)
data.interfaces.forEach(updateInterfaceData);
// After tab switch, ensure chart toggle listeners are attached even if interfaces exist
// This handles the case where DOM was replaced but interfaces were re-rendered
const toggleButtons = container.querySelectorAll('.chart-toggle');
if (toggleButtons.length > 0) {
toggleButtons.forEach(button => {
// Check if button already has a data-listener attribute
if (!button.dataset.listenerAttached) {
button.dataset.listenerAttached = 'true';
const interfaceId = button.dataset.interface;
button.addEventListener('click', (e) => {
const interfaceId = e.currentTarget.dataset.interface;
const container = document.querySelector(`[data-chart-container="${interfaceId}"]`);
if (container) {
container.classList.toggle('open');
// Create chart on first open
if (container.classList.contains('open') && !charts[interfaceId]) {
const attemptCreateChart = (retries = 5, delay = 200) => {
const interfaceData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
const canvas = document.getElementById(`chart-${interfaceId}`);
if (canvas && interfaceData && interfaceData.history) {
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
console.log('Creating chart for interface:', interfaceId, 'after tab switch');
requestAnimationFrame(() => {
setTimeout(() => {
if (!charts[interfaceId]) {
createChart(interfaceId, interfaceData);
}
}, 50);
});
return;
}
}
if (retries > 0) {
setTimeout(() => attemptCreateChart(retries - 1, delay), delay);
}
};
attemptCreateChart();
}
}
});
}
});
}
}
}
} else {
console.error('Network Traffic Response not OK:', response.status, response.statusText);
const text = await response.text();
console.error('Response body:', text);
}
} catch (error) {
console.error('Failed to fetch live traffic data:', error);
const container = document.getElementById('network-traffic-container');
// Only show error if there's no existing network data (initial load failed)
// Keep existing content if we already have data (refresh failed)
const hasExistingContent = container && container.querySelectorAll('.chart-toggle').length > 0;
if (container && !hasExistingContent) {
container.innerHTML = `
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
<svg class="w-10 h-10 mx-auto text-red-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400">Failed to load network data</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">${error.message}</p>
</div>
`;
}
} finally {
pollInFlight = false;
}
};
let pollInterval = null;
const bootstrap = () => {
// Clear any existing interval
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
const container = document.getElementById('network-traffic-container');
pollingEndpoint = container?.dataset?.liveTrafficEndpoint || null;
console.log('Network Traffic Bootstrap: Container found:', !!container);
console.log('Network Traffic Bootstrap: Endpoint:', pollingEndpoint);
if (pollingEndpoint && container) {
// Check if container exists and has endpoint - be less strict about visibility
// Sometimes container exists but offsetParent check fails initially
const isVisible = container.offsetParent !== null;
const containerExists = container && container.dataset.liveTrafficEndpoint;
if (isVisible || containerExists) {
// Always poll immediately on bootstrap (don't wait for interval)
console.log('Network Traffic: Polling immediately on bootstrap...');
// Poll immediately - don't wait
poll();
// Set up interval for subsequent polls (30 seconds)
// Minimal delay to avoid race condition
setTimeout(() => {
if (!pollInterval) {
pollInterval = setInterval(poll, 30000);
console.log('Network Traffic: Polling interval set up (30 seconds)');
}
}, 100);
} else {
console.log('Network Traffic: Container not ready, will retry via visibility check');
}
}
};
// Make bootstrap accessible globally for re-initialization
window.initNetworkTraffic = bootstrap;
// Initial bootstrap - try multiple times aggressively
const tryBootstrap = () => {
const container = document.getElementById('network-traffic-container');
if (container && container.dataset.liveTrafficEndpoint) {
bootstrap();
return true;
}
return false;
};
// Try immediately
if (!tryBootstrap()) {
// If DOM is loading, wait for it
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
// Try multiple times with minimal delays
setTimeout(() => tryBootstrap() || setTimeout(() => tryBootstrap() || setTimeout(tryBootstrap, 50), 50), 10);
});
} else {
// DOM ready, try with minimal delays
setTimeout(() => tryBootstrap() || setTimeout(() => tryBootstrap() || setTimeout(tryBootstrap, 50), 50), 10);
}
} else {
// Successfully bootstrapped, also try immediate poll as backup
console.log('Network Traffic: Bootstrap successful, ensuring immediate poll...');
}
// Re-initialize on Livewire lifecycle events (immediate)
document.addEventListener('livewire:init', () => {
setTimeout(tryBootstrap, 10);
});
document.addEventListener('livewire:load', () => {
setTimeout(tryBootstrap, 10);
});
// Re-initialize when Livewire navigates (tab switches)
document.addEventListener('livewire:navigated', () => {
// Clear charts when navigating (DOM will be replaced)
Object.keys(charts).forEach(interfaceId => {
try {
if (charts[interfaceId] && typeof charts[interfaceId].destroy === 'function') {
charts[interfaceId].destroy();
}
} catch (e) {
console.warn('Error destroying chart during navigation:', e);
}
});
charts = {}; // Clear charts object
// Re-initialize after a short delay to ensure DOM is ready
setTimeout(() => {
tryBootstrap();
// Also ensure event listeners are re-attached if interfaces are already rendered
if (networkData && networkData.interfaces && networkData.interfaces.length > 0) {
const container = document.getElementById('network-traffic-container');
if (container && container.querySelectorAll('.chart-toggle').length > 0) {
// Re-attach chart toggle listeners
document.querySelectorAll('.chart-toggle').forEach(button => {
// Remove any existing listeners by cloning the button
const newButton = button.cloneNode(true);
button.parentNode.replaceChild(newButton, button);
// Add fresh event listener
newButton.addEventListener('click', (e) => {
const interfaceId = e.currentTarget.dataset.interface;
const container = document.querySelector(`[data-chart-container="${interfaceId}"]`);
if (container) {
container.classList.toggle('open');
// Create chart on first open
if (container.classList.contains('open') && !charts[interfaceId]) {
const attemptCreateChart = (retries = 5, delay = 200) => {
const interfaceData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
const canvas = document.getElementById(`chart-${interfaceId}`);
if (canvas && interfaceData && interfaceData.history) {
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
console.log('Creating chart for interface:', interfaceId, 'with data:', {
id: interfaceData.id,
name: interfaceData.name,
historyLength: historyLength
});
requestAnimationFrame(() => {
setTimeout(() => {
if (!charts[interfaceId]) {
createChart(interfaceId, interfaceData);
}
}, 50);
});
return;
}
}
if (retries > 0) {
console.log(`Chart data not ready for ${interfaceId}, retrying... (${retries} attempts left)`);
setTimeout(() => attemptCreateChart(retries - 1, delay), delay);
} else {
console.warn('Failed to create chart for', interfaceId, 'after multiple attempts');
}
};
attemptCreateChart();
}
}
});
});
}
}
}, 100);
});
// Check periodically if container is visible and needs initialization (very frequent)
const visibilityCheck = setInterval(() => {
const container = document.getElementById('network-traffic-container');
if (container) {
const isVisible = container.offsetParent !== null;
const hasEndpoint = container.dataset.liveTrafficEndpoint;
const showsPlaceholder = container.querySelector('.border-dashed') !== null ||
container.textContent.includes('Loading live network statistics');
// If visible, has endpoint, and either:
// 1. No polling interval (not initialized), OR
// 2. Shows placeholder (was reset)
if (isVisible && hasEndpoint && (showsPlaceholder || !pollInterval)) {
console.log('Network Traffic: Container needs initialization (placeholder:', showsPlaceholder, 'no interval:', !pollInterval, '), re-initializing...');
bootstrap();
}
// Also check if we have endpoint but haven't polled yet - poll immediately
if (isVisible && hasEndpoint && !pollInterval && !pollInFlight) {
// Update pollingEndpoint if it's not set
if (!pollingEndpoint) {
pollingEndpoint = hasEndpoint;
}
console.log('Network Traffic: Container visible with endpoint but no polling active, polling immediately...');
poll();
setTimeout(() => {
if (!pollInterval) {
pollInterval = setInterval(poll, 30000);
}
}, 100);
}
}
}, 200); // Check very frequently (every 200ms) for immediate response
// Clean up on page unload
window.addEventListener('beforeunload', () => {
clearInterval(visibilityCheck);
if (pollInterval) {
clearInterval(pollInterval);
}
});
})();
// Export helper functions
window.downloadCSV = window.downloadCSV || function(csvContent, filename) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
window.downloadText = function(textContent, filename) {
const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const sanitizeCellText = (value) => {
return (value || '').toString().replace(/\s+/g, ' ').trim();
};
const csvEscape = (value) => {
const normalized = sanitizeCellText(value ?? '');
if (/[",\n]/.test(normalized)) {
return `"${normalized.replace(/"/g, '""')}"`;
}
return normalized;
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Export Live Network Traffic to CSV
window.exportLiveNetworkTrafficToCSV = function() {
// Access the networkData from the closure
const networkDataVar = window.networkTrafficData || null;
if (!networkDataVar || !networkDataVar.interfaces || networkDataVar.interfaces.length === 0) {
alert('No network traffic data available to export.');
return;
}
let csv = 'Interface,Download Rate (B/s),Upload Rate (B/s),Combined Rate (B/s),Total In (bytes),Total Out (bytes),Timestamp\n';
networkDataVar.interfaces.forEach(iface => {
const timestamp = new Date().toISOString();
csv += [
csvEscape(iface.name || 'Unknown'),
csvEscape(iface.downloadRate || 0),
csvEscape(iface.uploadRate || 0),
csvEscape((iface.downloadRate || 0) + (iface.uploadRate || 0)),
csvEscape(iface.totalIn || 0),
csvEscape(iface.totalOut || 0),
csvEscape(timestamp),
].join(',') + '\n';
// Also export history data if available
if (iface.history && iface.history.download && iface.history.upload) {
const historyLength = Math.max(iface.history.download.length, iface.history.upload.length);
for (let i = 0; i < historyLength; i++) {
const downloadVal = iface.history.download[i] || 0;
const uploadVal = iface.history.upload[i] || 0;
const historyTimestamp = new Date(Date.now() - (historyLength - i) * 30000).toISOString();
csv += [
csvEscape((iface.name || 'Unknown') + ' (History)'),
csvEscape(downloadVal),
csvEscape(uploadVal),
csvEscape(downloadVal + uploadVal),
'',
'',
csvEscape(historyTimestamp),
].join(',') + '\n';
}
}
});
window.downloadCSV(csv, 'live-network-traffic-' + new Date().toISOString().split('T')[0] + '.csv');
};
// Export Notes to text file
window.exportNotesToCSV = function() {
let notesText = '';
// Try to get notes from Livewire component
const livewireComponent = Livewire.find(document.querySelector('[wire\\:id]')?.getAttribute('wire:id') ||
document.querySelector('[wire\\:model="notes"]')?.closest('[wire\\:id]')?.getAttribute('wire:id'));
if (livewireComponent && livewireComponent.get('notes')) {
notesText = livewireComponent.get('notes') || '';
} else {
// Fallback: try to get from textarea
const notesTextarea = document.querySelector('[wire\\:model="notes"]');
if (notesTextarea && notesTextarea.tagName === 'TEXTAREA') {
notesText = notesTextarea.value || '';
} else {
// Fallback: extract from rendered markdown (less ideal, but works)
const notesElement = document.querySelector('.prose');
if (notesElement) {
notesText = notesElement.innerText || notesElement.textContent || '';
}
}
}
if (!notesText || notesText.trim() === '' || notesText.includes('No notes stored')) {
// Find the notes section and show the error modal
const notesSection = document.getElementById('notes-section');
if (notesSection && notesSection._x_dataStack && notesSection._x_dataStack[0]) {
notesSection._x_dataStack[0].showExportErrorModal = true;
}
return;
}
const filename = 'notes-' + new Date().toISOString().split('T')[0] + '.txt';
window.downloadText(notesText, filename);
};
// Store network data globally for export access
window.networkTrafficData = null;
</script>
@endonce
@endif
</x-proxmox::layout>