refactor: remove live network traffic section and related export functionality
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user