Compare commits
4 Commits
b4f47d0cb1
...
79fa989a6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 79fa989a6d | |||
| de5a2a189b | |||
| a7c7481d24 | |||
| 9b191c3df7 |
34
app.py
34
app.py
@ -1,7 +1,12 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Use eventlet only in production; keep dev on threading to avoid monkey_patch issues with reloader
|
||||||
|
FLASK_ENV = os.environ.get('FLASK_ENV', 'production')
|
||||||
|
if FLASK_ENV == 'production':
|
||||||
import eventlet
|
import eventlet
|
||||||
eventlet.monkey_patch()
|
eventlet.monkey_patch()
|
||||||
|
|
||||||
from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, make_response, abort
|
from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory, make_response, abort
|
||||||
import os
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
import io
|
import io
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@ -24,6 +29,7 @@ import auth
|
|||||||
import analytics as a
|
import analytics as a
|
||||||
import folder_secret_config_editor as fsce
|
import folder_secret_config_editor as fsce
|
||||||
import helperfunctions as hf
|
import helperfunctions as hf
|
||||||
|
import search_db_analyzer as sdb
|
||||||
import fnmatch
|
import fnmatch
|
||||||
|
|
||||||
app_config = auth.return_app_config()
|
app_config = auth.return_app_config()
|
||||||
@ -39,7 +45,7 @@ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
|||||||
|
|
||||||
app.config['SECRET_KEY'] = app_config['SECRET_KEY']
|
app.config['SECRET_KEY'] = app_config['SECRET_KEY']
|
||||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90)
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90)
|
||||||
if os.environ.get('FLASK_ENV') == 'production':
|
if FLASK_ENV == 'production':
|
||||||
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
app.config['SESSION_COOKIE_SAMESITE'] = 'None'
|
||||||
app.config['SESSION_COOKIE_SECURE'] = True
|
app.config['SESSION_COOKIE_SECURE'] = True
|
||||||
|
|
||||||
@ -56,6 +62,9 @@ app.add_url_rule('/searchcommand', view_func=search.searchcommand, methods=['POS
|
|||||||
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
|
app.add_url_rule('/admin/folder_secret_config_editor', view_func=auth.require_admin(fsce.folder_secret_config_editor), methods=['GET', 'POST'])
|
||||||
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
|
app.add_url_rule('/admin/folder_secret_config_editor/data', view_func=auth.require_admin(auth.load_folder_config))
|
||||||
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
|
app.add_url_rule('/admin/folder_secret_config_editor/action', view_func=auth.require_admin(fsce.folder_secret_config_action), methods=['POST'])
|
||||||
|
app.add_url_rule('/admin/search_db_analyzer', view_func=auth.require_admin(sdb.search_db_analyzer))
|
||||||
|
app.add_url_rule('/admin/search_db_analyzer/query', view_func=auth.require_admin(sdb.search_db_query), methods=['POST'])
|
||||||
|
app.add_url_rule('/admin/search_db_analyzer/folders', view_func=auth.require_admin(sdb.search_db_folders))
|
||||||
|
|
||||||
# Grab the HOST_RULE environment variable
|
# Grab the HOST_RULE environment variable
|
||||||
host_rule = os.getenv("HOST_RULE", "")
|
host_rule = os.getenv("HOST_RULE", "")
|
||||||
@ -65,7 +74,7 @@ allowed_domains = re.findall(pattern, host_rule)
|
|||||||
|
|
||||||
socketio = SocketIO(
|
socketio = SocketIO(
|
||||||
app,
|
app,
|
||||||
async_mode='eventlet',
|
async_mode='eventlet' if FLASK_ENV == 'production' else 'threading',
|
||||||
cors_allowed_origins=allowed_domains
|
cors_allowed_origins=allowed_domains
|
||||||
)
|
)
|
||||||
background_thread_running = False
|
background_thread_running = False
|
||||||
@ -213,6 +222,23 @@ def generate_breadcrumbs(subpath=None):
|
|||||||
breadcrumbs.append({'name': part, 'path': path_accum})
|
breadcrumbs.append({'name': part, 'path': path_accum})
|
||||||
return breadcrumbs
|
return breadcrumbs
|
||||||
|
|
||||||
|
|
||||||
|
def human_readable_size(num_bytes):
|
||||||
|
"""
|
||||||
|
Convert a byte count into a human-readable string (e.g., 1.2 MB).
|
||||||
|
"""
|
||||||
|
if num_bytes is None:
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
num = float(num_bytes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "-"
|
||||||
|
units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
for unit in units:
|
||||||
|
if num < 1024 or unit == units[-1]:
|
||||||
|
return f"{num:.1f} {unit}" if num % 1 else f"{int(num)} {unit}"
|
||||||
|
num /= 1024
|
||||||
|
|
||||||
@app.route('/icon/<string:size>.png')
|
@app.route('/icon/<string:size>.png')
|
||||||
def serve_resized_icon(size):
|
def serve_resized_icon(size):
|
||||||
cached_image_bytes = get_cached_image(size)
|
cached_image_bytes = get_cached_image(size)
|
||||||
@ -623,6 +649,7 @@ def query_recent_connections():
|
|||||||
'timestamp': datetime.fromisoformat(row[0]).strftime('%d.%m.%Y %H:%M:%S'),
|
'timestamp': datetime.fromisoformat(row[0]).strftime('%d.%m.%Y %H:%M:%S'),
|
||||||
'full_path': row[1],
|
'full_path': row[1],
|
||||||
'filesize': row[2],
|
'filesize': row[2],
|
||||||
|
'filesize_human': human_readable_size(row[2]),
|
||||||
'mime_typ': row[3],
|
'mime_typ': row[3],
|
||||||
'location': row[4],
|
'location': row[4],
|
||||||
'user_agent': row[5],
|
'user_agent': row[5],
|
||||||
@ -664,6 +691,7 @@ def handle_request_initial_data():
|
|||||||
'timestamp': datetime.fromisoformat(row[0]).strftime('%d.%m.%Y %H:%M:%S'),
|
'timestamp': datetime.fromisoformat(row[0]).strftime('%d.%m.%Y %H:%M:%S'),
|
||||||
'full_path': row[1],
|
'full_path': row[1],
|
||||||
'filesize' : row[2],
|
'filesize' : row[2],
|
||||||
|
'filesize_human': human_readable_size(row[2]),
|
||||||
'mime_typ' : row[3],
|
'mime_typ' : row[3],
|
||||||
'location': row[4],
|
'location': row[4],
|
||||||
'user_agent': row[5],
|
'user_agent': row[5],
|
||||||
|
|||||||
@ -16,7 +16,8 @@ services:
|
|||||||
propagation: rshared
|
propagation: rshared
|
||||||
environment:
|
environment:
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_ENV=production
|
- FLASK_ENV=development
|
||||||
|
- FLASK_DEBUG=1
|
||||||
networks:
|
networks:
|
||||||
- traefik
|
- traefik
|
||||||
labels:
|
labels:
|
||||||
@ -37,10 +38,10 @@ services:
|
|||||||
# Internal port
|
# Internal port
|
||||||
- "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=5000"
|
- "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=5000"
|
||||||
|
|
||||||
# Production-ready Gunicorn command with eventlet
|
# Dev server with autoreload for live code changes
|
||||||
command: >
|
command: >
|
||||||
sh -c "pip install -r requirements.txt &&
|
sh -c "pip install -r requirements.txt &&
|
||||||
gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app"
|
flask run --host=0.0.0.0 --port=5000 --reload"
|
||||||
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
127
search_db_analyzer.py
Normal file
127
search_db_analyzer.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
from flask import render_template, request, jsonify
|
||||||
|
|
||||||
|
import auth
|
||||||
|
|
||||||
|
APP_CONFIG = auth.return_app_config()
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SEARCH_DB_PATH = os.path.join(BASE_DIR, "search.db")
|
||||||
|
|
||||||
|
# Open search.db in read-only mode to avoid accidental writes.
|
||||||
|
search_db = sqlite3.connect(f"file:{SEARCH_DB_PATH}?mode=ro", uri=True, check_same_thread=False)
|
||||||
|
search_db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_folder_path(folder_path: str) -> str:
|
||||||
|
"""Normalize folder paths to a consistent, slash-based format."""
|
||||||
|
return folder_path.replace("\\", "/").strip().strip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _list_children(parent: str = "") -> List[str]:
|
||||||
|
"""
|
||||||
|
Return the next folder level for the given parent.
|
||||||
|
- parent == "" → first level (basefolder column)
|
||||||
|
- parent != "" → distinct next segment in relative_path below that parent
|
||||||
|
"""
|
||||||
|
cursor = search_db.cursor()
|
||||||
|
|
||||||
|
normalized = _normalize_folder_path(parent)
|
||||||
|
if not normalized:
|
||||||
|
rows = cursor.execute("SELECT DISTINCT basefolder FROM files ORDER BY basefolder").fetchall()
|
||||||
|
return [row["basefolder"] for row in rows if row["basefolder"]]
|
||||||
|
|
||||||
|
prefix = normalized + "/"
|
||||||
|
rows = cursor.execute(
|
||||||
|
"SELECT relative_path FROM files WHERE relative_path LIKE ?",
|
||||||
|
(prefix + "%",)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
children = set()
|
||||||
|
plen = len(prefix)
|
||||||
|
for row in rows:
|
||||||
|
rel = row["relative_path"]
|
||||||
|
# Strip the prefix and keep only the next segment
|
||||||
|
remainder = rel[plen:]
|
||||||
|
if "/" in remainder:
|
||||||
|
next_seg = remainder.split("/", 1)[0]
|
||||||
|
else:
|
||||||
|
# File directly under parent; no deeper folder
|
||||||
|
continue
|
||||||
|
if next_seg:
|
||||||
|
children.add(next_seg)
|
||||||
|
|
||||||
|
return sorted(children)
|
||||||
|
|
||||||
|
|
||||||
|
def _query_counts(folder_path: str) -> Dict[str, Any]:
|
||||||
|
"""Run a grouped count query on search.db filtered by folder_path."""
|
||||||
|
normalized = _normalize_folder_path(folder_path)
|
||||||
|
params: List[str] = []
|
||||||
|
conditions: List[str] = []
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("Bitte einen Ordnerpfad angeben.")
|
||||||
|
|
||||||
|
# Match both basefolder and deeper paths inside that folder.
|
||||||
|
conditions.append("(relative_path LIKE ? OR basefolder = ?)")
|
||||||
|
params.extend([f"{normalized}/%", normalized])
|
||||||
|
|
||||||
|
where_sql = " AND ".join(conditions) if conditions else "1=1"
|
||||||
|
sql = f"""
|
||||||
|
SELECT COALESCE(category, 'Keine Kategorie') AS category_label,
|
||||||
|
COUNT(*) AS file_count
|
||||||
|
FROM files
|
||||||
|
WHERE {where_sql}
|
||||||
|
GROUP BY category_label
|
||||||
|
ORDER BY file_count DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursor = search_db.cursor()
|
||||||
|
rows = cursor.execute(sql, params).fetchall()
|
||||||
|
|
||||||
|
total = sum(row["file_count"] for row in rows)
|
||||||
|
categories = [
|
||||||
|
{"category": row["category_label"], "count": row["file_count"]}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {"total": total, "categories": categories}
|
||||||
|
|
||||||
|
|
||||||
|
def search_db_analyzer():
|
||||||
|
"""Render the UI for analyzing search.db by folder."""
|
||||||
|
return render_template(
|
||||||
|
"search_db_analyzer.html",
|
||||||
|
admin_enabled=auth.is_admin(),
|
||||||
|
title_short=APP_CONFIG.get("TITLE_SHORT", "Default Title"),
|
||||||
|
title_long=APP_CONFIG.get("TITLE_LONG", "Default Title"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def search_db_query():
|
||||||
|
"""Return grouped counts by category for a given folder path."""
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
folder_path = (payload.get("folder_path") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = _query_counts(folder_path)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
return jsonify({"error": f"Abfrage fehlgeschlagen: {exc}"}), 500
|
||||||
|
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
|
def search_db_folders():
|
||||||
|
"""Return next-level folder names for the given parent path (or basefolders)."""
|
||||||
|
parent = request.args.get("parent", "").strip()
|
||||||
|
try:
|
||||||
|
children = _list_children(parent)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
return jsonify({"error": f"Ordner konnten nicht geladen werden: {exc}"}), 500
|
||||||
|
|
||||||
|
return jsonify({"children": children})
|
||||||
@ -128,12 +128,13 @@ function renderContent(data) {
|
|||||||
}
|
}
|
||||||
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
|
contentHTML += `<li class="directory-item"><a href="#" class="directory-link" data-path="${dir.path}">${link_symbol} ${dir.name}</a>${share_link}</li>`;
|
||||||
});
|
});
|
||||||
if (data.breadcrumbs.length === 1) {
|
|
||||||
contentHTML += `<li class="link-item" onclick="viewSearch()"><a onclick="viewSearch() class="link-link">🔎 Suche</a></li>`;
|
|
||||||
}
|
|
||||||
contentHTML += '</ul>';
|
contentHTML += '</ul>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Add search link at top level above directories/files
|
||||||
|
if (data.breadcrumbs.length === 1) {
|
||||||
|
contentHTML = `<ul><li class="link-item" onclick="viewSearch()"><a onclick="viewSearch()" class="link-link">🔎 Suche</a></li></ul>` + contentHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// Render files (including music and non-image files)
|
// Render files (including music and non-image files)
|
||||||
currentMusicFiles = [];
|
currentMusicFiles = [];
|
||||||
|
|||||||
@ -42,6 +42,8 @@
|
|||||||
<a href="{{ url_for('songs_dashboard') }}">Wiederholungen</a>
|
<a href="{{ url_for('songs_dashboard') }}">Wiederholungen</a>
|
||||||
<span> | </span>
|
<span> | </span>
|
||||||
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
<a href="{{ url_for('folder_secret_config_editor') }}" id="edit-folder-config">Ordnerkonfiguration</a>
|
||||||
|
<span> | </span>
|
||||||
|
<a href="{{ url_for('search_db_analyzer') }}">Dateiindex Analyse</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
height: 85vh;
|
height: 85vh;
|
||||||
padding: 20px;
|
|
||||||
}
|
}
|
||||||
.map-section {
|
.map-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@ -39,6 +38,9 @@
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||||
}
|
}
|
||||||
|
.page-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
.stats {
|
.stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
@ -94,36 +96,37 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="split-view">
|
<div class="page-content">
|
||||||
<!-- Map Section -->
|
|
||||||
<div class="map-section">
|
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2 style="margin: 0 0 10px 0;">Live Connection Map</h2>
|
<h2 style="margin: 0;">Verbindungen der letzten 10 Minuten</h2>
|
||||||
<div class="stats">
|
<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-item">
|
||||||
<div class="stat-label">Last Connection</div>
|
<div class="stat-label">Last Connection</div>
|
||||||
<div class="stat-value" id="last-connection" style="font-size: 14px;">-</div>
|
<div class="stat-value" id="last-connection" style="font-size: 14px;">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">Total Connections</div>
|
||||||
|
<div class="stat-value" id="totalConnections">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">Total File Size</div>
|
||||||
|
<div class="stat-value" id="totalSize">0 B</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">Cached %</div>
|
||||||
|
<div class="stat-value" id="cachedPercent">0%</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-view">
|
||||||
|
<!-- Map Section -->
|
||||||
|
<div class="map-section">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table Section -->
|
<!-- Table Section -->
|
||||||
<div class="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">
|
<div class="table-container">
|
||||||
<table class="table table-hover">
|
<table class="table table-hover">
|
||||||
<thead class="table-secondary">
|
<thead class="table-secondary">
|
||||||
@ -144,6 +147,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
@ -228,8 +232,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMapStats() {
|
function updateMapStats() {
|
||||||
document.getElementById('active-dots').textContent = activeDots.size;
|
|
||||||
|
|
||||||
let mostRecent = null;
|
let mostRecent = null;
|
||||||
activeDots.forEach(dot => {
|
activeDots.forEach(dot => {
|
||||||
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
if (!mostRecent || dot.lastUpdate > mostRecent) {
|
||||||
@ -252,9 +254,31 @@
|
|||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function animateDots() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const fadeTime = 60000; // 60 seconds
|
const fadeTime = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
activeDots.forEach((dot, key) => {
|
activeDots.forEach((dot, key) => {
|
||||||
const age = now - dot.lastUpdate;
|
const age = now - dot.lastUpdate;
|
||||||
@ -306,6 +330,14 @@
|
|||||||
// Table functions
|
// Table functions
|
||||||
function updateStats(data) {
|
function updateStats(data) {
|
||||||
document.getElementById("totalConnections").textContent = data.length;
|
document.getElementById("totalConnections").textContent = data.length;
|
||||||
|
const totalBytes = data.reduce((sum, rec) => {
|
||||||
|
const val = parseFloat(rec.filesize);
|
||||||
|
return sum + (isNaN(val) ? 0 : val);
|
||||||
|
}, 0);
|
||||||
|
document.getElementById("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;
|
||||||
|
document.getElementById("cachedPercent").textContent = `${pct}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRow(record, animate=true) {
|
function createRow(record, animate=true) {
|
||||||
@ -321,7 +353,7 @@
|
|||||||
<td>${record.location}</td>
|
<td>${record.location}</td>
|
||||||
<td>${record.user_agent}</td>
|
<td>${record.user_agent}</td>
|
||||||
<td>${record.full_path}</td>
|
<td>${record.full_path}</td>
|
||||||
<td>${record.filesize}</td>
|
<td title="${record.filesize}">${record.filesize_human || record.filesize}</td>
|
||||||
<td>${record.mime_typ}</td>
|
<td>${record.mime_typ}</td>
|
||||||
<td>${record.cached}</td>
|
<td>${record.cached}</td>
|
||||||
`;
|
`;
|
||||||
|
|||||||
262
templates/search_db_analyzer.html
Normal file
262
templates/search_db_analyzer.html
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Search-DB Analyse{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<h2>Index auswerten</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
Ordner auswählen und die Anzahl der Dateien pro Kategorie aus der Dateiindex abrufen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="analyzer-form" class="row g-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Ordnerauswahl</label>
|
||||||
|
<div id="folder-levels" class="d-flex flex-wrap gap-2 align-items-end"></div>
|
||||||
|
<div class="form-text">Wähle zuerst den Hauptordner, füge dann bei Bedarf Unterordner hinzu.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 col-12 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Abfrage starten</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="analyzer-feedback" class="mt-3 text-danger" style="display: none;"></div>
|
||||||
|
|
||||||
|
<div id="analyzer-result" class="mt-4" style="display: none;">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h4 class="mb-0">Ergebnis</h4>
|
||||||
|
<span class="badge bg-secondary" id="totalCount"></span>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kategorie</th>
|
||||||
|
<th>Anzahl Dateien</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="result-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ super() }}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const form = document.getElementById('analyzer-form');
|
||||||
|
const feedback = document.getElementById('analyzer-feedback');
|
||||||
|
const resultWrapper = document.getElementById('analyzer-result');
|
||||||
|
const resultBody = document.getElementById('result-body');
|
||||||
|
const totalCount = document.getElementById('totalCount');
|
||||||
|
const levelsContainer = document.getElementById('folder-levels');
|
||||||
|
|
||||||
|
const levelSelects = [];
|
||||||
|
|
||||||
|
const createSelect = (level, options) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'folder-level';
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-select';
|
||||||
|
select.dataset.level = level;
|
||||||
|
select.required = level === 0;
|
||||||
|
|
||||||
|
const placeholder = document.createElement('option');
|
||||||
|
placeholder.value = '';
|
||||||
|
placeholder.textContent = level === 0 ? 'Bitte auswählen' : 'Optionaler Unterordner';
|
||||||
|
select.appendChild(placeholder);
|
||||||
|
|
||||||
|
options.forEach((opt) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = opt;
|
||||||
|
option.textContent = opt;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
removeLevelsFrom(level + 1);
|
||||||
|
updateControlButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'd-flex gap-2 align-items-center';
|
||||||
|
const btnGroup = document.createElement('div');
|
||||||
|
btnGroup.className = 'btn-group btn-group-sm';
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.className = 'btn btn-outline-secondary';
|
||||||
|
addBtn.textContent = '+';
|
||||||
|
addBtn.addEventListener('click', () => handleAddLevel());
|
||||||
|
btnGroup.appendChild(addBtn);
|
||||||
|
|
||||||
|
let removeBtn = null;
|
||||||
|
if (level > 0) {
|
||||||
|
removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'btn btn-outline-danger';
|
||||||
|
removeBtn.textContent = '-';
|
||||||
|
removeBtn.addEventListener('click', () => {
|
||||||
|
removeLevelsFrom(level);
|
||||||
|
updateControlButtons();
|
||||||
|
});
|
||||||
|
btnGroup.appendChild(removeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
controls.append(select, btnGroup);
|
||||||
|
wrapper.append(controls);
|
||||||
|
levelsContainer.appendChild(wrapper);
|
||||||
|
levelSelects.push({ wrapper, select, addBtn, removeBtn, btnGroup });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeLevelsFrom = (startIndex) => {
|
||||||
|
while (levelSelects.length > startIndex) {
|
||||||
|
const item = levelSelects.pop();
|
||||||
|
levelsContainer.removeChild(item.wrapper);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPath = () => {
|
||||||
|
const parts = levelSelects
|
||||||
|
.map(({ select }) => select.value)
|
||||||
|
.filter((v) => v && v.trim() !== '');
|
||||||
|
return parts.join('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChildren = async (parentPath = '') => {
|
||||||
|
const params = parentPath ? `?parent=${encodeURIComponent(parentPath)}` : '';
|
||||||
|
const response = await fetch(`{{ url_for("search_db_folders") }}${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Ordner konnten nicht geladen werden.');
|
||||||
|
}
|
||||||
|
return data.children || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateControlButtons = () => {
|
||||||
|
if (!levelSelects.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
levelSelects.forEach((item, idx) => {
|
||||||
|
const isLast = idx === levelSelects.length - 1;
|
||||||
|
if (item.btnGroup) {
|
||||||
|
item.btnGroup.classList.toggle('d-none', !isLast);
|
||||||
|
}
|
||||||
|
if (item.addBtn) {
|
||||||
|
item.addBtn.disabled = !item.select.value;
|
||||||
|
}
|
||||||
|
if (item.removeBtn) {
|
||||||
|
const hideRemove = !isLast || idx === 0;
|
||||||
|
item.removeBtn.classList.toggle('d-none', hideRemove);
|
||||||
|
item.removeBtn.disabled = hideRemove;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initBaseLevel = async () => {
|
||||||
|
try {
|
||||||
|
const children = await fetchChildren('');
|
||||||
|
if (!children.length) {
|
||||||
|
feedback.textContent = 'Keine Ordner in der Datenbank gefunden.';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createSelect(0, children);
|
||||||
|
updateControlButtons();
|
||||||
|
} catch (error) {
|
||||||
|
feedback.textContent = error.message;
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLevel = async () => {
|
||||||
|
feedback.style.display = 'none';
|
||||||
|
const path = currentPath();
|
||||||
|
if (!path) {
|
||||||
|
feedback.textContent = 'Bitte zuerst einen Hauptordner auswählen.';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const children = await fetchChildren(path);
|
||||||
|
if (!children.length) {
|
||||||
|
feedback.textContent = 'Keine weiteren Unterordner vorhanden.';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createSelect(levelSelects.length, children);
|
||||||
|
updateControlButtons();
|
||||||
|
} catch (error) {
|
||||||
|
feedback.textContent = error.message;
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initBaseLevel();
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
feedback.style.display = 'none';
|
||||||
|
resultWrapper.style.display = 'none';
|
||||||
|
|
||||||
|
const folderPath = currentPath();
|
||||||
|
|
||||||
|
if (!folderPath) {
|
||||||
|
feedback.textContent = 'Bitte einen Ordner auswählen.';
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitButton = form.querySelector('button[type="submit"]');
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Wird geladen...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('{{ url_for("search_db_query") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ folder_path: folderPath })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Abfrage fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
resultBody.innerHTML = '';
|
||||||
|
if (data.categories && data.categories.length) {
|
||||||
|
data.categories.forEach((row) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const cat = document.createElement('td');
|
||||||
|
const cnt = document.createElement('td');
|
||||||
|
|
||||||
|
cat.textContent = row.category || 'Keine Kategorie';
|
||||||
|
cnt.textContent = row.count;
|
||||||
|
|
||||||
|
tr.append(cat, cnt);
|
||||||
|
resultBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const td = document.createElement('td');
|
||||||
|
td.colSpan = 2;
|
||||||
|
td.textContent = 'Keine Treffer gefunden.';
|
||||||
|
tr.appendChild(td);
|
||||||
|
resultBody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount.textContent = `${data.total || 0} Dateien`;
|
||||||
|
resultWrapper.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
feedback.textContent = error.message;
|
||||||
|
feedback.style.display = 'block';
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = 'Abfrage starten';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user