refactor: remove live network traffic section and related export functionality

This commit is contained in:
2025-12-17 17:08:50 -05:00
parent 19356d05e6
commit 65c150ee75

View File

@@ -775,80 +775,8 @@
@endif
</div>
<!-- Live Network Traffic & Notes Row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<!-- Live Network Traffic -->
<div wire:ignore class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h3 class="text-base font-semibold flex items-center gap-2">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
Live Network Traffic
</h3>
<div class="relative" x-data="{ open: false }" x-cloak>
<button type="button" @click="open = true" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-question-line class="size-4" />
</button>
<div x-show="open" @click="open = false"
@keydown.escape.window="open = false"
class="fixed inset-0 z-[60] bg-gray-900 bg-opacity-50 backdrop-blur-sm"
style="display: none;">
</div>
<!-- Modal -->
<div x-show="open" @click.away="open = false" @keydown.escape.window="open = false"
class="fixed inset-0 z-[60] flex items-center justify-center p-4" style="display: none;">
<div class="relative w-full max-w-xl max-h-[90vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-xl p-4">
<button @click="open = false" class="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<x-ri-close-line class="size-4" />
</button>
<div class="flex items-center gap-2 mb-3 pr-6">
<div class="flex items-center justify-center w-8 h-8 rounded bg-blue-50 dark:bg-blue-900">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h6 class="text-base font-semibold">Live Network Traffic</h6>
</div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Real-time bandwidth stats per interface with current rates, peaks, and historical graphs.
</p>
<div class="pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2 text-xs text-gray-600 dark:text-gray-400">
<p><strong>Current:</strong> Download/upload speeds (bytes/s), updated every 30s.</p>
<p><strong>Peak:</strong> Highest observed rates since page load.</p>
<p><strong>Graph:</strong> View 10-min traffic trends (download=green, upload=blue).</p>
<p><strong>Totals:</strong> Cumulative bytes in/out since VM start.</p>
</div>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Updates every 30 seconds</p>
</div>
<div class="flex items-center gap-2">
<button type="button" @click="exportLiveNetworkTrafficToCSV()"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-2 py-1 rounded text-xs font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-1"
title="Export to CSV">
<x-ri-download-line class="size-3" />
Export
</button>
</div>
</div>
<!-- Network Traffic Sections -->
<div id="network-traffic-container" data-live-traffic-endpoint="{{ route('proxmox.server.live-traffic', $server) }}" data-initialized="false">
<!-- Initial placeholder - will be replaced with actual data on first load -->
<div class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm" id="network-traffic-placeholder">
<svg class="w-8 h-8 mx-auto mb-2 animate-spin text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Connecting to network monitor...
</div>
</div>
</div>
<!-- Notes Section -->
<!-- Notes Section -->
<div class="mt-4">
<div id="notes-section" class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 flex flex-col" x-data="{ showExportErrorModal: false }" x-cloak>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
@@ -967,653 +895,8 @@
@once
<style>
.network-flow-bar { position: relative; height: 6px; background: rgba(128, 128, 128, 0.1); border-radius: 9999px; overflow: hidden; }
.network-flow-fill { position: absolute; top: 0; left: 0; height: 100%; border-radius: 9999px; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); }
.network-flow-fill.download { background: linear-gradient(90deg, rgba(34, 197, 94, 0.3), rgba(34, 197, 94, 0.8), rgba(34, 197, 94, 0.3)); }
.network-flow-fill.upload { background: linear-gradient(90deg, rgba(59, 130, 246, 0.3), rgba(59, 130, 246, 0.8), rgba(59, 130, 246, 0.3)); }
.sparkline-chart { width: 100%; height: 32px; }
.chart-dropdown { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
.chart-dropdown.open { max-height: 350px; transition: max-height 0.5s ease-in; }
#network-traffic-container { min-height: 80px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
(() => {
// State variables
let pollInFlight = false;
let pollingEndpoint = null;
let networkData = null;
let charts = {};
let peakRates = {};
let pollInterval = null;
let isInitialized = false;
// Utility functions
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const formatRate = (bytesPerSec) => formatBytes(bytesPerSec) + '/s';
const createInterfaceCard = (interfaceData) => {
const interfaceId = interfaceData.id.replace(/[^a-z0-9]/gi, '-');
return `
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 w-full">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold flex items-center gap-1">
<svg class="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
${interfaceData.name}
</h3>
<span class="text-xs text-gray-500">30s refresh</span>
</div>
<button class="flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 chart-toggle text-xs" data-interface="${interfaceId}" title="Toggle graph">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Graph
</button>
</div>
<!-- Download Speed -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<svg class="w-3 h-3 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"></path>
</svg>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">Down</span>
<span class="text-sm font-bold text-success" data-traffic="${interfaceId}-download-rate">0 B/s</span>
</div>
<div class="text-right">
<span class="text-xs text-gray-400">Peak: </span>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" data-traffic="${interfaceId}-download-peak">0 B/s</span>
</div>
</div>
<div class="network-flow-bar">
<div class="network-flow-fill download" data-traffic="${interfaceId}-download-bar"></div>
</div>
</div>
<!-- Upload Speed -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-1">
<svg class="w-3 h-3 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">Up</span>
<span class="text-sm font-bold text-info" data-traffic="${interfaceId}-upload-rate">0 B/s</span>
</div>
<div class="text-right">
<span class="text-xs text-gray-400">Peak: </span>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400" data-traffic="${interfaceId}-upload-peak">0 B/s</span>
</div>
</div>
<div class="network-flow-bar">
<div class="network-flow-fill upload" data-traffic="${interfaceId}-upload-bar"></div>
</div>
</div>
<!-- Network Graph Dropdown -->
<div class="chart-dropdown" data-chart-container="${interfaceId}">
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-2">
<canvas id="chart-${interfaceId}" class="w-full" style="height: 150px;"></canvas>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-3 gap-2 text-center mt-2">
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">Combined</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-combined">0 B/s</p>
</div>
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">In</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-total-in">0 MB</p>
</div>
<div class="p-2 rounded bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-400 uppercase">Out</p>
<p class="text-xs font-semibold" data-traffic="${interfaceId}-total-out">0 MB</p>
</div>
</div>
</div>
`;
};
const updateInterfaceData = (interfaceData) => {
const interfaceId = interfaceData.id.replace(/[^a-z0-9]/gi, '-');
const scale = networkData.scale || (100 * 1024 * 1024);
// Use only per-interface rates (no fallback to aggregate totals)
// This ensures each adapter shows its own individual statistics
let downloadRate = interfaceData.downloadRate ?? 0;
let uploadRate = interfaceData.uploadRate ?? 0;
// Debug: Log interface data to verify each interface gets unique data
console.log(`Updating interface ${interfaceId} (${interfaceData.id}):`, {
downloadRate,
uploadRate,
totalIn: interfaceData.totalIn,
totalOut: interfaceData.totalOut,
hasHistory: !!interfaceData.history,
historyLength: interfaceData.history?.download?.length || 0
});
// Update peak rates
if (!peakRates[interfaceId]) {
peakRates[interfaceId] = { download: 0, upload: 0 };
}
peakRates[interfaceId].download = Math.max(peakRates[interfaceId].download, downloadRate);
peakRates[interfaceId].upload = Math.max(peakRates[interfaceId].upload, uploadRate);
// Update text elements
const setText = (key, value) => {
const el = document.querySelector(`[data-traffic="${interfaceId}-${key}"]`);
if (el) el.textContent = value;
};
setText('download-rate', formatRate(downloadRate));
setText('upload-rate', formatRate(uploadRate));
setText('download-peak', formatRate(peakRates[interfaceId].download));
setText('upload-peak', formatRate(peakRates[interfaceId].upload));
setText('combined', formatRate(downloadRate + uploadRate));
// Use per-interface totals (unique for each adapter)
const totalIn = interfaceData.totalIn ?? 0;
const totalOut = interfaceData.totalOut ?? 0;
setText('total-in', formatBytes(totalIn));
setText('total-out', formatBytes(totalOut));
// Update progress bars using individual interface rates
const downloadPercent = Math.min(100, (downloadRate / scale) * 100);
const uploadPercent = Math.min(100, (uploadRate / scale) * 100);
const downloadBar = document.querySelector(`[data-traffic="${interfaceId}-download-bar"]`);
const uploadBar = document.querySelector(`[data-traffic="${interfaceId}-upload-bar"]`);
if (downloadBar) {
downloadBar.style.width = `${downloadPercent}%`;
}
if (uploadBar) {
uploadBar.style.width = `${uploadPercent}%`;
}
// Check if chart container is open but chart doesn't exist - create it
const chartContainer = document.querySelector(`[data-chart-container="${interfaceId}"]`);
if (chartContainer && chartContainer.classList.contains('open') && !charts[interfaceId]) {
// Chart container is open but chart wasn't created - try to create it now
if (interfaceData.history) {
const historyLength = Math.max(
interfaceData.history.download?.length || 0,
interfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
console.log('Creating chart for', interfaceId, 'on data update (container was open)');
setTimeout(() => createChart(interfaceId, interfaceData), 100);
}
}
}
// Update chart if visible - get latest data from networkData
if (charts[interfaceId]) {
const chart = charts[interfaceId];
// Get the latest interface data from networkData
const latestInterfaceData = networkData?.interfaces?.find(i =>
i.id.replace(/[^a-z0-9]/gi, '-') === interfaceId
);
if (latestInterfaceData && latestInterfaceData.history) {
const historyLength = Math.max(
latestInterfaceData.history.download?.length || 0,
latestInterfaceData.history.upload?.length || 0
);
if (historyLength > 0) {
// Generate labels based on data length (assuming ~30 second intervals for hour timeframe)
const labels = Array.from({ length: historyLength }, (_, i) => {
const minutesAgo = Math.round((historyLength - i - 1) * 0.5);
return minutesAgo === 0 ? 'Now' : `${minutesAgo}m`;
});
// Ensure all values are proper numbers (handle null, undefined, NaN)
const downloadData = (latestInterfaceData.history.download || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
const uploadData = (latestInterfaceData.history.upload || [])
.slice(-20)
.map(v => {
const num = Number(v);
return (isNaN(num) || num === null || num === undefined) ? 0 : num;
});
chart.data.labels = labels.slice(-20);
chart.data.datasets[0].data = downloadData;
chart.data.datasets[1].data = uploadData;
chart.update('none');
}
}
}
};
const renderInterfaces = () => {
const container = document.getElementById('network-traffic-container');
if (!container || !networkData) return;
if (networkData.interfaces.length === 0) {
container.innerHTML = `
<div class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-400" 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>
No network interfaces detected
</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,
}
}
}
}
}
});
};
// Simplified poll - renders once, then only updates values
const poll = async () => {
if (pollInFlight || !pollingEndpoint) return;
pollInFlight = true;
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',
});
if (response.ok) {
const data = await response.json();
const container = document.getElementById('network-traffic-container');
if (!container) {
pollInFlight = false;
return;
}
// Store data globally
networkData = data;
window.networkTrafficData = data;
// Use data-initialized attribute to track first load
const needsRender = container.dataset.initialized !== 'true';
if (data.interfaces && data.interfaces.length > 0) {
if (needsRender) {
// First load - render interfaces and mark as initialized
renderInterfaces();
container.dataset.initialized = 'true';
requestAnimationFrame(() => {
data.interfaces.forEach(updateInterfaceData);
});
} else {
// Already initialized - just update values (no re-render)
data.interfaces.forEach(updateInterfaceData);
}
}
}
} catch (error) {
console.error('Network traffic poll error:', error);
const container = document.getElementById('network-traffic-container');
// Only show error if never initialized
if (container && container.dataset.initialized !== 'true') {
container.innerHTML = `
<div class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
<svg class="w-8 h-8 mx-auto mb-2 text-red-400" 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>
Failed to load network data
</div>
`;
}
} finally {
pollInFlight = false;
}
};
// Simplified bootstrap - initializes once
const bootstrap = () => {
if (isInitialized) return;
const container = document.getElementById('network-traffic-container');
if (!container) return;
pollingEndpoint = container.dataset.liveTrafficEndpoint;
if (!pollingEndpoint) return;
isInitialized = true;
if (pollInterval) {
clearInterval(pollInterval);
}
// Poll immediately and set up interval
poll();
pollInterval = setInterval(poll, 30000);
};
window.initNetworkTraffic = () => {
isInitialized = false;
bootstrap();
};
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap);
} else {
setTimeout(bootstrap, 10);
}
// Handle Livewire navigation
document.addEventListener('livewire:navigated', () => {
Object.keys(charts).forEach(id => { try { charts[id]?.destroy(); } catch(e) {} });
charts = {};
isInitialized = false;
setTimeout(bootstrap, 50);
});
window.addEventListener('beforeunload', () => {
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);
};
// Export helper function for downloading text files
window.downloadText = function(textContent, filename) {
const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8;' });
const link = document.createElement('a');
@@ -1626,73 +909,6 @@
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 = '';
@@ -1729,11 +945,8 @@
const filename = 'notes-' + new Date().toISOString().split('T')[0] + '.txt';
window.downloadText(notesText, filename);
};
// Store network data globally for export access
window.networkTrafficData = null;
</script>
@endonce
@endif
</x-proxmox::layout>
</x-proxmox::layout>