add map for connection
This commit is contained in:
parent
462581b698
commit
b4f47d0cb1
63
analytics.py
63
analytics.py
@ -35,9 +35,22 @@ def init_log_db():
|
||||
country TEXT,
|
||||
user_agent TEXT,
|
||||
device_id TEXT,
|
||||
cached BOOLEAN
|
||||
cached BOOLEAN,
|
||||
latitude REAL,
|
||||
longitude REAL
|
||||
)
|
||||
''')
|
||||
|
||||
# Migrate existing database to add latitude and longitude columns if they don't exist
|
||||
cursor = log_db.execute("PRAGMA table_info(file_access_log)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'latitude' not in columns:
|
||||
with log_db:
|
||||
log_db.execute('ALTER TABLE file_access_log ADD COLUMN latitude REAL')
|
||||
if 'longitude' not in columns:
|
||||
with log_db:
|
||||
log_db.execute('ALTER TABLE file_access_log ADD COLUMN longitude REAL')
|
||||
|
||||
init_log_db()
|
||||
|
||||
@ -46,9 +59,11 @@ def lookup_location(ip):
|
||||
response = geoReader.city(ip)
|
||||
country = response.country.name if response.country.name else "Unknown"
|
||||
city = response.city.name if response.city.name else "Unknown"
|
||||
return city, country
|
||||
lat = response.location.latitude if response.location.latitude else None
|
||||
lon = response.location.longitude if response.location.longitude else None
|
||||
return city, country, lat, lon
|
||||
except Exception:
|
||||
return "Unknown", "Unknown"
|
||||
return "Unknown", "Unknown", None, None
|
||||
|
||||
def get_device_type(user_agent):
|
||||
"""Classify device type based on user agent string."""
|
||||
@ -108,15 +123,32 @@ def log_file_access(rel_path, filesize, mime, ip_address, user_agent, device_id,
|
||||
now = datetime.now(timezone.utc).astimezone()
|
||||
iso_ts = now.isoformat()
|
||||
|
||||
# Convert the IP address to a location
|
||||
city, country = lookup_location(ip_address)
|
||||
# Convert the IP address to a location with coordinates
|
||||
city, country, lat, lon = lookup_location(ip_address)
|
||||
|
||||
with log_db:
|
||||
log_db.execute('''
|
||||
INSERT INTO file_access_log
|
||||
(timestamp, rel_path, filesize, mime, city, country, user_agent, device_id, cached)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (iso_ts, rel_path, filesize, mime, city, country, user_agent, device_id, cached))
|
||||
(timestamp, rel_path, filesize, mime, city, country, user_agent, device_id, cached, latitude, longitude)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (iso_ts, rel_path, filesize, mime, city, country, user_agent, device_id, cached, lat, lon))
|
||||
|
||||
# Emit real-time connection data via WebSocket
|
||||
try:
|
||||
from flask_socketio import SocketIO
|
||||
# Import socketio from app module to emit the event
|
||||
import app as app_module
|
||||
if hasattr(app_module, 'socketio') and lat and lon:
|
||||
app_module.socketio.emit('new_connection', {
|
||||
'timestamp': iso_ts,
|
||||
'city': city,
|
||||
'country': country,
|
||||
'lat': lat,
|
||||
'lon': lon
|
||||
})
|
||||
except Exception as e:
|
||||
# Don't fail the logging if WebSocket emit fails
|
||||
print(f"WebSocket emit failed: {e}")
|
||||
|
||||
# Prune temp entries older than 10 minutes
|
||||
cutoff = now - timedelta(minutes=10)
|
||||
@ -227,6 +259,21 @@ def return_file_access():
|
||||
return []
|
||||
|
||||
|
||||
def return_file_access_with_geo():
|
||||
"""Return recent file access logs with geographic coordinates from the database."""
|
||||
cutoff_time = datetime.now(timezone.utc).astimezone() - timedelta(minutes=10)
|
||||
cutoff_str = cutoff_time.isoformat()
|
||||
|
||||
with log_db:
|
||||
cursor = log_db.execute('''
|
||||
SELECT timestamp, rel_path, filesize, mime, city, country, user_agent, device_id, latitude, longitude
|
||||
FROM file_access_log
|
||||
WHERE timestamp >= ?
|
||||
ORDER BY timestamp DESC
|
||||
''', (cutoff_str,))
|
||||
return cursor.fetchall()
|
||||
|
||||
|
||||
def songs_dashboard():
|
||||
# — SESSION & PARAM HANDLING (unchanged) —
|
||||
if 'songs_dashboard_timeframe' not in session:
|
||||
|
||||
16
app.py
16
app.py
@ -673,6 +673,22 @@ def handle_request_initial_data():
|
||||
]
|
||||
emit('recent_connections', connections)
|
||||
|
||||
@socketio.on('request_map_data')
|
||||
def handle_request_map_data():
|
||||
"""Send initial map data with geographic coordinates"""
|
||||
rows = a.return_file_access_with_geo()
|
||||
connections = []
|
||||
for row in rows:
|
||||
if row[8] and row[9]: # lat and lon exist
|
||||
connections.append({
|
||||
'timestamp': row[0],
|
||||
'city': row[4],
|
||||
'country': row[5],
|
||||
'lat': row[8],
|
||||
'lon': row[9]
|
||||
})
|
||||
emit('map_initial_data', {'connections': connections})
|
||||
|
||||
# Catch-all route to serve the single-page application template.
|
||||
@app.route('/', defaults={'path': ''})
|
||||
@app.route('/<path:path>')
|
||||
|
||||
@ -4,9 +4,68 @@
|
||||
{% 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 */
|
||||
.table-container { flex: 1; overflow-y: auto; }
|
||||
.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);
|
||||
@ -21,32 +80,68 @@
|
||||
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="container-fluid">
|
||||
<h2>Verbindungen der letzten 10 Minuten</h2>
|
||||
<span class="ms-3">
|
||||
Anzahl Verbindungen: <strong id="totalConnections">0</strong>
|
||||
</span>
|
||||
<div class="table-responsive 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 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 %}
|
||||
@ -54,9 +149,161 @@
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
|
||||
<script>
|
||||
const socket = io();
|
||||
socket.on("connect", () => socket.emit("request_initial_data"));
|
||||
// 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user