Files
pyMC_Repeater/repeater/templates/dashboard.html
T

1299 lines
49 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<title>pyMC Repeater Stats</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1"></script>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="layout">
<!-- Navigation Component -->
<!-- NAVIGATION_PLACEHOLDER -->
<!-- Main Content -->
<main class="content">
<header>
<h1>Repeater Dashboard</h1>
<div class="header-info">
<span>System Status: <strong>Active</strong></span>
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
</div>
</header>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card success">
<div class="stat-label">RX Packets</div>
<div class="stat-value" id="rx-count">{{ rx_count }}<span class="stat-unit">total</span></div>
</div>
<div class="stat-card success">
<div class="stat-label">Forwarded</div>
<div class="stat-value" id="forwarded-count">{{ forwarded_count }}<span class="stat-unit">packets</span></div>
</div>
<div class="stat-card warning">
<div class="stat-label">Uptime</div>
<div class="stat-value" id="uptime">{{ uptime_hours }}<span class="stat-unit">h</span></div>
</div>
<div class="stat-card error">
<div class="stat-label">Dropped</div>
<div class="stat-value" id="dropped-count">{{ dropped_count }}<span class="stat-unit">packets</span></div>
</div>
</div>
<!-- Charts Section -->
<h2>Performance Metrics</h2>
<div class="charts-grid">
<div class="chart-card">
<h3>Packet Rate (RX/TX per hour)</h3>
<div class="chart-container">
<canvas id="packetRateChart"></canvas>
</div>
</div>
<div class="chart-card">
<h3>Signal Quality Distribution</h3>
<div class="chart-container">
<canvas id="signalQualityChart"></canvas>
</div>
</div>
</div>
<!-- Packets Table -->
<h2>Recent Packets</h2>
<div class="table-container">
<table>
<thead>
<tr>
<th>Time</th>
<th>Type</th>
<th>Route</th>
<th>Len</th>
<th>Path / Hashes</th>
<th>RSSI</th>
<th>SNR</th>
<th>Score</th>
<th>TX Delay</th>
<th>Status</th>
</tr>
</thead>
<tbody id="packet-table">
<tr>
<td colspan="11" class="empty-message">
No packets received yet - waiting for traffic...
</td>
</tr>
</tbody>
</table>
</div>
<div class="refresh-info">
Real-time updates enabled
</div>
</main>
</div>
<!-- Packet Details Dialog -->
<div id="packet-dialog" class="dialog-overlay">
<div class="dialog-content">
<div class="dialog-header">
<h3>Packet Details</h3>
<button class="dialog-close" onclick="closePacketDialog()">&times;</button>
</div>
<div class="dialog-body">
<div class="packet-details-grid">
<!-- Content will be populated by JavaScript -->
</div>
</div>
</div>
</div>
<script>
// Initialize charts
let packetRateChart = null;
let signalQualityChart = null;
function initCharts() {
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#d4d4d4'
}
},
filler: {
propagate: true
}
},
scales: {
x: {
ticks: { color: '#999' },
grid: { color: '#333' }
},
y: {
ticks: { color: '#999' },
grid: { color: '#333' }
}
}
};
// Packet rate chart
let packetRateCtx = document.getElementById('packetRateChart').getContext('2d');
packetRateChart = new Chart(packetRateCtx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'RX/hour',
data: [],
borderColor: '#4ec9b0',
backgroundColor: 'rgba(78, 201, 176, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: '#4ec9b0',
pointBorderColor: '#4ec9b0'
},
{
label: 'TX/hour',
data: [],
borderColor: '#6a9955',
backgroundColor: 'rgba(106, 153, 85, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointBackgroundColor: '#6a9955',
pointBorderColor: '#6a9955'
}
]
},
options: chartOptions
});
// Signal quality chart
let signalQualityCtx = document.getElementById('signalQualityChart').getContext('2d');
signalQualityChart = new Chart(signalQualityCtx, {
type: 'bar',
data: {
labels: ['Excellent', 'Good', 'Fair', 'Poor'],
datasets: [{
label: 'Packet Count',
data: [0, 0, 0, 0],
backgroundColor: ['#6a9955', '#4ec9b0', '#dcdcaa', '#f48771'],
borderRadius: 4
}]
},
options: chartOptions
});
}
function updateStats() {
fetch('/api/stats')
.then(r => r.json())
.then(data => {
// Update stat cards
document.getElementById('rx-count').textContent = data.rx_count || 0;
document.getElementById('forwarded-count').textContent = data.forwarded_count || 0;
document.getElementById('dropped-count').textContent = data.dropped_count || 0;
// Safely update uptime - handle missing or invalid values
const uptimeSeconds = data.uptime_seconds || 0;
const uptimeHours = Math.floor(uptimeSeconds / 3600);
document.getElementById('uptime').innerHTML = uptimeHours + '<span class="stat-unit">h</span>';
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
// Update packet table with local hash for highlighting
if (data.recent_packets) {
const localHash = data.local_hash ? data.local_hash.replace('0x', '').toUpperCase() : null;
updatePacketTable(data.recent_packets, localHash);
}
// Update charts
updateCharts(data);
})
.catch(e => console.error('Error fetching stats:', e));
}
// Helper function to create signal strength bars with SNR value
function getSignalBars(snr, spreadingFactor = 8) {
// SNR thresholds per SF (matching engine.py)
const snrThresholds = {7: -7.5, 8: -10.0, 9: -12.5, 10: -15.0, 11: -17.5, 12: -20.0};
const threshold = snrThresholds[spreadingFactor] || -10.0;
let level, className;
if (snr >= threshold + 10) {
level = 4; className = 'signal-excellent';
} else if (snr >= threshold + 5) {
level = 3; className = 'signal-good';
} else if (snr >= threshold) {
level = 2; className = 'signal-fair';
} else {
level = 1; className = 'signal-poor';
}
return `<div class="signal-container">
<span class="signal-bars ${className}" title="Signal: ${className.replace('signal-', '')}">${'<span class="signal-bar"></span>'.repeat(4)}</span>
<span class="snr-value">${snr.toFixed(1)} dB</span>
</div>`;
}
// Helper function to display SNR for trace packets with path information
function getTraceSnrDisplay(pkt, localHash) {
if (!pkt.is_trace || !pkt.path_snr_details || pkt.path_snr_details.length === 0) {
// Regular packet or no path SNR data
return getSignalBars(pkt.snr);
}
// Build trace path SNR display
let pathSnrHtml = `<div class="trace-snr-container">`;
// Show received packet SNR first
pathSnrHtml += `<div class="rx-snr">${getSignalBars(pkt.snr)}</div>`;
// Show path SNRs if available
if (pkt.path_snr_details.length > 0) {
pathSnrHtml += `<div class="path-snrs">`;
pathSnrHtml += `<div class="path-snr-label">Path (${pkt.path_snr_details.length} hops):</div>`;
// Handle many hops - show first few and indicate if there are more
const maxDisplayHops = 4;
const hopsToShow = pkt.path_snr_details.slice(0, maxDisplayHops);
const hasMoreHops = pkt.path_snr_details.length > maxDisplayHops;
hopsToShow.forEach((pathSnr, index) => {
const isMyHash = localHash && pathSnr.hash === localHash;
const hashClass = isMyHash ? 'my-hash' : 'path-hash';
// Get signal quality class for color coding
let snrClass = 'snr-poor';
if (pathSnr.snr_db >= 10) snrClass = 'snr-excellent';
else if (pathSnr.snr_db >= 5) snrClass = 'snr-good';
else if (pathSnr.snr_db >= 0) snrClass = 'snr-fair';
pathSnrHtml += `<div class="path-snr-item">
<span class="hop-index">${index + 1}.</span>
<span class="${hashClass}">${pathSnr.hash}</span>
<span class="snr-value ${snrClass}">${pathSnr.snr_db.toFixed(1)}dB</span>
</div>`;
});
// Show indicator if there are more hops
if (hasMoreHops) {
const remainingCount = pkt.path_snr_details.length - maxDisplayHops;
pathSnrHtml += `<div class="path-snr-item">
<span class="more-hops">+${remainingCount} more hop${remainingCount > 1 ? 's' : ''}</span>
</div>`;
}
pathSnrHtml += `</div>`;
}
pathSnrHtml += `</div>`;
return pathSnrHtml;
} function updatePacketTable(packets, localHash) {
const tbody = document.getElementById('packet-table');
if (!packets || packets.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="9" class="empty-message">
No packets received yet - waiting for traffic...
</td>
</tr>
`;
return;
}
tbody.innerHTML = packets.slice(-20).map(pkt => {
const time = new Date(pkt.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
// Match pyMC_core PAYLOAD_TYPES exactly (from constants.py)
const typeNames = {
0: 'REQ',
1: 'RESPONSE',
2: 'TXT_MSG',
3: 'ACK',
4: 'ADVERT',
5: 'GRP_TXT',
6: 'GRP_DATA',
7: 'ANON_REQ',
8: 'PATH',
9: 'TRACE',
15: 'RAW_CUSTOM'
};
const type = typeNames[pkt.type] || `0x${pkt.type.toString(16).toUpperCase()}`;
// Match pyMC_core ROUTE_TYPES exactly
const routeNames = {
0: 'TRANSPORT_FLOOD',
1: 'FLOOD',
2: 'DIRECT',
3: 'TRANSPORT_DIRECT'
};
const route = routeNames[pkt.route] || `UNKNOWN_${pkt.route}`;
const status = pkt.transmitted ? 'FORWARDED' : 'DROPPED';
const hasDuplicates = pkt.duplicates && pkt.duplicates.length > 0;
// Format path/hashes column - compact layout for mobile
let pathHashesHtml = '';
// Build path display with transformation
if (pkt.path_hash) {
let pathDisplay = pkt.path_hash;
if (localHash) {
pathDisplay = pathDisplay.replace(
new RegExp(`\\b${localHash}\\b`, 'g'),
`<span class="my-hash" title="This repeater (${localHash})">${localHash}</span>`
);
}
// Check if path was transformed
if (pkt.transmitted && pkt.original_path && pkt.forwarded_path !== undefined && pkt.forwarded_path !== null) {
const origPath = `[${pkt.original_path.join(', ')}]`;
const fwdPath = pkt.forwarded_path.length > 0 ? `[${pkt.forwarded_path.join(', ')}]` : '[]';
if (origPath !== fwdPath) {
// Compact inline transformation
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span> <span class="path-arrow">→</span> <span class="path-hash">${fwdPath}</span></div>`;
} else {
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span></div>`;
}
} else {
pathHashesHtml = `<div class="path-info"><span class="path-hash">${pathDisplay}</span></div>`;
}
}
// Add src→dst on separate line for clarity
if (pkt.src_hash && pkt.dst_hash) {
pathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${pkt.src_hash}${pkt.dst_hash}</span></div>`;
} else if (pkt.src_hash || pkt.dst_hash) {
pathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${pkt.src_hash || '?'}${pkt.dst_hash || '?'}</span></div>`;
}
if (!pathHashesHtml) {
pathHashesHtml = '<span class="na">-</span>';
}
// Format status with drop reason on separate line
let statusHtml = `<span class="status-${status === 'FORWARDED' ? 'tx' : 'dropped'}">${status}</span>`;
if (!pkt.transmitted && pkt.drop_reason) {
statusHtml += `<br><small class="drop-reason">${pkt.drop_reason}</small>`;
}
if (hasDuplicates) {
statusHtml += ` <span class="dupe-badge">${pkt.duplicates.length} Repeat ${pkt.duplicates.length > 1 ? 's' : ''}</span>`;
}
let mainRow = `
<tr class="${hasDuplicates ? 'has-duplicates' : ''} clickable-row" onclick="showPacketDetails(${JSON.stringify(pkt).replace(/"/g, '&quot;')})">
<td data-label="Time">${time}</td>
<td data-label="Type"><span class="packet-type">${type}</span></td>
<td data-label="Route"><span class="route-${route.toLowerCase().replace('_', '-')}">${route}</span></td>
<td data-label="Len">${pkt.length}B</td>
<td data-label="Path/Hashes">${pathHashesHtml}</td>
<td data-label="RSSI">${pkt.rssi}</td>
<td data-label="SNR">${getTraceSnrDisplay(pkt, localHash)}</td>
<td data-label="Score"><span class="score">${pkt.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${pkt.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${statusHtml}</td>
</tr>
`;
// Add duplicate rows (always visible)
if (hasDuplicates) {
mainRow += pkt.duplicates.map(dupe => {
const dupeTime = new Date(dupe.timestamp * 1000).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
const dupeRoute = routeNames[dupe.route] || `UNKNOWN_${dupe.route}`;
// Format duplicate path/hashes - match main row format
let dupePathHashesHtml = '';
if (dupe.path_hash) {
let dupePathDisplay = dupe.path_hash;
if (localHash) {
dupePathDisplay = dupePathDisplay.replace(
new RegExp(`\\b${localHash}\\b`, 'g'),
`<span class="my-hash" title="This repeater (${localHash})">${localHash}</span>`
);
}
dupePathHashesHtml = `<div class="path-info"><span class="path-hash">${dupePathDisplay}</span></div>`;
}
if (dupe.src_hash && dupe.dst_hash) {
dupePathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${dupe.src_hash}${dupe.dst_hash}</span></div>`;
} else if (dupe.src_hash || dupe.dst_hash) {
dupePathHashesHtml += `<div class="route-info"><span class="src-dst-hash">${dupe.src_hash || '?'}${dupe.dst_hash || '?'}</span></div>`;
}
if (!dupePathHashesHtml) {
dupePathHashesHtml = '<span class="na">-</span>';
}
// Format duplicate status
let dupeStatusHtml = '<span class="status-dropped">DROPPED</span>';
if (dupe.drop_reason) {
dupeStatusHtml += `<br><small class="drop-reason">${dupe.drop_reason}</small>`;
}
return `
<tr class="duplicate-row clickable-row" onclick="showPacketDetails(${JSON.stringify(dupe).replace(/"/g, '&quot;')})">
<td data-label="Time" style="padding-left: 30px;">↳ ${dupeTime}</td>
<td data-label="Type"><span class="packet-type-dim">${type}</span></td>
<td data-label="Route"><span class="route-${dupeRoute.toLowerCase().replace('_', '-')}">${dupeRoute}</span></td>
<td data-label="Len">${dupe.length}B</td>
<td data-label="Path/Hashes">${dupePathHashesHtml}</td>
<td data-label="RSSI">${dupe.rssi}</td>
<td data-label="SNR">${getTraceSnrDisplay(dupe, localHash)}</td>
<td data-label="Score"><span class="score">${dupe.score.toFixed(2)}</span></td>
<td data-label="TX Delay">${dupe.tx_delay_ms.toFixed(0)}ms</td>
<td data-label="Status">${dupeStatusHtml}</td>
</tr>
`;
}).join('');
}
return mainRow;
}).join('');
}
// Track previous values to detect changes
let lastRxPerHour = -1;
let lastTxPerHour = -1;
function updateCharts(data) {
if (!packetRateChart) return;
// Use actual hourly rates from backend (packets in last 3600 seconds)
const rxPerHour = data.rx_per_hour || 0;
const txPerHour = data.forwarded_per_hour || 0;
// Only update packet rate chart if values changed
if (rxPerHour !== lastRxPerHour || txPerHour !== lastTxPerHour) {
// Add current timestamp as label
const currentTime = new Date().toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
packetRateChart.data.labels.push(currentTime);
packetRateChart.data.datasets[0].data.push(rxPerHour);
packetRateChart.data.datasets[1].data.push(txPerHour);
// Keep only last 10 data points
if (packetRateChart.data.labels.length > 10) {
packetRateChart.data.labels.shift();
packetRateChart.data.datasets[0].data.shift();
packetRateChart.data.datasets[1].data.shift();
}
packetRateChart.update();
lastRxPerHour = rxPerHour;
lastTxPerHour = txPerHour;
}
// Get recent packets for signal quality chart (still use last minute for responsiveness)
const now = Date.now() / 1000;
const recentPackets = (data.recent_packets || []).filter(p => now - p.timestamp < 60);
// Update signal quality distribution
const excellent = recentPackets.filter(p => p.score > 0.75).length;
const good = recentPackets.filter(p => p.score > 0.5 && p.score <= 0.75).length;
const fair = recentPackets.filter(p => p.score > 0.25 && p.score <= 0.5).length;
const poor = recentPackets.filter(p => p.score <= 0.25).length;
signalQualityChart.data.datasets[0].data = [excellent, good, fair, poor];
signalQualityChart.update();
}
// Packet Details Dialog Functions
function showPacketDetails(packet) {
const dialog = document.getElementById('packet-dialog');
const detailsGrid = dialog.querySelector('.packet-details-grid');
// Format timestamp
const timestamp = new Date(packet.timestamp * 1000).toLocaleString();
// Format payload for display (with line breaks every 32 characters)
const formatPayload = (payload) => {
if (!payload) return 'None';
return payload.match(/.{1,32}/g).join('\n');
};
// Create the packet details HTML with sections
detailsGrid.innerHTML = `
<div class="detail-section">
<h4>Basic Information</h4>
<div class="detail-item">
<span class="detail-label">Timestamp:</span>
<span class="detail-value">${timestamp}</span>
</div>
<div class="detail-item">
<span class="detail-label">Packet Hash:</span>
<span class="detail-value monospace">${packet.packet_hash}</span>
</div>
<div class="detail-item">
<span class="detail-label">Header:</span>
<span class="detail-value monospace">${packet.header || 'None'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Type:</span>
<span class="detail-value">${packet.type} (${getTypeName(packet.type)})</span>
</div>
<div class="detail-item">
<span class="detail-label">Route:</span>
<span class="detail-value">${packet.route} (${getRouteName(packet.route)})</span>
</div>
</div>
<div class="detail-section">
<h4>Payload Data</h4>
<div class="detail-item">
<span class="detail-label">Payload Length:</span>
<span class="detail-value">${packet.payload_length || packet.length} bytes</span>
</div>
<div class="detail-item">
<span class="detail-label">Payload (Hex):</span>
<div class="detail-value payload-hex">${formatPayload(packet.payload)}</div>
</div>
</div>
<div class="detail-section">
<h4>Path Information</h4>
<div class="detail-item">
<span class="detail-label">Original Path:</span>
<span class="detail-value monospace">${packet.original_path ? '[' + packet.original_path.join(', ') + ']' : 'None'}</span>
</div>
${packet.transmitted ? `
<div class="detail-item">
<span class="detail-label">Forwarded Path:</span>
<span class="detail-value monospace">${packet.forwarded_path ? '[' + packet.forwarded_path.join(', ') + ']' : 'None'}</span>
</div>
` : ''}
<div class="detail-item">
<span class="detail-label">Source Hash:</span>
<span class="detail-value monospace">${packet.src_hash || 'None'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Destination Hash:</span>
<span class="detail-value monospace">${packet.dst_hash || 'None'}</span>
</div>
</div>
<div class="detail-section">
<h4>Signal & Processing</h4>
<div class="detail-item">
<span class="detail-label">RSSI:</span>
<span class="detail-value">${packet.rssi} dBm</span>
</div>
<div class="detail-item">
<span class="detail-label">SNR:</span>
<span class="detail-value">${packet.snr.toFixed(2)} dB</span>
</div>
${packet.is_trace && packet.path_snr_details && packet.path_snr_details.length > 0 ? `
<div class="detail-item">
<span class="detail-label">Path SNRs:</span>
<div class="detail-value">
<div class="path-snrs-detail">
${packet.path_snr_details.map((pathSnr, index) => {
let snrClass = 'snr-poor';
if (pathSnr.snr_db >= 10) snrClass = 'snr-excellent';
else if (pathSnr.snr_db >= 5) snrClass = 'snr-good';
else if (pathSnr.snr_db >= 0) snrClass = 'snr-fair';
return `<div class="path-snr-item-detail">
<span class="hop-index">${index + 1}.</span>
<span class="path-hash">${pathSnr.hash}</span>
<span class="snr-value ${snrClass}">${pathSnr.snr_db.toFixed(1)}dB</span>
</div>`;
}).join('')}
</div>
</div>
</div>
` : ''}
<div class="detail-item">
<span class="detail-label">Score:</span>
<span class="detail-value">${packet.score.toFixed(3)}</span>
</div>
<div class="detail-item">
<span class="detail-label">TX Delay:</span>
<span class="detail-value">${packet.tx_delay_ms.toFixed(1)} ms</span>
</div>
<div class="detail-item">
<span class="detail-label">Transmitted:</span>
<span class="detail-value ${packet.transmitted ? 'status-tx' : 'status-dropped'}">${packet.transmitted ? 'Yes' : 'No'}</span>
</div>
<div class="detail-item">
<span class="detail-label">Is Duplicate:</span>
<span class="detail-value">${packet.is_duplicate ? 'Yes' : 'No'}</span>
</div>
${packet.drop_reason ? `
<div class="detail-item">
<span class="detail-label">Drop Reason:</span>
<span class="detail-value status-dropped">${packet.drop_reason}</span>
</div>
` : ''}
</div>
`;
dialog.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closePacketDialog() {
const dialog = document.getElementById('packet-dialog');
dialog.style.display = 'none';
document.body.style.overflow = 'auto';
}
function getTypeName(type) {
const typeNames = {
0: 'REQ',
1: 'RESPONSE',
2: 'TXT_MSG',
3: 'ACK',
4: 'ADVERT',
5: 'GRP_TXT',
6: 'GRP_DATA',
7: 'ANON_REQ',
8: 'PATH',
9: 'TRACE',
15: 'RAW_CUSTOM'
};
return typeNames[type] || `Unknown (0x${type.toString(16).toUpperCase()})`;
}
function getRouteName(route) {
const routeNames = {
0: 'TRANSPORT_FLOOD',
1: 'FLOOD',
2: 'DIRECT',
3: 'TRANSPORT_DIRECT'
};
return routeNames[route] || `Unknown (${route})`;
}
// Close dialog when clicking outside
document.addEventListener('click', function(event) {
const dialog = document.getElementById('packet-dialog');
if (event.target === dialog) {
closePacketDialog();
}
});
// Close dialog on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closePacketDialog();
}
});
// Handle Send Advert button
function sendAdvert() {
const btn = document.getElementById('send-advert-btn');
if (!btn) return;
const icon = btn.querySelector('.icon');
const iconHTML = icon ? icon.outerHTML : '';
btn.disabled = true;
btn.innerHTML = iconHTML + 'Sending...';
fetch('/api/send_advert', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.success) {
btn.innerHTML = iconHTML + 'Sent!';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
} else {
btn.innerHTML = iconHTML + 'Error';
console.error('Failed to send advert:', data.error);
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
}
})
.catch(e => {
console.error('Error sending advert:', e);
btn.innerHTML = iconHTML + 'Error';
setTimeout(() => {
btn.innerHTML = iconHTML + 'Send Advert';
btn.disabled = false;
}, 2000);
});
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
initCharts();
updateStats();
// Auto-update every 5 seconds
setInterval(updateStats, 5000);
// Attach send advert button handler
const sendAdvertBtn = document.getElementById('send-advert-btn');
if (sendAdvertBtn) {
sendAdvertBtn.addEventListener('click', sendAdvert);
}
});
</script>
<style>
/* GitHub link styling */
.github-link {
display: inline-flex;
align-items: center;
margin-left: 12px;
color: #d4d4d4;
text-decoration: none;
transition: color 0.2s, transform 0.2s;
vertical-align: middle;
}
.github-link:hover {
color: #4ec9b0;
transform: scale(1.1);
}
.github-link svg {
display: block;
}
.has-duplicates {
border-left: 3px solid #4ec9b0;
}
.duplicate-row {
background-color: rgba(244, 135, 113, 0.05);
}
.duplicate-row td {
font-size: 0.9em;
color: #999;
}
.packet-type-dim {
opacity: 0.6;
}
.dupe-badge {
display: inline-block;
padding: 2px 6px;
background-color: rgba(244, 135, 113, 0.2);
border-radius: 3px;
font-size: 0.85em;
color: #f48771;
margin-left: 4px;
}
.status-dropped {
color: #f48771;
font-weight: 500;
}
.status-tx {
color: #4ade80;
font-weight: 500;
}
.hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
background-color: rgba(78, 201, 176, 0.1);
padding: 2px 4px;
border-radius: 3px;
}
/* Signal strength bars */
.signal-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.signal-bars {
display: inline-flex;
align-items: flex-end;
gap: 2px;
height: 14px;
}
.signal-bar {
width: 3px;
background-color: #333;
border-radius: 1px;
transition: background-color 0.3s;
}
.signal-bar:nth-child(1) { height: 25%; }
.signal-bar:nth-child(2) { height: 50%; }
.signal-bar:nth-child(3) { height: 75%; }
.signal-bar:nth-child(4) { height: 100%; }
/* Signal strength colors */
.signal-excellent .signal-bar { background-color: #4ade80; }
.signal-good .signal-bar:nth-child(-n+3) { background-color: #4ade80; }
.signal-fair .signal-bar:nth-child(-n+2) { background-color: #fbbf24; }
.signal-poor .signal-bar:nth-child(1) { background-color: #f48771; }
/* SNR value styling */
.snr-value {
font-size: 0.8em;
color: #999;
white-space: nowrap;
}
/* Trace packet SNR display */
.trace-snr-container {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
min-width: 120px;
}
.rx-snr {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
padding-bottom: 4px;
}
.path-snrs {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 0.85em;
width: 100%;
}
.path-snr-label {
font-size: 0.75em;
color: #888;
text-align: center;
margin-bottom: 2px;
font-weight: 500;
}
.path-snr-item {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
justify-content: space-between;
}
.hop-index {
font-size: 0.7em;
color: #666;
min-width: 16px;
text-align: right;
}
.path-snr-item .path-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .my-hash {
font-family: 'Courier New', monospace;
font-size: 0.75em;
background: rgba(86, 156, 214, 0.2);
color: #569cd6;
font-weight: 700;
padding: 1px 3px;
border-radius: 3px;
min-width: 24px;
text-align: center;
}
.path-snr-item .snr-value {
font-size: 0.75em;
font-weight: 500;
min-width: 48px;
text-align: right;
}
/* SNR quality color coding */
.snr-excellent { color: #4ade80; }
.snr-good { color: #4ec9b0; }
.snr-fair { color: #fbbf24; }
.snr-poor { color: #f48771; }
.more-hops {
font-size: 0.7em;
color: #888;
font-style: italic;
text-align: center;
width: 100%;
} /* Path/Hashes column layout */
.path-info {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
margin-bottom: 4px;
font-size: 0.85em;
}
.route-info {
font-size: 0.9em;
opacity: 0.85;
}
.path-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
white-space: nowrap;
border: 1px solid rgba(220, 220, 170, 0.2);
}
.path-arrow {
color: #4ec9b0;
font-weight: bold;
font-size: 1.1em;
padding: 0 2px;
}
.my-hash {
background: rgba(86, 156, 214, 0.2);
color: #569cd6;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(86, 156, 214, 0.4);
}
.src-dst-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #4ec9b0;
font-weight: 500;
white-space: nowrap;
}
/* Make the arrow larger and more visible */
td {
line-height: 1.4;
}
.drop-reason {
color: #888;
font-size: 0.8em;
font-style: italic;
display: block;
margin-top: 2px;
}
.na {
color: #666;
font-style: italic;
}
/* Clickable row styling */
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
}
.clickable-row:hover {
background-color: rgba(78, 201, 176, 0.1);
}
/* Dialog styling */
.dialog-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.dialog-content {
background: linear-gradient(135deg, #1e1e1e 0%, #252526 100%);
border-radius: 12px;
border: 1px solid rgba(86, 156, 214, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
max-width: 800px;
max-height: 80vh;
width: 90%;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px 16px;
border-bottom: 1px solid rgba(86, 156, 214, 0.2);
background: linear-gradient(90deg, rgba(86, 156, 214, 0.1) 0%, rgba(86, 156, 214, 0.05) 100%);
}
.dialog-header h3 {
margin: 0;
color: #569cd6;
font-size: 1.4em;
font-weight: 600;
}
.dialog-close {
background: none;
border: none;
color: #d4d4d4;
font-size: 1.8em;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
}
.dialog-close:hover {
background-color: rgba(244, 135, 113, 0.2);
color: #f48771;
transform: scale(1.1);
}
.dialog-body {
padding: 24px;
overflow-y: auto;
max-height: calc(80vh - 80px);
}
.packet-details-grid {
display: grid;
gap: 24px;
}
.detail-section {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(86, 156, 214, 0.2);
border-radius: 8px;
padding: 20px;
}
.detail-section h4 {
margin: 0 0 16px 0;
color: #569cd6;
font-size: 1.1em;
font-weight: 600;
padding-bottom: 8px;
border-bottom: 1px solid rgba(86, 156, 214, 0.3);
}
.detail-item {
display: grid;
grid-template-columns: 140px 1fr;
gap: 16px;
padding: 8px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
align-items: start;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-label {
color: #dcdcaa;
font-weight: 500;
font-size: 0.9em;
}
.detail-value {
color: #d4d4d4;
font-size: 0.9em;
word-break: break-all;
}
.detail-value.monospace {
font-family: 'Courier New', monospace;
background: rgba(220, 220, 170, 0.1);
color: #dcdcaa;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(220, 220, 170, 0.2);
font-size: 0.85em;
}
.payload-hex {
font-family: 'Courier New', monospace;
background: rgba(30, 30, 30, 0.8);
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(86, 156, 214, 0.2);
font-size: 0.8em;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
color: #dcdcaa;
}
/* Path SNR details in dialog */
.path-snrs-detail {
display: flex;
flex-direction: column;
gap: 6px;
background: rgba(30, 30, 30, 0.6);
padding: 12px;
border-radius: 6px;
border: 1px solid rgba(86, 156, 214, 0.2);
}
.path-snr-item-detail {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.path-snr-item-detail .hop-index {
font-size: 0.8em;
color: #666;
min-width: 20px;
text-align: right;
}
.path-snr-item-detail .path-hash {
font-family: 'Courier New', monospace;
font-size: 0.85em;
color: #dcdcaa;
background: rgba(220, 220, 170, 0.1);
padding: 3px 6px;
border-radius: 4px;
min-width: 32px;
text-align: center;
border: 1px solid rgba(220, 220, 170, 0.2);
}
.path-snr-item-detail .snr-value {
font-size: 0.85em;
font-weight: 500;
min-width: 60px;
text-align: right;
}
/* Mobile optimization */
@media (max-width: 768px) {
.dialog-content {
width: 95%;
max-height: 90vh;
}
.dialog-header {
padding: 16px 20px 12px;
}
.dialog-header h3 {
font-size: 1.2em;
}
.dialog-body {
padding: 20px;
}
.detail-item {
grid-template-columns: 1fr;
gap: 8px;
}
.detail-label {
font-weight: 600;
color: #569cd6;
}
.payload-hex {
font-size: 0.75em;
padding: 8px;
}
.path-snrs-detail {
padding: 8px;
}
.path-snr-item-detail .path-hash {
font-size: 0.8em;
min-width: 28px;
}
.path-snr-item-detail .snr-value {
font-size: 0.8em;
min-width: 50px;
}
}
/* Mobile optimization */
@media (max-width: 768px) {
/* Keep path info on same line, allow wrapping if needed */
.path-info {
flex-wrap: wrap;
gap: 4px;
margin-bottom: 6px;
}
.path-hash {
font-size: 0.8em;
padding: 3px 6px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.my-hash {
font-size: 0.8em;
padding: 3px 6px;
}
.route-info {
font-size: 0.85em;
margin-top: 2px;
}
.src-dst-hash {
font-size: 0.8em;
padding: 2px 6px;
background: rgba(78, 201, 176, 0.15);
border-radius: 4px;
border: 1px solid rgba(78, 201, 176, 0.3);
}
/* Better mobile card spacing */
tbody tr {
padding: var(--spacing-md);
}
td[data-label="Path/Hashes"] {
display: block;
width: 100%;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
td[data-label="Status"] {
display: block;
width: 100%;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
}
</style>
</body>
</html>