improve dashboard
This commit is contained in:
parent
d233e66268
commit
ab79098e9a
79
app.py
79
app.py
@ -6,18 +6,16 @@ from functools import wraps
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
from urllib.parse import unquote
|
|
||||||
import diskcache
|
import diskcache
|
||||||
import json
|
import json
|
||||||
import geoip2.database
|
import geoip2.database
|
||||||
|
from urllib.parse import urlparse, unquote
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
cache = diskcache.Cache('./filecache', size_limit= 32 * 1024**3) # 32 GB limit
|
cache = diskcache.Cache('./filecache', size_limit= 48 * 1024**3) # 32 GB limit
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||||||
|
|
||||||
# Use a raw string for the default FILE_ROOT path.
|
|
||||||
app.config['FILE_ROOT'] = r'/mp3_root'
|
|
||||||
app.config['SECRET_KEY'] = '85c1117eb3a5f2c79f0ff395bada8ff8d9a257b99ef5e143'
|
app.config['SECRET_KEY'] = '85c1117eb3a5f2c79f0ff395bada8ff8d9a257b99ef5e143'
|
||||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90)
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90)
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
||||||
@ -131,8 +129,9 @@ def list_directory_contents(directory, subpath):
|
|||||||
full_path = os.path.join(directory, item)
|
full_path = os.path.join(directory, item)
|
||||||
# Process directories.
|
# Process directories.
|
||||||
if os.path.isdir(full_path):
|
if os.path.isdir(full_path):
|
||||||
# Also skip the "Transkription" folder.
|
# skip folder
|
||||||
if item.lower() == "transkription":
|
skip_folder = ["Transkription", "@eaDir"]
|
||||||
|
if item in skip_folder:
|
||||||
continue
|
continue
|
||||||
rel_path = os.path.join(subpath, item) if subpath else item
|
rel_path = os.path.join(subpath, item) if subpath else item
|
||||||
rel_path = rel_path.replace(os.sep, '/')
|
rel_path = rel_path.replace(os.sep, '/')
|
||||||
@ -211,6 +210,28 @@ def lookup_location(ip, reader):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return "Unknown", "Unknown"
|
return "Unknown", "Unknown"
|
||||||
|
|
||||||
|
# Helper function to classify device type based on user agent string
|
||||||
|
def get_device_type(user_agent):
|
||||||
|
if 'Android' in user_agent:
|
||||||
|
return 'Android'
|
||||||
|
elif 'iPhone' in user_agent or 'iPad' in user_agent:
|
||||||
|
return 'iOS'
|
||||||
|
elif 'Windows' in user_agent:
|
||||||
|
return 'Windows'
|
||||||
|
elif 'Macintosh' in user_agent or 'Mac OS' in user_agent:
|
||||||
|
return 'MacOS'
|
||||||
|
elif 'Linux' in user_agent:
|
||||||
|
return 'Linux'
|
||||||
|
else:
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
def shorten_referrer(url):
|
||||||
|
segments = [seg for seg in url.split('/') if seg]
|
||||||
|
segment = segments[-1]
|
||||||
|
# Decode all percent-encoded characters (like %20, %2F, etc.)
|
||||||
|
segment_decoded = unquote(segment)
|
||||||
|
return segment_decoded
|
||||||
|
|
||||||
@app.route("/dashboard")
|
@app.route("/dashboard")
|
||||||
@require_secret
|
@require_secret
|
||||||
def dashboard():
|
def dashboard():
|
||||||
@ -238,6 +259,7 @@ def dashboard():
|
|||||||
WHERE timestamp >= ?
|
WHERE timestamp >= ?
|
||||||
GROUP BY full_path
|
GROUP BY full_path
|
||||||
ORDER BY access_count DESC
|
ORDER BY access_count DESC
|
||||||
|
LIMIT 20
|
||||||
''', (start.isoformat(),))
|
''', (start.isoformat(),))
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
@ -262,18 +284,23 @@ def dashboard():
|
|||||||
''', (start.isoformat(),))
|
''', (start.isoformat(),))
|
||||||
top_files_data = [dict(full_path=row[0], access_count=row[1]) for row in cursor.fetchall()]
|
top_files_data = [dict(full_path=row[0], access_count=row[1]) for row in cursor.fetchall()]
|
||||||
|
|
||||||
# User agent distribution (limit to 10)
|
# User agent distribution (aggregate by device type)
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT user_agent, COUNT(*) as count
|
SELECT user_agent, COUNT(*) as count
|
||||||
FROM file_access_log
|
FROM file_access_log
|
||||||
WHERE timestamp >= ?
|
WHERE timestamp >= ?
|
||||||
GROUP BY user_agent
|
GROUP BY user_agent
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 10
|
|
||||||
''', (start.isoformat(),))
|
''', (start.isoformat(),))
|
||||||
user_agent_data = [dict(user_agent=row[0], count=row[1]) for row in cursor.fetchall()]
|
raw_user_agents = [dict(user_agent=row[0], count=row[1]) for row in cursor.fetchall()]
|
||||||
|
device_counts = {}
|
||||||
|
for entry in raw_user_agents:
|
||||||
|
device = get_device_type(entry['user_agent'])
|
||||||
|
device_counts[device] = device_counts.get(device, 0) + entry['count']
|
||||||
|
# Rename to user_agent_data for compatibility with the frontend
|
||||||
|
user_agent_data = [dict(device=device, count=count) for device, count in device_counts.items()]
|
||||||
|
|
||||||
# Referrer distribution (limit to 10)
|
# Referrer distribution (shorten links)
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT referrer, COUNT(*) as count
|
SELECT referrer, COUNT(*) as count
|
||||||
FROM file_access_log
|
FROM file_access_log
|
||||||
@ -282,7 +309,11 @@ def dashboard():
|
|||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
''', (start.isoformat(),))
|
''', (start.isoformat(),))
|
||||||
referrer_data = [dict(referrer=row[0] if row[0] else "Direct/None", count=row[1]) for row in cursor.fetchall()]
|
referrer_data = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
raw_ref = row[0]
|
||||||
|
shortened = shorten_referrer(raw_ref) if raw_ref else "Direct/None"
|
||||||
|
referrer_data.append(dict(referrer=shortened, count=row[1]))
|
||||||
|
|
||||||
# Aggregate IP addresses with counts
|
# Aggregate IP addresses with counts
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@ -291,6 +322,7 @@ def dashboard():
|
|||||||
WHERE timestamp >= ?
|
WHERE timestamp >= ?
|
||||||
GROUP BY ip_address
|
GROUP BY ip_address
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
|
LIMIT 20
|
||||||
''', (start.isoformat(),))
|
''', (start.isoformat(),))
|
||||||
ip_rows = cursor.fetchall()
|
ip_rows = cursor.fetchall()
|
||||||
|
|
||||||
@ -302,6 +334,13 @@ def dashboard():
|
|||||||
ip_data.append(dict(ip=ip, count=count, country=country, city=city))
|
ip_data.append(dict(ip=ip, count=count, country=country, city=city))
|
||||||
reader.close()
|
reader.close()
|
||||||
|
|
||||||
|
# Aggregate by city (ignoring entries without a city)
|
||||||
|
city_counts = {}
|
||||||
|
for entry in ip_data:
|
||||||
|
if entry['city']:
|
||||||
|
city_counts[entry['city']] = city_counts.get(entry['city'], 0) + entry['count']
|
||||||
|
city_data = [dict(city=city, count=count) for city, count in city_counts.items()]
|
||||||
|
|
||||||
# Summary stats
|
# Summary stats
|
||||||
total_accesses = sum([row[1] for row in rows])
|
total_accesses = sum([row[1] for row in rows])
|
||||||
unique_files = len(rows)
|
unique_files = len(rows)
|
||||||
@ -317,6 +356,7 @@ def dashboard():
|
|||||||
user_agent_data=user_agent_data,
|
user_agent_data=user_agent_data,
|
||||||
referrer_data=referrer_data,
|
referrer_data=referrer_data,
|
||||||
ip_data=ip_data,
|
ip_data=ip_data,
|
||||||
|
city_data=city_data,
|
||||||
total_accesses=total_accesses,
|
total_accesses=total_accesses,
|
||||||
unique_files=unique_files,
|
unique_files=unique_files,
|
||||||
unique_ips=unique_ips)
|
unique_ips=unique_ips)
|
||||||
@ -364,18 +404,21 @@ def serve_file(filename):
|
|||||||
app.logger.error(f"File not found: {full_path}")
|
app.logger.error(f"File not found: {full_path}")
|
||||||
return "File not found", 404
|
return "File not found", 404
|
||||||
|
|
||||||
# HEAD request are coming in to initiate server caching.
|
|
||||||
# only log initial hits and not the reload of further file parts
|
|
||||||
range_header = request.headers.get('Range')
|
|
||||||
if request.method != 'HEAD' and (not range_header or range_header.startswith("bytes=0-")):
|
|
||||||
log_file_access(full_path)
|
|
||||||
|
|
||||||
mime, _ = mimetypes.guess_type(full_path)
|
mime, _ = mimetypes.guess_type(full_path)
|
||||||
mime = mime or 'application/octet-stream'
|
mime = mime or 'application/octet-stream'
|
||||||
|
|
||||||
response = None
|
if mime and mime.startswith('image/'):
|
||||||
|
pass # do not log access to images
|
||||||
|
|
||||||
|
else:
|
||||||
|
# HEAD request are coming in to initiate server caching.
|
||||||
|
# only log initial hits and not the reload of further file parts
|
||||||
|
range_header = request.headers.get('Range')
|
||||||
|
if request.method != 'HEAD' and (not range_header or range_header.startswith("bytes=0-")):
|
||||||
|
log_file_access(full_path)
|
||||||
|
|
||||||
# Check cache first (using diskcache)
|
# Check cache first (using diskcache)
|
||||||
|
response = None
|
||||||
cached = cache.get(filename)
|
cached = cache.get(filename)
|
||||||
if cached:
|
if cached:
|
||||||
cached_file_bytes, mime = cached
|
cached_file_bytes, mime = cached
|
||||||
|
|||||||
@ -16,11 +16,13 @@ SERVER1_MOUNT_POINTS=(
|
|||||||
"/mnt/app.bethaus/Gottesdienste Speyer"
|
"/mnt/app.bethaus/Gottesdienste Speyer"
|
||||||
"/mnt/app.bethaus/Besondere Gottesdienste"
|
"/mnt/app.bethaus/Besondere Gottesdienste"
|
||||||
"/mnt/app.bethaus/Liedersammlung"
|
"/mnt/app.bethaus/Liedersammlung"
|
||||||
|
"/mnt/app.share"
|
||||||
)
|
)
|
||||||
SERVER1_NFS_SHARES=(
|
SERVER1_NFS_SHARES=(
|
||||||
"/volume1/Aufnahme-stereo/010 Gottesdienste ARCHIV"
|
"/volume1/Aufnahme-stereo/010 Gottesdienste ARCHIV"
|
||||||
"/volume1/Aufnahme-stereo/013 Besondere Gottesdienste"
|
"/volume1/Aufnahme-stereo/013 Besondere Gottesdienste"
|
||||||
"/volume1/Aufnahme-stereo/014 Liedersammlung"
|
"/volume1/Aufnahme-stereo/014 Liedersammlung"
|
||||||
|
"/volume1/app.share"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Server 2 Configuration
|
# Server 2 Configuration
|
||||||
|
|||||||
@ -11,14 +11,18 @@ services:
|
|||||||
- ./GeoLite2-City.mmdb:/app/GeoLite2-City.mmdb:ro
|
- ./GeoLite2-City.mmdb:/app/GeoLite2-City.mmdb:ro
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /mnt/app.bethaus
|
source: /mnt/app.bethaus
|
||||||
target: /mp3_root
|
target: /main
|
||||||
|
bind:
|
||||||
|
propagation: rshared
|
||||||
|
- type: bind
|
||||||
|
source: /mnt/app.share
|
||||||
|
target: /share
|
||||||
bind:
|
bind:
|
||||||
propagation: rshared
|
propagation: rshared
|
||||||
environment:
|
environment:
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_RUN_HOST=0.0.0.0
|
- FLASK_RUN_HOST=0.0.0.0
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=production
|
||||||
- MP3_ROOT=/mp3_root
|
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@ -91,35 +91,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Detailed Table of Top File Accesses -->
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
Detailed File Access Data
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<table class="table table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>File Path</th>
|
|
||||||
<th>Access Count</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for row in rows %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ row[0] }}</td>
|
|
||||||
<td>{{ row[1] }}</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="2">No data available for the selected timeframe.</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- New Section: IP Address Access Data -->
|
<!-- New Section: IP Address Access Data -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
@ -152,13 +123,74 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New Section: Aggregated City Access Data -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
City Access Distribution
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>City</th>
|
||||||
|
<th>Access Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for city in city_data %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ city.city }}</td>
|
||||||
|
<td>{{ city.count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">No city access data available for the selected timeframe.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Table of Top File Accesses -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Detailed File Access Data
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>File Path</th>
|
||||||
|
<th>Access Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row[0] }}</td>
|
||||||
|
<td>{{ row[1] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2">No data available for the selected timeframe.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load Chart.js from CDN -->
|
<!-- Load Chart.js from CDN -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Data passed from the backend as JSON
|
// Data passed from the backend as JSON
|
||||||
const dailyAccessData = {{ daily_access_data|tojson }};
|
const dailyAccessData = {{ daily_access_data|tojson }};
|
||||||
const topFilesData = {{ top_files_data|tojson }};
|
const topFilesData = {{ top_files_data|tojson }};
|
||||||
|
// Note: user_agent_data now contains 'device' and 'count'
|
||||||
const userAgentData = {{ user_agent_data|tojson }};
|
const userAgentData = {{ user_agent_data|tojson }};
|
||||||
const referrerData = {{ referrer_data|tojson }};
|
const referrerData = {{ referrer_data|tojson }};
|
||||||
|
|
||||||
@ -208,12 +240,12 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// User Agent Distribution - Pie Chart
|
// User Agent Distribution - Pie Chart (using aggregated device data)
|
||||||
const ctxUserAgent = document.getElementById('userAgentChart').getContext('2d');
|
const ctxUserAgent = document.getElementById('userAgentChart').getContext('2d');
|
||||||
new Chart(ctxUserAgent, {
|
new Chart(ctxUserAgent, {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
labels: userAgentData.map(item => item.user_agent),
|
labels: userAgentData.map(item => item.device),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: userAgentData.map(item => item.count)
|
data: userAgentData.map(item => item.count)
|
||||||
}]
|
}]
|
||||||
@ -221,7 +253,7 @@
|
|||||||
options: { responsive: true }
|
options: { responsive: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Referrer Distribution - Pie Chart
|
// Referrer Distribution - Pie Chart (with shortened referrers)
|
||||||
const ctxReferrer = document.getElementById('referrerChart').getContext('2d');
|
const ctxReferrer = document.getElementById('referrerChart').getContext('2d');
|
||||||
new Chart(ctxReferrer, {
|
new Chart(ctxReferrer, {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user