|
@@ -136,6 +136,7 @@ function navigate(tabName) {
|
|
|
l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
|
|
l2tp: { title: 'L2TP VPN Client', sub: 'Layer 2 Tunneling Protocol via xl2tpd' },
|
|
|
pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
|
|
pptp: { title: 'PPTP VPN Client', sub: 'Point-to-Point Tunneling Protocol with MPPE-128 encryption' },
|
|
|
vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
|
|
vrrp: { title: 'VRRP High Availability', sub: 'Virtual Router Redundancy Protocol, active-backup gateway failover & Virtual IP (VIP) routing' },
|
|
|
|
|
+ bwtest: { title: 'Bandwidth Test Benchmark', sub: 'High-speed line-rate throughput benchmark, token-bucket rate limiting, loss & jitter telemetry' },
|
|
|
tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
|
|
tools: { title: 'Diagnostic & Troubleshooting Tools', sub: 'Network latency analysis, ICMP/MTR telemetry, path tracing, DNS resolution and TCP probing' },
|
|
|
persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
|
|
persistence: { title: 'Debian Configuration', sub: 'Native /etc/network/interfaces.d/ persistence manager' }
|
|
|
};
|
|
};
|
|
@@ -181,6 +182,7 @@ function navigate(tabName) {
|
|
|
if (tabName === 'dhcp-client') loadDHCPClients();
|
|
if (tabName === 'dhcp-client') loadDHCPClients();
|
|
|
if (tabName === 'dhcp-server') loadDHCPServer();
|
|
if (tabName === 'dhcp-server') loadDHCPServer();
|
|
|
if (tabName === 'vrrp') loadVRRP();
|
|
if (tabName === 'vrrp') loadVRRP();
|
|
|
|
|
+ if (tabName === 'bwtest') loadBwTestView();
|
|
|
setTimeout(initTableSorting, 50);
|
|
setTimeout(initTableSorting, 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -268,6 +270,7 @@ async function fetchData(isManual = false) {
|
|
|
if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
|
|
if (state.activeTab === 'dhcp-client') loadDHCPClients(false);
|
|
|
if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
|
|
if (state.activeTab === 'dhcp-server') loadDHCPServer(false);
|
|
|
if (state.activeTab === 'vrrp') loadVRRP(false);
|
|
if (state.activeTab === 'vrrp') loadVRRP(false);
|
|
|
|
|
+ fetchBwTestServerStatus();
|
|
|
|
|
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error('Failed to fetch router state:', err);
|
|
console.error('Failed to fetch router state:', err);
|
|
@@ -13884,4 +13887,568 @@ if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined')
|
|
|
docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
docTableObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// =========================================================================
|
|
|
|
|
+// BANDWIDTH TEST (BwTest) BENCHMARK ENGINE
|
|
|
|
|
+// =========================================================================
|
|
|
|
|
+
|
|
|
|
|
+const bwtestState = {
|
|
|
|
|
+ running: false,
|
|
|
|
|
+ pollTimer: null,
|
|
|
|
|
+ eventSource: null,
|
|
|
|
|
+ chartPoints: [],
|
|
|
|
|
+ maxChartPoints: 60,
|
|
|
|
|
+ serverConfig: null,
|
|
|
|
|
+ peakTx: 0,
|
|
|
|
|
+ peakRx: 0,
|
|
|
|
|
+ targetDuration: 10,
|
|
|
|
|
+ lastSampleIndex: 0
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+async function loadBwTestView() {
|
|
|
|
|
+ fetchBwTestServerStatus();
|
|
|
|
|
+ loadBwTestHistory();
|
|
|
|
|
+ resizeBwTestCanvas();
|
|
|
|
|
+ checkActiveBwTest();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function fetchBwTestServerStatus() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/server`);
|
|
|
|
|
+ if (!res.ok) return;
|
|
|
|
|
+ const s = await res.json();
|
|
|
|
|
+ bwtestState.serverConfig = s;
|
|
|
|
|
+
|
|
|
|
|
+ // Update indicator and badges
|
|
|
|
|
+ const indicator = document.getElementById('bwtest-server-indicator');
|
|
|
|
|
+ const badge = document.getElementById('bwtest-server-badge');
|
|
|
|
|
+ const sideBadge = document.getElementById('badge-bwtest-server');
|
|
|
|
|
+
|
|
|
|
|
+ if (s.enabled && s.running) {
|
|
|
|
|
+ if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-emerald-400 animate-pulse';
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = `Port ${s.port} (Active)`;
|
|
|
|
|
+ badge.className = 'text-emerald-400 font-bold';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sideBadge) {
|
|
|
|
|
+ sideBadge.innerText = s.port;
|
|
|
|
|
+ sideBadge.className = 'text-[10px] bg-emerald-950 text-emerald-300 border border-emerald-800/40 px-1.5 py-0.2 rounded-full font-mono';
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (indicator) indicator.className = 'w-2 h-2 rounded-full bg-slate-600';
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = `Disabled`;
|
|
|
|
|
+ badge.className = 'text-slate-500 font-bold';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (sideBadge) {
|
|
|
|
|
+ sideBadge.innerText = 'Off';
|
|
|
|
|
+ sideBadge.className = 'text-[10px] bg-slate-800 text-slate-500 border border-slate-700 px-1.5 py-0.2 rounded-full font-mono';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Modal fields
|
|
|
|
|
+ const toggle = document.getElementById('bwtest-server-enable-toggle');
|
|
|
|
|
+ if (toggle) toggle.checked = !!s.enabled;
|
|
|
|
|
+ const portInp = document.getElementById('bwtest-server-port-input');
|
|
|
|
|
+ if (portInp) portInp.value = s.port || 2000;
|
|
|
|
|
+ const actSess = document.getElementById('bwtest-modal-active-sessions');
|
|
|
|
|
+ if (actSess) actSess.innerText = s.active_sessions || 0;
|
|
|
|
|
+ const totServ = document.getElementById('bwtest-modal-total-served');
|
|
|
|
|
+ if (totServ) totServ.innerText = s.total_sessions_served || 0;
|
|
|
|
|
+ const totBytes = document.getElementById('bwtest-modal-total-bytes');
|
|
|
|
|
+ if (totBytes) totBytes.innerText = formatBytes(s.total_bytes_processed || 0);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to fetch BwTest server status:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function openBwTestServerModal() {
|
|
|
|
|
+ fetchBwTestServerStatus();
|
|
|
|
|
+ document.getElementById('modal-bwtest-server')?.classList.remove('hidden');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function closeBwTestServerModal() {
|
|
|
|
|
+ document.getElementById('modal-bwtest-server')?.classList.add('hidden');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function submitBwTestServerSettings(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ const enabled = document.getElementById('bwtest-server-enable-toggle').checked;
|
|
|
|
|
+ const port = parseInt(document.getElementById('bwtest-server-port-input').value, 10) || 2000;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/server`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ enabled, port, listen_addr: '0.0.0.0', allow_anonymous: true })
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const err = await res.json();
|
|
|
|
|
+ throw new Error(err.error || 'Failed to update server configuration');
|
|
|
|
|
+ }
|
|
|
|
|
+ showToast('BwTest Server', `Bandwidth Test server updated (Port ${port})`, 'success');
|
|
|
|
|
+ closeBwTestServerModal();
|
|
|
|
|
+ fetchBwTestServerStatus();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ showToast('Server Configuration Error', err.message, 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setBwTestTarget(target) {
|
|
|
|
|
+ const inp = document.getElementById('bwtest-input-target');
|
|
|
|
|
+ if (inp) inp.value = target;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setBwTestDirection(dir) {
|
|
|
|
|
+ const hiddenInp = document.getElementById('bwtest-input-direction');
|
|
|
|
|
+ if (hiddenInp) hiddenInp.value = dir;
|
|
|
|
|
+
|
|
|
|
|
+ const btnBoth = document.getElementById('bwtest-dir-both');
|
|
|
|
|
+ const btnTx = document.getElementById('bwtest-dir-tx');
|
|
|
|
|
+ const btnRx = document.getElementById('bwtest-dir-rx');
|
|
|
|
|
+
|
|
|
|
|
+ const activeCls = 'py-2 px-2 rounded-lg text-xs font-bold border transition text-center bg-emerald-600/20 border-emerald-500/50 text-emerald-300 shadow-sm';
|
|
|
|
|
+ const inactiveCls = 'py-2 px-2 rounded-lg text-xs font-medium border border-slate-800 bg-slate-950 text-slate-400 hover:text-white transition text-center';
|
|
|
|
|
+
|
|
|
|
|
+ if (btnBoth) btnBoth.className = dir === 'both' ? activeCls : inactiveCls;
|
|
|
|
|
+ if (btnTx) btnTx.className = dir === 'tx' ? activeCls : inactiveCls;
|
|
|
|
|
+ if (btnRx) btnRx.className = dir === 'rx' ? activeCls : inactiveCls;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setBwTestLimit(bps) {
|
|
|
|
|
+ const sel = document.getElementById('bwtest-input-limit');
|
|
|
|
|
+ if (sel) sel.value = String(bps);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function checkActiveBwTest() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/status`);
|
|
|
|
|
+ if (!res.ok) return;
|
|
|
|
|
+ const st = await res.json();
|
|
|
|
|
+ if (st.running) {
|
|
|
|
|
+ setBwTestRunningUI(true);
|
|
|
|
|
+ if (st.current_sample) handleBwTestSample(st.current_sample);
|
|
|
|
|
+ startBwTestTelemetry();
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to check active BwTest:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function startBwTest(e) {
|
|
|
|
|
+ if (e && e.preventDefault) e.preventDefault();
|
|
|
|
|
+
|
|
|
|
|
+ const target = document.getElementById('bwtest-input-target').value.trim() || '127.0.0.1';
|
|
|
|
|
+ const port = parseInt(document.getElementById('bwtest-input-port').value, 10) || 2000;
|
|
|
|
|
+ const direction = document.getElementById('bwtest-input-direction').value || 'both';
|
|
|
|
|
+ const protocol = document.getElementById('bwtest-input-protocol').value || 'udp';
|
|
|
|
|
+ const bandwidth_limit = parseInt(document.getElementById('bwtest-input-limit').value, 10) || 0;
|
|
|
|
|
+ const packet_size = parseInt(document.getElementById('bwtest-input-pktsize').value, 10) || 1472;
|
|
|
|
|
+ const streams = parseInt(document.getElementById('bwtest-input-streams').value, 10) || 1;
|
|
|
|
|
+ const duration = parseInt(document.getElementById('bwtest-input-duration').value, 10);
|
|
|
|
|
+
|
|
|
|
|
+ bwtestState.targetDuration = duration;
|
|
|
|
|
+ bwtestState.chartPoints = [];
|
|
|
|
|
+ bwtestState.peakTx = 0;
|
|
|
|
|
+ bwtestState.peakRx = 0;
|
|
|
|
|
+ bwtestState.lastSampleIndex = 0;
|
|
|
|
|
+
|
|
|
|
|
+ // Clear UI elements
|
|
|
|
|
+ document.getElementById('bwtest-stat-tx-mbps').innerText = '0.00';
|
|
|
|
|
+ document.getElementById('bwtest-stat-tx-peak').innerText = '0.00 Mbps';
|
|
|
|
|
+ document.getElementById('bwtest-stat-tx-pkts').innerText = '0';
|
|
|
|
|
+ document.getElementById('bwtest-stat-rx-mbps').innerText = '0.00';
|
|
|
|
|
+ document.getElementById('bwtest-stat-rx-peak').innerText = '0.00 Mbps';
|
|
|
|
|
+ document.getElementById('bwtest-stat-rx-pkts').innerText = '0';
|
|
|
|
|
+ document.getElementById('bwtest-stat-loss').innerText = '0.0%';
|
|
|
|
|
+ document.getElementById('bwtest-stat-jitter').innerText = '0.00 ms';
|
|
|
|
|
+ document.getElementById('bwtest-stat-time').innerText = '0.0s';
|
|
|
|
|
+ document.getElementById('bwtest-stat-data').innerText = '0.0 MB';
|
|
|
|
|
+ document.getElementById('bwtest-stat-target-dur').innerText = duration > 0 ? `/ ${duration}s` : '/ ∞';
|
|
|
|
|
+
|
|
|
|
|
+ const tbody = document.getElementById('bwtest-samples-tbody');
|
|
|
|
|
+ if (tbody) tbody.innerHTML = '';
|
|
|
|
|
+ document.getElementById('bwtest-sample-count').innerText = '0 samples';
|
|
|
|
|
+
|
|
|
|
|
+ setBwTestRunningUI(true);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/start`, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ target,
|
|
|
|
|
+ port,
|
|
|
|
|
+ direction,
|
|
|
|
|
+ protocol,
|
|
|
|
|
+ bandwidth_limit,
|
|
|
|
|
+ packet_size,
|
|
|
|
|
+ streams,
|
|
|
|
|
+ duration
|
|
|
|
|
+ })
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!res.ok) {
|
|
|
|
|
+ const err = await res.json();
|
|
|
|
|
+ throw new Error(err.error || 'Failed to start bandwidth test');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ showToast('Benchmark Started', `Target: ${target}:${port} (${direction.toUpperCase()}, ${protocol.toUpperCase()})`, 'info');
|
|
|
|
|
+ startBwTestTelemetry();
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ setBwTestRunningUI(false);
|
|
|
|
|
+ showToast('Start Failed', err.message, 'error');
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setBwTestRunningUI(running) {
|
|
|
|
|
+ bwtestState.running = running;
|
|
|
|
|
+ const startBtn = document.getElementById('bwtest-btn-start');
|
|
|
|
|
+ const stopBtn = document.getElementById('bwtest-btn-stop');
|
|
|
|
|
+ const badge = document.getElementById('bwtest-run-status-badge');
|
|
|
|
|
+ const ph = document.getElementById('bwtest-chart-placeholder');
|
|
|
|
|
+
|
|
|
|
|
+ if (running) {
|
|
|
|
|
+ if (startBtn) {
|
|
|
|
|
+ startBtn.disabled = true;
|
|
|
|
|
+ startBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (stopBtn) {
|
|
|
|
|
+ stopBtn.disabled = false;
|
|
|
|
|
+ stopBtn.classList.remove('opacity-40');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = 'TESTING...';
|
|
|
|
|
+ badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold animate-pulse';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (ph) ph.style.display = 'none';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (startBtn) {
|
|
|
|
|
+ startBtn.disabled = false;
|
|
|
|
|
+ startBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (stopBtn) {
|
|
|
|
|
+ stopBtn.disabled = true;
|
|
|
|
|
+ stopBtn.classList.add('opacity-40');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = 'IDLE';
|
|
|
|
|
+ badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-slate-800 text-slate-400 border border-slate-700 font-semibold';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function stopBwTest() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fetch(`${API_BASE}/tools/bwtest/stop`, { method: 'POST' });
|
|
|
|
|
+ showToast('Test Stopped', 'Bandwidth test terminated by user', 'info');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to stop test:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function startBwTestTelemetry() {
|
|
|
|
|
+ if (bwtestState.pollTimer) clearInterval(bwtestState.pollTimer);
|
|
|
|
|
+
|
|
|
|
|
+ bwtestState.pollTimer = setInterval(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/status`);
|
|
|
|
|
+ if (!res.ok) return;
|
|
|
|
|
+ const st = await res.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (st.recent_samples && st.recent_samples.length > 0) {
|
|
|
|
|
+ // Process new samples
|
|
|
|
|
+ const newSamples = st.recent_samples.slice(bwtestState.lastSampleIndex);
|
|
|
|
|
+ newSamples.forEach(s => handleBwTestSample(s));
|
|
|
|
|
+ bwtestState.lastSampleIndex = st.recent_samples.length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!st.running) {
|
|
|
|
|
+ clearInterval(bwtestState.pollTimer);
|
|
|
|
|
+ bwtestState.pollTimer = null;
|
|
|
|
|
+ setBwTestRunningUI(false);
|
|
|
|
|
+ handleBwTestFinish(st.summary, st.error);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('BwTest polling error:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 400);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleBwTestSample(s) {
|
|
|
|
|
+ if (!s) return;
|
|
|
|
|
+
|
|
|
|
|
+ const txMbps = s.tx_mbps || 0;
|
|
|
|
|
+ const rxMbps = s.rx_mbps || 0;
|
|
|
|
|
+
|
|
|
|
|
+ if (txMbps > bwtestState.peakTx) bwtestState.peakTx = txMbps;
|
|
|
|
|
+ if (rxMbps > bwtestState.peakRx) bwtestState.peakRx = rxMbps;
|
|
|
|
|
+
|
|
|
|
|
+ // KPI updates
|
|
|
|
|
+ const elTx = document.getElementById('bwtest-stat-tx-mbps');
|
|
|
|
|
+ if (elTx) elTx.innerText = txMbps.toFixed(2);
|
|
|
|
|
+ const elTxPeak = document.getElementById('bwtest-stat-tx-peak');
|
|
|
|
|
+ if (elTxPeak) elTxPeak.innerText = `${bwtestState.peakTx.toFixed(2)} Mbps`;
|
|
|
|
|
+ const elTxPkts = document.getElementById('bwtest-stat-tx-pkts');
|
|
|
|
|
+ if (elTxPkts) elTxPkts.innerText = s.tx_packets || 0;
|
|
|
|
|
+
|
|
|
|
|
+ const elRx = document.getElementById('bwtest-stat-rx-mbps');
|
|
|
|
|
+ if (elRx) elRx.innerText = rxMbps.toFixed(2);
|
|
|
|
|
+ const elRxPeak = document.getElementById('bwtest-stat-rx-peak');
|
|
|
|
|
+ if (elRxPeak) elRxPeak.innerText = `${bwtestState.peakRx.toFixed(2)} Mbps`;
|
|
|
|
|
+ const elRxPkts = document.getElementById('bwtest-stat-rx-pkts');
|
|
|
|
|
+ if (elRxPkts) elRxPkts.innerText = s.rx_packets || 0;
|
|
|
|
|
+
|
|
|
|
|
+ const elLoss = document.getElementById('bwtest-stat-loss');
|
|
|
|
|
+ if (elLoss) {
|
|
|
|
|
+ elLoss.innerText = `${(s.loss_pct || 0).toFixed(1)}%`;
|
|
|
|
|
+ elLoss.className = (s.loss_pct > 1.0) ? 'text-2xl font-bold text-rose-400' : 'text-2xl font-bold text-emerald-400';
|
|
|
|
|
+ }
|
|
|
|
|
+ const elLostPkts = document.getElementById('bwtest-stat-lost-pkts');
|
|
|
|
|
+ if (elLostPkts) elLostPkts.innerText = s.lost_packets || 0;
|
|
|
|
|
+
|
|
|
|
|
+ const elJitter = document.getElementById('bwtest-stat-jitter');
|
|
|
|
|
+ if (elJitter) elJitter.innerText = `${(s.jitter_ms || 0).toFixed(2)} ms`;
|
|
|
|
|
+
|
|
|
|
|
+ const elTime = document.getElementById('bwtest-stat-time');
|
|
|
|
|
+ if (elTime) elTime.innerText = `${(s.elapsed_sec || 0).toFixed(1)}s`;
|
|
|
|
|
+
|
|
|
|
|
+ const elData = document.getElementById('bwtest-stat-data');
|
|
|
|
|
+ if (elData) elData.innerText = formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0));
|
|
|
|
|
+
|
|
|
|
|
+ const elGraphRate = document.getElementById('bwtest-graph-rate');
|
|
|
|
|
+ if (elGraphRate) elGraphRate.innerText = `TX: ${txMbps.toFixed(1)} Mbps • RX: ${rxMbps.toFixed(1)} Mbps`;
|
|
|
|
|
+
|
|
|
|
|
+ // Append point to chart buffer
|
|
|
|
|
+ bwtestState.chartPoints.push({
|
|
|
|
|
+ elapsed: s.elapsed_sec || 0,
|
|
|
|
|
+ tx: txMbps,
|
|
|
|
|
+ rx: rxMbps
|
|
|
|
|
+ });
|
|
|
|
|
+ if (bwtestState.chartPoints.length > bwtestState.maxChartPoints) {
|
|
|
|
|
+ bwtestState.chartPoints.shift();
|
|
|
|
|
+ }
|
|
|
|
|
+ drawBwTestChart();
|
|
|
|
|
+
|
|
|
|
|
+ // Add row to breakdown table
|
|
|
|
|
+ const tbody = document.getElementById('bwtest-samples-tbody');
|
|
|
|
|
+ if (tbody) {
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
|
|
|
|
|
+ tr.innerHTML = `
|
|
|
|
|
+ <td class="py-1.5 px-3 font-semibold text-white">${(s.elapsed_sec || 0).toFixed(1)}s</td>
|
|
|
|
|
+ <td class="py-1.5 px-3 font-bold text-cyan-400">${txMbps.toFixed(2)} Mbps</td>
|
|
|
|
|
+ <td class="py-1.5 px-3 font-bold text-purple-400">${rxMbps.toFixed(2)} Mbps</td>
|
|
|
|
|
+ <td class="py-1.5 px-3 ${(s.loss_pct > 1.0) ? 'text-rose-400 font-bold' : 'text-emerald-400'}">${(s.loss_pct || 0).toFixed(1)}%</td>
|
|
|
|
|
+ <td class="py-1.5 px-3 text-slate-300">${(s.jitter_ms || 0).toFixed(2)} ms</td>
|
|
|
|
|
+ <td class="py-1.5 px-3 text-slate-400">${formatBytes((s.tx_bytes || 0) + (s.rx_bytes || 0))}</td>
|
|
|
|
|
+ `;
|
|
|
|
|
+ tbody.insertBefore(tr, tbody.firstChild);
|
|
|
|
|
+
|
|
|
|
|
+ const countEl = document.getElementById('bwtest-sample-count');
|
|
|
|
|
+ if (countEl) countEl.innerText = `${tbody.children.length} samples`;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function handleBwTestFinish(summary, error) {
|
|
|
|
|
+ const badge = document.getElementById('bwtest-run-status-badge');
|
|
|
|
|
+ if (error) {
|
|
|
|
|
+ showToast('Benchmark Error', error, 'error');
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = 'ERROR';
|
|
|
|
|
+ badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-rose-500/20 text-rose-300 border border-rose-500/40 font-bold';
|
|
|
|
|
+ }
|
|
|
|
|
+ } else if (summary) {
|
|
|
|
|
+ const avgTx = summary.avg_tx_mbps.toFixed(1);
|
|
|
|
|
+ const avgRx = summary.avg_rx_mbps.toFixed(1);
|
|
|
|
|
+ showToast('Benchmark Complete', `Average TX: ${avgTx} Mbps | RX: ${avgRx} Mbps (Loss: ${summary.avg_loss_pct.toFixed(1)}%)`, 'success');
|
|
|
|
|
+ if (badge) {
|
|
|
|
|
+ badge.innerText = 'COMPLETED';
|
|
|
|
|
+ badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 font-bold';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ loadBwTestHistory();
|
|
|
|
|
+ fetchBwTestServerStatus();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function resizeBwTestCanvas() {
|
|
|
|
|
+ const canvas = document.getElementById('bwtest-chart');
|
|
|
|
|
+ if (!canvas || !canvas.parentElement) return;
|
|
|
|
|
+ const rect = canvas.parentElement.getBoundingClientRect();
|
|
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
+ canvas.width = rect.width * dpr;
|
|
|
|
|
+ canvas.height = rect.height * dpr;
|
|
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
|
|
+ ctx.scale(dpr, dpr);
|
|
|
|
|
+ drawBwTestChart();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+window.addEventListener('resize', () => {
|
|
|
|
|
+ if (state.activeTab === 'bwtest') resizeBwTestCanvas();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+function drawBwTestChart() {
|
|
|
|
|
+ const canvas = document.getElementById('bwtest-chart');
|
|
|
|
|
+ if (!canvas) return;
|
|
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
+ const width = canvas.width / dpr;
|
|
|
|
|
+ const height = canvas.height / dpr;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.clearRect(0, 0, width, height);
|
|
|
|
|
+
|
|
|
|
|
+ const points = bwtestState.chartPoints;
|
|
|
|
|
+ if (!points || points.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Determine scale ceiling
|
|
|
|
|
+ let maxRate = 10;
|
|
|
|
|
+ for (const p of points) {
|
|
|
|
|
+ if (p.tx > maxRate) maxRate = p.tx;
|
|
|
|
|
+ if (p.rx > maxRate) maxRate = p.rx;
|
|
|
|
|
+ }
|
|
|
|
|
+ maxRate = Math.ceil(maxRate * 1.25); // 25% headroom
|
|
|
|
|
+
|
|
|
|
|
+ const paddingLeft = 55;
|
|
|
|
|
+ const paddingBottom = 24;
|
|
|
|
|
+ const paddingTop = 12;
|
|
|
|
|
+ const paddingRight = 16;
|
|
|
|
|
+ const chartW = width - paddingLeft - paddingRight;
|
|
|
|
|
+ const chartH = height - paddingTop - paddingBottom;
|
|
|
|
|
+
|
|
|
|
|
+ // Grid Lines and Y-Axis labels
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(51, 65, 85, 0.4)';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.fillStyle = '#94a3b8';
|
|
|
|
|
+ ctx.font = '10px monospace';
|
|
|
|
|
+ ctx.textAlign = 'right';
|
|
|
|
|
+
|
|
|
|
|
+ const gridSteps = 4;
|
|
|
|
|
+ for (let i = 0; i <= gridSteps; i++) {
|
|
|
|
|
+ const val = (maxRate / gridSteps) * i;
|
|
|
|
|
+ const y = paddingTop + chartH - (i / gridSteps) * chartH;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(paddingLeft, y);
|
|
|
|
|
+ ctx.lineTo(width - paddingRight, y);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.fillText(`${Math.round(val)}M`, paddingLeft - 8, y + 3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Draw TX Line (Cyan #22d3ee)
|
|
|
|
|
+ drawSeries(ctx, points, p => p.tx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#22d3ee', 'rgba(34, 211, 238, 0.15)');
|
|
|
|
|
+
|
|
|
|
|
+ // Draw RX Line (Purple #a855f7)
|
|
|
|
|
+ drawSeries(ctx, points, p => p.rx, maxRate, paddingLeft, paddingTop, chartW, chartH, '#a855f7', 'rgba(168, 85, 247, 0.15)');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function drawSeries(ctx, points, valFn, maxVal, left, top, w, h, strokeColor, fillColor) {
|
|
|
|
|
+ if (points.length < 2) return;
|
|
|
|
|
+
|
|
|
|
|
+ const stepX = w / Math.max(points.length - 1, 1);
|
|
|
|
|
+
|
|
|
|
|
+ // Gradient area fill
|
|
|
|
|
+ const grad = ctx.createLinearGradient(0, top, 0, top + h);
|
|
|
|
|
+ grad.addColorStop(0, fillColor);
|
|
|
|
|
+ grad.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
|
|
|
|
+
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ points.forEach((p, idx) => {
|
|
|
|
|
+ const x = left + idx * stepX;
|
|
|
|
|
+ const val = valFn(p);
|
|
|
|
|
+ const y = top + h - (val / maxVal) * h;
|
|
|
|
|
+ if (idx === 0) {
|
|
|
|
|
+ ctx.moveTo(x, y);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.lineTo(x, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Complete path for fill
|
|
|
|
|
+ ctx.lineTo(left + (points.length - 1) * stepX, top + h);
|
|
|
|
|
+ ctx.lineTo(left, top + h);
|
|
|
|
|
+ ctx.closePath();
|
|
|
|
|
+ ctx.fillStyle = grad;
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+
|
|
|
|
|
+ // Draw line stroke
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.lineWidth = 2.5;
|
|
|
|
|
+ ctx.strokeStyle = strokeColor;
|
|
|
|
|
+ points.forEach((p, idx) => {
|
|
|
|
|
+ const x = left + idx * stepX;
|
|
|
|
|
+ const val = valFn(p);
|
|
|
|
|
+ const y = top + h - (val / maxVal) * h;
|
|
|
|
|
+ if (idx === 0) {
|
|
|
|
|
+ ctx.moveTo(x, y);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.lineTo(x, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+
|
|
|
|
|
+ // Draw end point halo
|
|
|
|
|
+ const lastP = points[points.length - 1];
|
|
|
|
|
+ const lastX = left + (points.length - 1) * stepX;
|
|
|
|
|
+ const lastY = top + h - (valFn(lastP) / maxVal) * h;
|
|
|
|
|
+
|
|
|
|
|
+ ctx.fillStyle = strokeColor;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(lastX, lastY, 4, 0, 2 * Math.PI);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadBwTestHistory() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`${API_BASE}/tools/bwtest/history`);
|
|
|
|
|
+ if (!res.ok) return;
|
|
|
|
|
+ const list = await res.json();
|
|
|
|
|
+ renderBwTestHistory(Array.isArray(list) ? list : []);
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('Failed to load BwTest history:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderBwTestHistory(list) {
|
|
|
|
|
+ const tbody = document.getElementById('bwtest-history-tbody');
|
|
|
|
|
+ if (!tbody) return;
|
|
|
|
|
+
|
|
|
|
|
+ if (list.length === 0) {
|
|
|
|
|
+ tbody.innerHTML = `<tr><td colspan="10" class="p-6 text-center text-slate-500 text-xs font-sans">No past benchmark runs recorded yet.</td></tr>`;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tbody.innerHTML = '';
|
|
|
|
|
+ list.forEach(item => {
|
|
|
|
|
+ const s = item.summary || {};
|
|
|
|
|
+ const tr = document.createElement('tr');
|
|
|
|
|
+ tr.className = 'hover:bg-slate-800/40 transition font-mono text-xs';
|
|
|
|
|
+
|
|
|
|
|
+ const dirBadge = item.direction === 'both'
|
|
|
|
|
+ ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 uppercase font-semibold">Both</span>`
|
|
|
|
|
+ : item.direction === 'tx'
|
|
|
|
|
+ ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 uppercase font-semibold">TX Only</span>`
|
|
|
|
|
+ : `<span class="px-1.5 py-0.5 rounded text-[10px] bg-purple-500/20 text-purple-300 border border-purple-500/30 uppercase font-semibold">RX Only</span>`;
|
|
|
|
|
+
|
|
|
|
|
+ const protoBadge = item.protocol === 'tcp'
|
|
|
|
|
+ ? `<span class="px-1.5 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-300 border border-blue-500/30 font-semibold uppercase">TCP</span>`
|
|
|
|
|
+ : `<span class="px-1.5 py-0.5 rounded text-[10px] bg-amber-500/20 text-amber-300 border border-amber-500/30 font-semibold uppercase">UDP</span>`;
|
|
|
|
|
+
|
|
|
|
|
+ const dateStr = item.timestamp ? new Date(item.timestamp).toLocaleString() : '-';
|
|
|
|
|
+
|
|
|
|
|
+ tr.innerHTML = `
|
|
|
|
|
+ <td class="py-2.5 px-4 font-bold text-white font-mono text-[11px]">${escapeHtml(item.id || '-')}</td>
|
|
|
|
|
+ <td class="py-2.5 px-4 text-emerald-400 font-mono font-medium">${escapeHtml(item.target || '-')}</td>
|
|
|
|
|
+ <td class="py-2.5 px-4">${protoBadge}</td>
|
|
|
|
|
+ <td class="py-2.5 px-4">${dirBadge}</td>
|
|
|
|
|
+ <td class="py-2.5 px-4 text-slate-300">${(s.duration_sec || 0).toFixed(1)}s</td>
|
|
|
|
|
+ <td class="py-2.5 px-4 font-bold text-cyan-400 font-mono">${(s.avg_tx_mbps || 0).toFixed(1)} / ${(s.peak_tx_mbps || 0).toFixed(1)} <span class="text-[10px] text-slate-400 font-normal">Mbps</span></td>
|
|
|
|
|
+ <td class="py-2.5 px-4 font-bold text-purple-400 font-mono">${(s.avg_rx_mbps || 0).toFixed(1)} / ${(s.peak_rx_mbps || 0).toFixed(1)} <span class="text-[10px] text-slate-400 font-normal">Mbps</span></td>
|
|
|
|
|
+ <td class="py-2.5 px-4 ${(s.avg_loss_pct > 1.0) ? 'text-rose-400 font-bold' : 'text-emerald-400'} font-mono">${(s.avg_loss_pct || 0).toFixed(1)}% <span class="text-[10px] text-slate-400 font-normal">(${s.total_lost_packets || 0})</span></td>
|
|
|
|
|
+ <td class="py-2.5 px-4 text-slate-300 font-mono">${(s.avg_jitter_ms || 0).toFixed(2)} ms</td>
|
|
|
|
|
+ <td class="py-2.5 px-4 text-slate-400 text-[11px] font-sans">${escapeHtml(dateStr)}</td>
|
|
|
|
|
+ `;
|
|
|
|
|
+ tbody.appendChild(tr);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
|