316 lines
9.3 KiB
JavaScript
316 lines
9.3 KiB
JavaScript
(() => {
|
|
let map = null;
|
|
let socket = null;
|
|
let activeDots = null;
|
|
let animateInterval = null;
|
|
let statsInterval = null;
|
|
let refreshMapSize = null;
|
|
|
|
function initConnectionsPage() {
|
|
const mapEl = document.getElementById('map');
|
|
if (!mapEl || mapEl.dataset.bound) return;
|
|
if (typeof L === 'undefined' || typeof io === 'undefined') return;
|
|
|
|
mapEl.dataset.bound = '1';
|
|
activeDots = new Map();
|
|
|
|
// Initialize the map centered on Europe
|
|
map = L.map('map').setView([50.0, 10.0], 5);
|
|
refreshMapSize = () => setTimeout(() => map && map.invalidateSize(), 150);
|
|
refreshMapSize();
|
|
window.addEventListener('resize', refreshMapSize);
|
|
window.addEventListener('orientationchange', refreshMapSize);
|
|
|
|
// Add OpenStreetMap tile layer
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19
|
|
}).addTo(map);
|
|
|
|
// Custom pulsing circle marker
|
|
const PulsingMarker = L.CircleMarker.extend({
|
|
options: {
|
|
radius: 10,
|
|
fillColor: '#ff4444',
|
|
color: '#ff0000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}
|
|
});
|
|
|
|
function updatePopup(dot) {
|
|
const location = dot.connections[0];
|
|
const content = `
|
|
<strong>${location.city}, ${location.country}</strong><br>
|
|
Connections: ${dot.mass}<br>
|
|
Latest: ${new Date(location.timestamp).toLocaleString()}
|
|
`;
|
|
dot.marker.bindPopup(content);
|
|
}
|
|
|
|
function getTimeAgo(timestamp) {
|
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
return `${hours}h ago`;
|
|
}
|
|
|
|
function updateMapStats() {
|
|
let mostRecent = null;
|
|
activeDots.forEach(dot => {
|
|
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
|
mostRecent = dot.lastUpdate;
|
|
}
|
|
});
|
|
|
|
if (mostRecent) {
|
|
const timeAgo = getTimeAgo(mostRecent);
|
|
const lastConnection = document.getElementById('last-connection');
|
|
if (lastConnection) lastConnection.textContent = timeAgo;
|
|
}
|
|
}
|
|
|
|
function addOrUpdateDot(lat, lon, city, country, timestamp) {
|
|
const key = `${lat},${lon}`;
|
|
const now = Date.now();
|
|
|
|
if (activeDots.has(key)) {
|
|
const dot = activeDots.get(key);
|
|
dot.mass += 1;
|
|
dot.lastUpdate = now;
|
|
dot.connections.push({ city, country, timestamp });
|
|
|
|
const newRadius = 10 + Math.log(dot.mass) * 5;
|
|
dot.marker.setRadius(newRadius);
|
|
updatePopup(dot);
|
|
} else {
|
|
const marker = new PulsingMarker([lat, lon], {
|
|
radius: 10
|
|
}).addTo(map);
|
|
|
|
const dot = {
|
|
marker: marker,
|
|
mass: 1,
|
|
createdAt: now,
|
|
lastUpdate: now,
|
|
lat: lat,
|
|
lon: lon,
|
|
connections: [{ city, country, timestamp }]
|
|
};
|
|
|
|
activeDots.set(key, dot);
|
|
updatePopup(dot);
|
|
}
|
|
updateMapStats();
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes && bytes !== 0) return '-';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let num = bytes;
|
|
let unitIndex = 0;
|
|
while (num >= 1024 && unitIndex < units.length - 1) {
|
|
num /= 1024;
|
|
unitIndex++;
|
|
}
|
|
const rounded = num % 1 === 0 ? num.toFixed(0) : num.toFixed(1);
|
|
return `${rounded} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function toBool(val) {
|
|
if (val === true || val === 1) return true;
|
|
if (typeof val === 'string') {
|
|
const lower = val.toLowerCase();
|
|
return lower === 'true' || lower === '1' || lower === 'yes';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function animateDots() {
|
|
if (!map) return;
|
|
const now = Date.now();
|
|
const fadeTime = 10 * 60 * 1000; // 10 minutes
|
|
|
|
activeDots.forEach((dot, key) => {
|
|
const age = now - dot.lastUpdate;
|
|
|
|
if (age > fadeTime) {
|
|
map.removeLayer(dot.marker);
|
|
activeDots.delete(key);
|
|
} else {
|
|
const fadeProgress = age / fadeTime;
|
|
const baseRadius = 10 + Math.log(dot.mass) * 5;
|
|
const currentRadius = baseRadius * (1 - fadeProgress * 0.8);
|
|
dot.marker.setRadius(Math.max(currentRadius, 2));
|
|
|
|
const opacity = 1 - fadeProgress * 0.7;
|
|
dot.marker.setStyle({
|
|
fillOpacity: opacity * 0.8,
|
|
opacity: opacity
|
|
});
|
|
}
|
|
});
|
|
|
|
updateMapStats();
|
|
}
|
|
|
|
animateInterval = setInterval(animateDots, 100);
|
|
statsInterval = setInterval(() => {
|
|
if (activeDots.size > 0) {
|
|
updateMapStats();
|
|
}
|
|
}, 1000);
|
|
|
|
// WebSocket connection
|
|
// Force polling to avoid websocket upgrade issues behind some proxies.
|
|
socket = io({
|
|
transports: ['polling'],
|
|
upgrade: false
|
|
});
|
|
socket.on('connect', () => {
|
|
socket.emit('request_initial_data');
|
|
socket.emit('request_map_data');
|
|
});
|
|
|
|
socket.on('map_initial_data', function(data) {
|
|
data.connections.forEach(conn => {
|
|
if (conn.lat && conn.lon) {
|
|
addOrUpdateDot(conn.lat, conn.lon, conn.city, conn.country, conn.timestamp);
|
|
}
|
|
});
|
|
});
|
|
|
|
socket.on('new_connection', function(data) {
|
|
if (data.lat && data.lon) {
|
|
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
|
|
}
|
|
});
|
|
|
|
function updateStats(data) {
|
|
const totalConnections = document.getElementById('totalConnections');
|
|
if (totalConnections) totalConnections.textContent = data.length;
|
|
const totalBytes = data.reduce((sum, rec) => {
|
|
const val = parseFloat(rec.filesize);
|
|
return sum + (isNaN(val) ? 0 : val);
|
|
}, 0);
|
|
const totalSize = document.getElementById('totalSize');
|
|
if (totalSize) totalSize.textContent = formatBytes(totalBytes);
|
|
const cachedCount = data.reduce((sum, rec) => sum + (toBool(rec.cached) ? 1 : 0), 0);
|
|
const pct = data.length ? ((cachedCount / data.length) * 100).toFixed(0) : 0;
|
|
const cachedPercent = document.getElementById('cachedPercent');
|
|
if (cachedPercent) cachedPercent.textContent = `${pct}%`;
|
|
}
|
|
|
|
function createRow(record, animate = true) {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.timestamp = record.timestamp;
|
|
if (animate) {
|
|
tr.classList.add('slide-in');
|
|
tr.addEventListener('animationend', () =>
|
|
tr.classList.remove('slide-in'), { once: true });
|
|
}
|
|
tr.innerHTML = `
|
|
<td>${record.timestamp}</td>
|
|
<td>${record.location}</td>
|
|
<td>${record.user_agent}</td>
|
|
<td>${record.full_path}</td>
|
|
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
|
<td>${record.mime_typ}</td>
|
|
<td>${record.cached}</td>
|
|
`;
|
|
return tr;
|
|
}
|
|
|
|
function updateTable(data) {
|
|
const tbody = document.getElementById('connectionsTableBody');
|
|
if (!tbody) return;
|
|
const existing = {};
|
|
Array.from(tbody.children).forEach(r => {
|
|
existing[r.dataset.timestamp] = r;
|
|
});
|
|
|
|
const frag = document.createDocumentFragment();
|
|
data.forEach(rec => {
|
|
let row = existing[rec.timestamp];
|
|
if (row) {
|
|
row.innerHTML = createRow(rec, false).innerHTML;
|
|
row.classList.remove('slide-out');
|
|
} else {
|
|
row = createRow(rec, true);
|
|
}
|
|
frag.appendChild(row);
|
|
});
|
|
|
|
tbody.innerHTML = '';
|
|
tbody.appendChild(frag);
|
|
}
|
|
|
|
function animateTableWithNewRow(data) {
|
|
const tbody = document.getElementById('connectionsTableBody');
|
|
if (!tbody) return;
|
|
const currentTs = new Set([...tbody.children].map(r => r.dataset.timestamp));
|
|
const newRecs = data.filter(r => !currentTs.has(r.timestamp));
|
|
|
|
if (newRecs.length) {
|
|
const temp = createRow(newRecs[0], false);
|
|
temp.style.visibility = 'hidden';
|
|
tbody.appendChild(temp);
|
|
const h = temp.getBoundingClientRect().height;
|
|
temp.remove();
|
|
|
|
tbody.style.transform = `translateY(${h}px)`;
|
|
setTimeout(() => {
|
|
updateTable(data);
|
|
tbody.style.transition = 'none';
|
|
tbody.style.transform = 'translateY(0)';
|
|
void tbody.offsetWidth;
|
|
tbody.style.transition = 'transform 0.5s ease-out';
|
|
}, 500);
|
|
} else {
|
|
updateTable(data);
|
|
}
|
|
}
|
|
|
|
socket.on('recent_connections', data => {
|
|
updateStats(data);
|
|
animateTableWithNewRow(data);
|
|
});
|
|
}
|
|
|
|
function destroyConnectionsPage() {
|
|
if (socket) {
|
|
socket.off();
|
|
socket.disconnect();
|
|
socket = null;
|
|
}
|
|
if (animateInterval) {
|
|
clearInterval(animateInterval);
|
|
animateInterval = null;
|
|
}
|
|
if (statsInterval) {
|
|
clearInterval(statsInterval);
|
|
statsInterval = null;
|
|
}
|
|
if (refreshMapSize) {
|
|
window.removeEventListener('resize', refreshMapSize);
|
|
window.removeEventListener('orientationchange', refreshMapSize);
|
|
refreshMapSize = null;
|
|
}
|
|
if (map) {
|
|
map.remove();
|
|
map = null;
|
|
}
|
|
activeDots = null;
|
|
}
|
|
|
|
if (window.PageRegistry && typeof window.PageRegistry.register === 'function') {
|
|
window.PageRegistry.register('connections', {
|
|
init: initConnectionsPage,
|
|
destroy: destroyConnectionsPage
|
|
});
|
|
}
|
|
})();
|