feat: make noVNC and xterm.js console links open in minimal popup windows
This commit is contained in:
@@ -17,6 +17,8 @@ use Paymenter\Extensions\Servers\Proxmox\Livewire\VNC;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\XTerm;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Help;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Livewire\Statistics;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Models\Server;
|
||||
use Paymenter\Extensions\Servers\Proxmox\Proxmox;
|
||||
|
||||
Route::group([
|
||||
'prefix' => 'servers',
|
||||
@@ -39,4 +41,27 @@ Route::group([
|
||||
Route::get('{server}/vnc', VNC::class)->name('server.vnc');
|
||||
Route::get('{server}/xterm', XTerm::class)->name('server.xterm');
|
||||
Route::get('{server}/help', Help::class)->name('server.help');
|
||||
|
||||
// Popup console routes (minimal layout for popup windows)
|
||||
Route::get('{server}/vnc-popup', function (Server $server) {
|
||||
if (!$server->node->vnc_enabled) {
|
||||
abort(403, 'VNC is not enabled on this server node.');
|
||||
}
|
||||
$proxmox = new Proxmox;
|
||||
return view('proxmox::vnc-popup', [
|
||||
'server' => $server,
|
||||
'vnc' => $proxmox->vnc($server),
|
||||
]);
|
||||
})->name('server.vnc-popup');
|
||||
|
||||
Route::get('{server}/xterm-popup', function (Server $server) {
|
||||
if (!$server->node->vnc_enabled) {
|
||||
abort(403, 'Console access is not enabled on this server node.');
|
||||
}
|
||||
$proxmox = new Proxmox;
|
||||
return view('proxmox::xterm-popup', [
|
||||
'server' => $server,
|
||||
'xterm' => $proxmox->xterm($server),
|
||||
]);
|
||||
})->name('server.xterm-popup');
|
||||
});
|
||||
|
||||
@@ -1588,8 +1588,8 @@
|
||||
'label' => 'Console',
|
||||
'dropdown' => true,
|
||||
'items' => [
|
||||
['label' => 'NoVNC', 'view' => 'vnc', 'route' => route('proxmox.server.vnc', ['server' => $server->id])],
|
||||
['label' => 'Xterm.js', 'view' => 'xterm', 'route' => route('proxmox.server.xterm', ['server' => $server->id])],
|
||||
['label' => 'NoVNC', 'view' => 'vnc', 'route' => route('proxmox.server.vnc-popup', ['server' => $server->id]), 'popup' => true],
|
||||
['label' => 'Xterm.js', 'view' => 'xterm', 'route' => route('proxmox.server.xterm-popup', ['server' => $server->id]), 'popup' => true],
|
||||
]
|
||||
];
|
||||
}
|
||||
@@ -1974,15 +1974,26 @@
|
||||
@foreach($tab['items'] as $itemIndex => $item)
|
||||
@php
|
||||
$itemAdvanced = $item['advanced'] ?? false;
|
||||
$isPopup = $item['popup'] ?? false;
|
||||
@endphp
|
||||
<div @if($itemAdvanced)
|
||||
x-show="isAdvancedView"
|
||||
@endif>
|
||||
<a href="{{ $item['route'] }}" wire:navigate
|
||||
class="dropdown-item {{ $view === $item['view'] ? 'active' : '' }}"
|
||||
@click="open = false">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@if($isPopup)
|
||||
<a href="{{ $item['route'] }}"
|
||||
target="_blank"
|
||||
onclick="event.preventDefault(); window.open('{{ $item['route'] }}', '{{ $item['label'] }}Console', 'width=1024,height=768,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes'); return false;"
|
||||
class="dropdown-item {{ $view === $item['view'] ? 'active' : '' }}"
|
||||
@click="open = false">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ $item['route'] }}" wire:navigate
|
||||
class="dropdown-item {{ $view === $item['view'] ? 'active' : '' }}"
|
||||
@click="open = false">
|
||||
{{ $item['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
@if($itemIndex < count($tab['items']) - 1 && $itemAdvanced && !($tab['items'][$itemIndex + 1]['advanced'] ?? false))
|
||||
<div x-show="isAdvancedView" class="dropdown-divider"></div>
|
||||
@endif
|
||||
|
||||
@@ -1408,6 +1408,63 @@
|
||||
let charts = {};
|
||||
let peakRates = {};
|
||||
|
||||
// Handle chart toggle clicks via event delegation (works for dynamically created elements)
|
||||
const handleChartToggle = (interfaceId) => {
|
||||
const chartContainer = document.querySelector(`[data-chart-container="${interfaceId}"]`);
|
||||
|
||||
if (chartContainer) {
|
||||
chartContainer.classList.toggle('open');
|
||||
|
||||
// Create chart on first open
|
||||
if (chartContainer.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);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
attemptCreateChart();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Event delegation for chart toggle buttons - attach once to document
|
||||
document.addEventListener('click', (e) => {
|
||||
const toggleButton = e.target.closest('.chart-toggle');
|
||||
if (toggleButton) {
|
||||
const interfaceId = toggleButton.dataset.interface;
|
||||
if (interfaceId) {
|
||||
handleChartToggle(interfaceId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -1669,96 +1726,7 @@
|
||||
.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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// Chart toggles are handled by event delegation at document level
|
||||
};
|
||||
|
||||
const createChart = (interfaceId, interfaceData) => {
|
||||
|
||||
31
extensions/Servers/Proxmox/views/vnc-popup.blade.php
Normal file
31
extensions/Servers/Proxmox/views/vnc-popup.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>NoVNC Console - {{ $server->name }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="{{ $vnc }}" allowfullscreen></iframe>
|
||||
</body>
|
||||
</html>
|
||||
31
extensions/Servers/Proxmox/views/xterm-popup.blade.php
Normal file
31
extensions/Servers/Proxmox/views/xterm-popup.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Xterm.js Console - {{ $server->name }}</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="{{ $xterm }}" allowfullscreen></iframe>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user