383 lines
11 KiB
HTML
383 lines
11 KiB
HTML
{# templates/connections.html #}
|
|
{% extends 'base.html' %}
|
|
|
|
{% block title %}Recent Connections{% endblock %}
|
|
|
|
{% block head_extra %}
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<style>
|
|
/* page-specific styles */
|
|
.split-view {
|
|
display: flex;
|
|
gap: 20px;
|
|
height: 85vh;
|
|
padding: 20px;
|
|
}
|
|
.map-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
.table-section {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
#map {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
}
|
|
.section-header {
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 15px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
gap: 20px;
|
|
flex-wrap: wrap;
|
|
margin-top: 10px;
|
|
}
|
|
.stat-item {
|
|
flex: 1;
|
|
min-width: 120px;
|
|
}
|
|
.stat-label {
|
|
font-size: 11px;
|
|
color: #666;
|
|
text-transform: uppercase;
|
|
}
|
|
.stat-value {
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
color: #333;
|
|
}
|
|
.table-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
|
}
|
|
#connectionsTableBody {
|
|
transition: transform 0.5s ease-out;
|
|
transform: translateY(0);
|
|
}
|
|
@keyframes slideIn {
|
|
0% { opacity: 0; transform: scale(0.8); }
|
|
100% { opacity: 1; transform: scale(1); }
|
|
}
|
|
.slide-in { animation: slideIn 0.5s ease-out; }
|
|
@keyframes slideOut {
|
|
0% { opacity: 1; transform: scale(1); }
|
|
100% { opacity: 0; transform: scale(0.1); }
|
|
}
|
|
.slide-out { animation: slideOut 0.5s ease-in forwards; }
|
|
|
|
@media (max-width: 1200px) {
|
|
.split-view {
|
|
flex-direction: column;
|
|
height: auto;
|
|
}
|
|
.map-section, .table-section {
|
|
min-height: 400px;
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="split-view">
|
|
<!-- Map Section -->
|
|
<div class="map-section">
|
|
<div class="section-header">
|
|
<h2 style="margin: 0 0 10px 0;">Live Connection Map</h2>
|
|
<div class="stats">
|
|
<div class="stat-item">
|
|
<div class="stat-label">Active Dots</div>
|
|
<div class="stat-value" id="active-dots">0</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">Last Connection</div>
|
|
<div class="stat-value" id="last-connection" style="font-size: 14px;">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="map"></div>
|
|
</div>
|
|
|
|
<!-- Table Section -->
|
|
<div class="table-section">
|
|
<div class="section-header">
|
|
<h2 style="margin: 0;">Verbindungen der letzten 10 Minuten</h2>
|
|
<div class="stats">
|
|
<div class="stat-item">
|
|
<div class="stat-label">Total Connections</div>
|
|
<div class="stat-value" id="totalConnections">0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="table-container">
|
|
<table class="table table-hover">
|
|
<thead class="table-secondary">
|
|
<tr>
|
|
<th>Timestamp</th>
|
|
<th>Location</th>
|
|
<th>User Agent</th>
|
|
<th>File Path</th>
|
|
<th>File Size</th>
|
|
<th>MIME-Typ</th>
|
|
<th>Cached</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="connectionsTableBody">
|
|
{# dynamically populated via Socket.IO #}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
|
<script>
|
|
// Initialize the map centered on Europe
|
|
const map = L.map('map').setView([50.0, 10.0], 5);
|
|
|
|
// Add OpenStreetMap tile layer
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19
|
|
}).addTo(map);
|
|
|
|
// Store active dots with their data
|
|
const activeDots = new Map();
|
|
let totalConnections = 0;
|
|
|
|
// Custom pulsing circle marker
|
|
const PulsingMarker = L.CircleMarker.extend({
|
|
options: {
|
|
radius: 10,
|
|
fillColor: '#ff4444',
|
|
color: '#ff0000',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}
|
|
});
|
|
|
|
// WebSocket connection
|
|
const socket = io();
|
|
socket.on("connect", () => {
|
|
socket.emit("request_initial_data");
|
|
socket.emit("request_map_data");
|
|
});
|
|
|
|
// Map functions
|
|
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);
|
|
totalConnections++;
|
|
}
|
|
updateMapStats();
|
|
}
|
|
|
|
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 updateMapStats() {
|
|
document.getElementById('active-dots').textContent = activeDots.size;
|
|
|
|
let mostRecent = null;
|
|
activeDots.forEach(dot => {
|
|
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
|
mostRecent = dot.lastUpdate;
|
|
}
|
|
});
|
|
|
|
if (mostRecent) {
|
|
const timeAgo = getTimeAgo(mostRecent);
|
|
document.getElementById('last-connection').textContent = timeAgo;
|
|
}
|
|
}
|
|
|
|
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 animateDots() {
|
|
const now = Date.now();
|
|
const fadeTime = 60000; // 60 seconds
|
|
|
|
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();
|
|
}
|
|
|
|
setInterval(animateDots, 100);
|
|
setInterval(() => {
|
|
if (activeDots.size > 0) {
|
|
updateMapStats();
|
|
}
|
|
}, 1000);
|
|
|
|
// Handle map data
|
|
socket.on('map_initial_data', function(data) {
|
|
console.log('Received map data:', data.connections.length, 'connections');
|
|
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) {
|
|
console.log('New connection:', data);
|
|
if (data.lat && data.lon) {
|
|
addOrUpdateDot(data.lat, data.lon, data.city, data.country, data.timestamp);
|
|
}
|
|
});
|
|
|
|
// Table functions
|
|
function updateStats(data) {
|
|
document.getElementById("totalConnections").textContent = data.length;
|
|
}
|
|
|
|
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>${record.filesize}</td>
|
|
<td>${record.mime_typ}</td>
|
|
<td>${record.cached}</td>
|
|
`;
|
|
return tr;
|
|
}
|
|
|
|
function updateTable(data) {
|
|
const tbody = document.getElementById("connectionsTableBody");
|
|
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");
|
|
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);
|
|
});
|
|
</script>
|
|
{% endblock %}
|