2338 lines
138 KiB
PHP
2338 lines
138 KiB
PHP
<x-proxmox::layout
|
|
:server="$server"
|
|
view="overview"
|
|
:status="$status"
|
|
:status-polling="true"
|
|
:status-polling-interval="'2s'"
|
|
>
|
|
<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="bg-info/10 border border-info/30 rounded-lg p-4 mb-6"
|
|
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-3">
|
|
<x-ri-information-line class="size-5 text-info flex-shrink-0 mt-0.5" />
|
|
<div class="flex-1">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h3 class="text-sm font-semibold">Cloud-init Configuration Notice</h3>
|
|
<button @click="toggle()" type="button" class="text-base/60 hover:text-base transition-colors">
|
|
<x-ri-arrow-down-s-line x-bind:class="isCollapsed ? 'size-4 transition-transform rotate-180' : 'size-4 transition-transform'" />
|
|
</button>
|
|
</div>
|
|
<div x-show="!isCollapsed" x-transition>
|
|
<p class="text-xs text-base/70 leading-relaxed">
|
|
<strong>Hostname, password, and networking settings</strong> are configured automatically via <strong>cloud-init</strong>.
|
|
This applies to standard operating system installations that support cloud-init. However, if you are:
|
|
</p>
|
|
<ul class="text-xs text-base/70 leading-relaxed mt-2 ml-4 list-disc space-y-1">
|
|
<li>Installing from an ISO image</li>
|
|
<li>Configuring the operating system manually during installation</li>
|
|
<li>Using Windows (which does not support cloud-init)</li>
|
|
</ul>
|
|
<p class="text-xs text-base/70 leading-relaxed mt-2">
|
|
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-xl font-semibold break-words">Server Details</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<x-ri-server-line class="size-5 text-primary" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Server Details
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
Displays comprehensive information about your virtual machine including status, resource usage, network configuration, and system details.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Status:</span> Shows the current power state of the VPS (running, stopped, etc.) with real-time updates.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Resources:</span> Displays CPU, memory, disk, and network usage statistics. Data is automatically refreshed at regular intervals.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Network:</span> Shows assigned IP addresses and network interface information configured for this virtual machine.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<style>
|
|
progress {
|
|
border-radius: var(--radius-lg);
|
|
background-color: hsl(var(--color-background));
|
|
border: 1px solid hsl(var(--color-neutral));
|
|
width: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
progress[value]::-webkit-progress-bar {
|
|
background-color: hsl(var(--color-background));
|
|
}
|
|
|
|
progress[value]#memory::-webkit-progress-value {
|
|
background-color: color-mix(in oklab, hsl(var(--color-success)) 60%, transparent);
|
|
border: 1px solid hsl(var(--color-success));
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
progress[value]#memory::-moz-progress-bar {
|
|
background-color: color-mix(in oklab, hsl(var(--color-success)) 60%, transparent);
|
|
border: 1px solid hsl(var(--color-success));
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
progress[value]#cpu::-webkit-progress-value {
|
|
background-color: color-mix(in oklab, hsl(var(--color-info)) 60%, transparent);
|
|
border: 1px solid hsl(var(--color-info));
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
progress[value]#cpu::-moz-progress-bar {
|
|
background-color: color-mix(in oklab, hsl(var(--color-info)) 60%, transparent);
|
|
border: 1px solid hsl(var(--color-info));
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
|
|
.formRight select {
|
|
background-color: hsl(var(--color-background));
|
|
border: 1px solid hsl(var(--color-neutral));
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
outline: none;
|
|
}
|
|
.formRight select:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px hsl(var(--color-secondary));
|
|
}
|
|
</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-danger bg-danger/15'];
|
|
}
|
|
|
|
if ($value >= 85) {
|
|
return ['label' => 'Watchlist', 'class' => 'text-warning bg-warning/15'];
|
|
}
|
|
|
|
return ['label' => 'Healthy', 'class' => 'text-success bg-success/15'];
|
|
};
|
|
|
|
$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-info bg-info/15'],
|
|
],
|
|
];
|
|
|
|
$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
|
|
|
|
<div class="bg-background-secondary border border-neutral rounded-xl p-6 mt-8">
|
|
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between mb-6">
|
|
<div>
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h3 class="text-lg font-semibold">Resource Allocation vs Entitlement</h3>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<x-ri-server-line class="size-5 text-primary" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Resource Allocation vs Entitlement
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
Compares your virtual machine's actual resource consumption against its allocated entitlements, providing real-time visibility into CPU, memory, and uptime metrics.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">CPU Allocation:</span> Shows live CPU usage as a percentage of your vCPU entitlement. Values are sampled directly from the Proxmox hypervisor and update with each status poll. The progress bar can exceed 100% to indicate over-allocation.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Memory Allocation:</span> Displays current RAM usage compared to your provisioned memory entitlement. Calculated from the VM's allocated memory and updates in real-time as memory consumption changes.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Hypervisor Uptime:</span> Shows the live uptime reported by the Proxmox hypervisor, plus power event statistics (starts, shutdowns, reboots) from the task history. This provides insight into VM stability and availability.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Status Badges:</span> Color-coded badges indicate resource health: "Healthy" (green) for normal usage, "Watchlist" (yellow) when approaching limits (85%+), and "Over entitlement" (red) when exceeding allocated resources.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="text-sm text-base/70">Instantly compare what this service is entitled to with how much it is consuming right now.</p>
|
|
</div>
|
|
<p class="text-xs text-base/50">Values refresh alongside the live status poll.</p>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
@foreach($resourceAllocations as $resource)
|
|
<div class="border border-neutral rounded-xl p-4 bg-background flex flex-col gap-4">
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div>
|
|
<p class="text-xs font-semibold tracking-wide uppercase text-base/60">{{ $resource['label'] }}</p>
|
|
<p class="text-lg font-semibold">{{ $resource['usage_summary'] }}</p>
|
|
<p class="text-xs text-base/60">{{ $resource['entitlement_label'] }}</p>
|
|
</div>
|
|
<span class="text-[10px] font-semibold px-2 py-1 rounded-full {{ $resource['badge']['class'] }}">
|
|
{{ $resource['badge']['label'] }}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
@if(!is_null($resource['used_percent']))
|
|
<div class="flex items-center justify-between text-xs text-base/60 mb-1">
|
|
<span>Usage</span>
|
|
<span>{{ $resource['used_percent'] }}%</span>
|
|
</div>
|
|
<div class="h-2 rounded-full bg-background-secondary overflow-hidden">
|
|
<div class="h-full rounded-full transition-all duration-300"
|
|
style="width: {{ min($resource['used_percent'], 130) }}%; background-image: {{ $resource['gradient'] }};">
|
|
</div>
|
|
</div>
|
|
@endif
|
|
<p class="text-xs text-base/60 mt-2">{{ $resource['description'] }}</p>
|
|
</div>
|
|
</div>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-12 mt-8 md:items-stretch">
|
|
|
|
<div class="grid gap-8 items-start">
|
|
<!-- Details -->
|
|
<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>
|
|
|
|
<!-- Status Section -->
|
|
<div class="bg-background-secondary border-x border-neutral p-4 space-y-4 rounded-m-lg">
|
|
<!-- Status -->
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-semibold text-base/80">Status</label>
|
|
<div class="flex items-center gap-2 px-3 py-2 text-sm text-base/80">
|
|
<div class="relative inline-flex size-5">
|
|
@if ($status && $status->status === 'running')
|
|
<span class="absolute inset-0 rounded-full bg-success/20 animate-ping"></span>
|
|
<span class="relative flex items-center justify-center size-5 p-1 rounded-full text-success bg-success/20">
|
|
<x-ri-circle-fill />
|
|
</span>
|
|
@else
|
|
<span class="flex items-center justify-center size-5 p-1 rounded-full text-warning bg-warning/20">
|
|
<x-ri-error-warning-fill />
|
|
</span>
|
|
@endif
|
|
</div>
|
|
<span class="truncate flex-1">
|
|
{{ $status && $status->status === 'running' ? 'Running' : 'Stopped' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operating System -->
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-semibold text-base/80">Operating System</label>
|
|
<div class="flex items-center gap-2 px-3 py-2 text-sm text-base/80">
|
|
<span class="truncate flex-1">
|
|
{{ $server->os->name }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hostname -->
|
|
<div class="space-y-2" x-data="{ showHostnameEditModal: @entangle('isEditingHostname') }">
|
|
<label class="text-sm font-semibold text-base/80">Hostname</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">
|
|
<span class="truncate flex-1">
|
|
{{ $server->hostname ?? $status->name ?? 'N/A' }}
|
|
</span>
|
|
<button wire:click="startEditingHostname" type="button"
|
|
class="text-base/70 hover:text-base/60 ml-2"
|
|
aria-label="Edit Hostname">
|
|
<x-ri-edit-fill class="inline size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hostname Edit Modal -->
|
|
<div x-cloak x-show="showHostnameEditModal" x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="showHostnameEditModal = false; $wire.cancelEditingHostname()"></div>
|
|
<div class="relative bg-background-secondary border border-neutral rounded-lg shadow-xl w-full max-w-xl mx-4 p-5 space-y-4"
|
|
x-on:keydown.escape.window="showHostnameEditModal = false; $wire.cancelEditingHostname()">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-base font-semibold text-base/90">Edit Hostname</h4>
|
|
<button type="button"
|
|
class="bg-background border border-neutral p-1.5 rounded-md hover:bg-background/80"
|
|
@click="showHostnameEditModal = false; $wire.cancelEditingHostname()"
|
|
aria-label="Close hostname edit modal">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs font-semibold text-base/70">Hostname</label>
|
|
<input type="text"
|
|
wire:model.defer="hostname"
|
|
class="form-input w-full mt-1"
|
|
placeholder="Enter hostname" />
|
|
</div>
|
|
@if($hostnameError)
|
|
<div class="text-sm text-error">{{ $hostnameError }}</div>
|
|
@endif
|
|
<p class="text-xs text-base/60">
|
|
<strong>Save:</strong> Updates the hostname in the database and order properties.<br>
|
|
<strong>Save & Apply:</strong> Saves to database and applies the hostname. (Will be configured on next boot via cloud-init)
|
|
</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="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);
|
|
}
|
|
}">
|
|
<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"
|
|
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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Primary IPv6 Address -->
|
|
@if($server->primaryIpv6)
|
|
<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);
|
|
}
|
|
}">
|
|
<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"
|
|
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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<!-- Root Password -->
|
|
<div class="space-y-2" x-data="{ showPasswordViewModal: false, showPasswordEditModal: @entangle('isEditingPassword') }">
|
|
<label class="text-sm font-semibold text-base/80">Root Password</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">
|
|
<span class="truncate flex-1">
|
|
@if(isset($server->password) && $server->password)
|
|
••••••••••••
|
|
@else
|
|
Password is sent via email on install.
|
|
@endif
|
|
</span>
|
|
@if(isset($server->password) && $server->password)
|
|
<button type="button"
|
|
class="text-base/70 hover:text-base/60 ml-2"
|
|
@click="showPasswordViewModal = true"
|
|
aria-label="View Root Password">
|
|
<x-ri-eye-line class="inline size-4" />
|
|
</button>
|
|
@endif
|
|
<button type="button"
|
|
class="text-base/70 hover:text-base/60 ml-2"
|
|
wire:click="startEditingPassword"
|
|
aria-label="Edit Root Password">
|
|
<x-ri-edit-fill class="inline size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password View Modal -->
|
|
<div x-cloak x-show="showPasswordViewModal" x-transition.opacity class="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="showPasswordViewModal = false"></div>
|
|
<div class="relative bg-background-secondary border border-neutral rounded-lg shadow-xl w-full max-w-xl mx-4 p-5 space-y-4"
|
|
x-on:keydown.escape.window="showPasswordViewModal = false"
|
|
x-data="{
|
|
copied: false,
|
|
copyText: {{ json_encode($server->password) }},
|
|
copyToClipboard() {
|
|
navigator.clipboard.writeText(this.copyText);
|
|
this.copied = true;
|
|
setTimeout(() => this.copied = false, 3000);
|
|
}
|
|
}">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-base font-semibold text-base/90">Root Password</h4>
|
|
<button type="button"
|
|
class="bg-background border border-neutral p-1.5 rounded-md hover:bg-background/80"
|
|
@click="showPasswordViewModal = false"
|
|
aria-label="Close password view modal">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="bg-background border border-neutral rounded-md p-3 text-sm text-base/80 overflow-auto">
|
|
<code class="whitespace-pre-wrap break-all block">{{ $server->password }}</code>
|
|
</div>
|
|
<div class="flex justify-end">
|
|
<button type="button"
|
|
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-white text-xs font-semibold hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
|
@click="copyToClipboard()">
|
|
<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.opacity class="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="showPasswordEditModal = false; $wire.cancelEditingPassword()"></div>
|
|
<div class="relative bg-background-secondary border border-neutral rounded-lg shadow-xl w-full max-w-xl mx-4 p-5 space-y-4"
|
|
x-on:keydown.escape.window="showPasswordEditModal = false; $wire.cancelEditingPassword()">
|
|
<div class="flex items-center justify-between">
|
|
<h4 class="text-base font-semibold text-base/90">Edit Root Password</h4>
|
|
<button type="button"
|
|
class="bg-background border border-neutral p-1.5 rounded-md hover:bg-background/80"
|
|
@click="showPasswordEditModal = false; $wire.cancelEditingPassword()"
|
|
aria-label="Close password edit modal">
|
|
<x-ri-close-line class="size-4" />
|
|
</button>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-xs font-semibold text-base/70">New Password</label>
|
|
<input type="password"
|
|
wire:model.defer="password"
|
|
class="form-input w-full mt-1"
|
|
placeholder="Enter new password" />
|
|
</div>
|
|
<div>
|
|
<label class="text-xs font-semibold text-base/70">Confirm Password</label>
|
|
<input type="password"
|
|
wire:model.defer="password_confirmation"
|
|
class="form-input w-full mt-1"
|
|
placeholder="Confirm password" />
|
|
</div>
|
|
@if($passwordError)
|
|
<div class="text-sm text-error">{{ $passwordError }}</div>
|
|
@endif
|
|
<p class="text-xs text-base/60">
|
|
<strong>Save:</strong> Updates the password in the database.<br>
|
|
<strong>Save & Apply:</strong> Saves to database and applies the password to the VPS via cloud-init. <strong>The server must be rebooted for the password change to take effect.</strong>
|
|
</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 flex-col bg-error/10 border border-error/30 p-4 rounded-b-lg">
|
|
<label class="text-lg font-semibold flex flex-row justify-between items-center gap-8" for="os">
|
|
|
|
<div class="flex flex-col gap-2">
|
|
Reinstall Server
|
|
<p class="text-sm font-semibold text-base/60">The data on the server will be permanently
|
|
deleted. This action is irreversible and cannot be undone.</p>
|
|
</div>
|
|
|
|
<livewire:proxmox.reinstall :server="$server" />
|
|
</label>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<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="fflex flex-col gap-4 bg-background-secondary hover:bg-background-secondary/80 border-m 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" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<x-ri-global-line class="size-5 text-primary" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Domain Name System (DNS)
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
DNS information for your virtual machine. This section displays the hostname and DNS configuration that will be used by the guest operating system.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Note:</span> DNS settings are typically configured via cloud-init for supported operating systems. For Windows or manual installations, DNS must be configured within the guest OS.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Explanation -->
|
|
|
|
<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>
|
|
</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>
|
|
|
|
|
|
|
|
<div class="flex flex-col bg-info/10 border border-info/30 p-4 rounded-b-lg ">
|
|
<p class="text-sm text-base/50 font-bold leading-relaxed">
|
|
<code>@</code> points the root domain directly to the server IP ({{ $server->primaryIpv6->ip }})<br/>
|
|
<code>*.@</code> points all subdomains (wildcard) to the same IP ({{ $server->primaryIpv6->ip }})<br/><br/></p><p class="text-sm text-base/50 italic leading-relaxed">
|
|
|
|
<code>@</code> — Represents the <strong>root domain</strong> (also called the apex domain).
|
|
This means the base domain name (e.g. <code>example.com</code>) will point directly to the server IP.<br/><br/>
|
|
|
|
<code>*.@</code> — Represents a <strong>wildcard record</strong>.
|
|
This ensures that <em>any</em> subdomain also points to the same server IP.
|
|
<br/><br/>
|
|
|
|
Together, using both <code>@</code> and <code>*.@</code> ensures that your server will respond to requests for the root domain and <em>all possible subdomains</em>.<br/><br/>
|
|
|
|
rDNS PTR Records: Please make note that at no time will PTR records be generated for any IP address via the RIR systems. No exceptions will be made other than for direct contract business customers due to the security risks involved with allowing arbitrary PTR records to be set and the potential for abuse.<br/><br/>
|
|
|
|
</p></div></div>
|
|
|
|
@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>
|
|
<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" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<x-ri-windows-line class="size-5 text-primary" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Windows Server Installations
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
Special instructions and information for Windows Server installations. Windows does not support cloud-init, so additional configuration steps are required.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Manual Configuration Required:</span> Unlike Linux distributions, Windows does not support cloud-init. You must manually configure networking, hostname, and user accounts during or after installation.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Network Setup:</span> Windows will need to be configured with static IP addresses or DHCP settings through the Windows Network Configuration interface.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Remote Access:</span> Enable Remote Desktop (RDP) within Windows to access the server remotely after installation.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mt-2 border-l-4 bg-info/10 border border-info/30 p-3 rounded-md">
|
|
<p class="text-sm dark:text-gray-100 font-bold leading-relaxed">
|
|
Windows Server Networking (IPv4/IPv6 Setup):
|
|
</p>
|
|
<p class="text-sm dark:text-gray-200 italic leading-relaxed ">
|
|
Cloud-init does not automatically configure IPv4 or IPv6 networking on Windows Server installations. Please configure the addresses manually as assigned in the control panel of your installation.
|
|
</p>
|
|
</div>
|
|
<div class="mt-2 border-l-4 bg-info/10 border border-info/30 p-3 rounded-md">
|
|
<p class="text-sm dark:text-gray-100 font-bold leading-relaxed">
|
|
Windows Server Activation (Licensing):
|
|
</p>
|
|
<p class="text-sm dark:text-gray-100 italic leading-relaxed ">
|
|
No Windows Server license key is provided by Virtnet.bond. It is the responsibility of the account holder
|
|
to provide and maintain active and compliant licensing with all Windows Server products.
|
|
</p>
|
|
</div>
|
|
<div class="mt-2 border-l-4 bg-info/10 border border-info/30 p-3 rounded-md">
|
|
<p class="text-sm dark:text-gray-100 font-bold leading-relaxed">
|
|
Periodic Licensing Audits and User Compliance:
|
|
</p>
|
|
<p class="text-sm dark:text-gray-200 italic leading-relaxed ">
|
|
Virtnet.bond reserves the right to perform periodic audits of Windows Server installations to ensure compliance with Microsoft's licensing terms. Non-compliant installations may be subject to suspension or termination. If you have any questions regarding licensing compliance, please contact our support team.
|
|
</p>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
@endif
|
|
|
|
<!-- Live Network Traffic -->
|
|
<div wire:ignore class="bg-background-secondary border border-neutral rounded-xl p-6 h-full w-full mt-0">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
|
<svg class="w-5 h-5 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-base/50 hover:text-base/70 transition-colors">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<svg class="w-5 h-5 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="font-sans text-lg font-semibold leading-tight">
|
|
Live Network Traffic
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
Displays real-time network bandwidth statistics for each network interface on your virtual machine, including current transfer rates, peak values, and historical traffic graphs.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Current Rates:</span> Shows the instantaneous download and upload speeds in bytes per second, updated every 30 seconds from Proxmox RRD data.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Peak Values:</span> Tracks the highest observed transfer rates for each direction since the page was loaded, helping identify maximum bandwidth usage.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Historical Graph:</span> Click the "Graph" button to view a line chart showing network traffic trends over the last 10 minutes, with separate lines for download (green) and upload (blue) traffic.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Total Statistics:</span> Displays cumulative data transfer totals (total bytes in and out) from the VM's start, providing a long-term view of network usage.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="text-sm text-base/60 mt-1">Real-time bandwidth monitoring • Updates every 30 seconds</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button type="button"
|
|
@click="exportLiveNetworkTrafficToCSV()"
|
|
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
|
title="Export network traffic data to CSV">
|
|
<x-ri-download-line class="size-4" />
|
|
Export
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Network Traffic Sections -->
|
|
<div id="network-traffic-container" data-live-traffic-endpoint="{{ route('proxmox.server.live-traffic', $server) }}">
|
|
<!-- Initial placeholder; replaced on first successful poll -->
|
|
<div class="border border-dashed border-neutral/40 rounded-lg p-6 text-center">
|
|
<p class="text-sm text-base/60">Loading live network statistics…</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Notes Section -->
|
|
<div id="notes-section" class="bg-background-secondary hover:bg-background-secondary/80 border border-neutral rounded-xl p-6 h-full flex flex-col mt-0" x-data="{ showExportErrorModal: false }" x-cloak>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<h2 class="text-lg font-semibold break-words">Notes</h2>
|
|
<div class="relative" x-data="{ open: false }" x-cloak>
|
|
<button type="button" @click="open = true" class="text-base/50 hover:text-base/70 transition-colors">
|
|
<x-ri-question-line class="size-4" />
|
|
</button>
|
|
<!-- Modal Backdrop -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
@click="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<!-- Modal -->
|
|
<div x-show="open"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="open = false"
|
|
@keydown.escape.window="open = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="open = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10">
|
|
<x-ri-file-text-line class="size-5 text-primary" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Notes
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
Store custom notes and documentation about this virtual machine. Notes support Markdown formatting for rich text editing.
|
|
</p>
|
|
<div class="pt-4 mt-4 border-t border-neutral/30 space-y-2.5">
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Markdown Support:</span> Use Markdown syntax for formatting including headers, lists, links, and code blocks.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Persistence:</span> Notes are stored in the database and persist across sessions. However, notes are deleted when the server is reinstalled.
|
|
</p>
|
|
<p class="block font-sans text-xs font-normal leading-relaxed text-base/75">
|
|
<span class="font-semibold text-base/90">Use Cases:</span> Document server configuration, installation steps, important IP addresses, credentials (stored securely), or any other information relevant to this VPS.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p class="text-md font-semibold text-base/60 mt-1">Store notes about this server, Markdown is supported. <small>(These do not persist reinstalls)</small></p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button type="button"
|
|
@click="exportNotesToCSV()"
|
|
class="bg-background border border-neutral px-3 py-2 rounded-md text-sm font-semibold hover:bg-background/80 flex items-center gap-2 h-[calc(0.5rem*2+1.25rem)]"
|
|
title="Export notes to text file">
|
|
<x-ri-download-line class="size-4" />
|
|
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-50 bg-black/50 backdrop-blur-sm"
|
|
style="display: none;">
|
|
</div>
|
|
<div x-show="showExportErrorModal"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0 scale-95"
|
|
x-transition:enter-end="opacity-100 scale-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100 scale-100"
|
|
x-transition:leave-end="opacity-0 scale-95"
|
|
@click.away="showExportErrorModal = false"
|
|
@keydown.escape.window="showExportErrorModal = false"
|
|
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
style="display: none;">
|
|
<div class="relative w-full max-w-md bg-background-secondary rounded-xl border border-neutral/20 shadow-2xl p-6">
|
|
<button @click="showExportErrorModal = false" class="absolute top-4 right-4 text-base/50 hover:text-base transition-colors">
|
|
<x-ri-close-line class="size-5" />
|
|
</button>
|
|
<div class="flex items-center gap-3 mb-4 pr-8">
|
|
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-error/10">
|
|
<x-ri-error-warning-line class="size-5 text-error" />
|
|
</div>
|
|
<h6 class="font-sans text-lg font-semibold leading-tight">
|
|
Export Error
|
|
</h6>
|
|
</div>
|
|
<p class="block font-sans text-sm font-normal leading-relaxed text-base/90 mb-4">
|
|
No notes available to export.
|
|
</p>
|
|
<div class="flex justify-end gap-2 mt-6">
|
|
<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-3 flex-1 flex flex-col">
|
|
<textarea wire:model="notes"
|
|
class="bg-background border border-neutral rounded-md px-3 py-2 text-sm w-full resize-y flex-1 focus:outline-none focus:ring-2 focus:ring-secondary"
|
|
placeholder="Enter your notes here..."></textarea>
|
|
@if($notesError)
|
|
<div class="text-sm 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-3 flex-1 flex flex-col">
|
|
<div class="bg-background border border-neutral rounded-md px-3 py-2 text-sm flex-1 overflow-auto prose prose-sm dark:prose-invert max-w-none text-base/80">
|
|
@if($notes)
|
|
{!! \Illuminate\Support\Str::markdown(e($notes), [
|
|
'allow_unsafe_links' => false,
|
|
'renderer' => [
|
|
'soft_break' => "<br>"
|
|
]
|
|
]) !!}
|
|
@else
|
|
<p class="text-base/60">No notes stored. Click Edit to add notes.</p>
|
|
@endif
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<x-button.secondary wire:click="startEditingNotes">
|
|
<x-ri-edit-fill class="size-4" />
|
|
Edit
|
|
</x-button.secondary>
|
|
</div>
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
@once
|
|
<style>
|
|
/* Network Traffic Bars */
|
|
.network-flow-bar {
|
|
position: relative;
|
|
height: 8px;
|
|
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 styles */
|
|
.sparkline-chart {
|
|
width: 100%;
|
|
height: 40px;
|
|
}
|
|
|
|
.chart-dropdown {
|
|
max-height: 0;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease-out;
|
|
}
|
|
|
|
.chart-dropdown.open {
|
|
max-height: 400px;
|
|
transition: max-height 0.5s ease-in;
|
|
}
|
|
|
|
/* Ensure network traffic container is visible */
|
|
#network-traffic-container {
|
|
min-height: 100px;
|
|
}
|
|
</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) => {
|
|
return formatBytes(bytesPerSec) + '/s';
|
|
};
|
|
|
|
const createInterfaceCard = (interfaceData) => {
|
|
const interfaceId = interfaceData.id.replace(/[^a-z0-9]/gi, '-');
|
|
|
|
return `
|
|
<div class="bg-background-secondary border border-neutral rounded-xl p-6 w-full">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-base flex items-center gap-2">
|
|
<svg class="w-5 h-5 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>
|
|
<p class="text-sm text-base/60 mt-1">Live bandwidth • Updates every 30 seconds</p>
|
|
</div>
|
|
<button class="flex items-center gap-1.5 text-base/60 hover:text-base transition-colors chart-toggle"
|
|
data-interface="${interfaceId}"
|
|
title="Toggle network graph">
|
|
<svg class="w-5 h-5" 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>
|
|
<span class="text-sm font-medium">Graph</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Download Speed -->
|
|
<div class="mb-6">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 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-sm font-medium text-base/80">Download</span>
|
|
<span class="text-lg font-bold text-success" data-traffic="${interfaceId}-download-rate">0 B/s</span>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-xs text-base/50">Peak: </span>
|
|
<span class="text-xs font-medium text-base/70" 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-6">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<div class="flex items-center gap-2">
|
|
<svg class="w-4 h-4 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-sm font-medium text-base/80">Upload</span>
|
|
<span class="text-lg font-bold text-info" data-traffic="${interfaceId}-upload-rate">0 B/s</span>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-xs text-base/50">Peak: </span>
|
|
<span class="text-xs font-medium text-base/70" 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-neutral/30 pt-4 mt-4">
|
|
<canvas id="chart-${interfaceId}" class="w-full" style="height: 200px;"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics -->
|
|
<div class="grid grid-cols-3 gap-3 text-center">
|
|
<div class="p-3 rounded bg-background-secondary/50 border border-neutral/20">
|
|
<p class="text-xs text-base/50 uppercase">Combined</p>
|
|
<p class="text-sm font-semibold text-base" data-traffic="${interfaceId}-combined">0 B/s</p>
|
|
</div>
|
|
<div class="p-3 rounded bg-background-secondary/50 border border-neutral/20">
|
|
<p class="text-xs text-base/50 uppercase">Total In</p>
|
|
<p class="text-sm font-semibold text-base" data-traffic="${interfaceId}-total-in">0 MB</p>
|
|
</div>
|
|
<div class="p-3 rounded bg-background-secondary/50 border border-neutral/20">
|
|
<p class="text-xs text-base/50 uppercase">Total Out</p>
|
|
<p class="text-sm font-semibold text-base" 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-background-secondary border border-neutral rounded-lg p-8 text-center">
|
|
<svg class="w-12 h-12 mx-auto text-base/40 mb-4" 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-base/60">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
|
|
const showsPlaceholder = container && (
|
|
container.querySelector('.border-dashed') !== null ||
|
|
container.textContent.includes('Loading live network statistics')
|
|
);
|
|
|
|
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');
|
|
if (container && !container.querySelector('.bg-background-secondary')) {
|
|
container.innerHTML = `
|
|
<div class="bg-background-secondary border border-neutral rounded-lg p-8 text-center">
|
|
<svg class="w-12 h-12 mx-auto text-red-400 mb-4" 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-base/60">Failed to load network data</p>
|
|
<p class="text-sm text-base/40 mt-2">${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
|
|
|
|
</x-proxmox::layout> |