diff --git a/JOURNAL.md b/JOURNAL.md index a613856..4d421c7 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -1394,4 +1394,61 @@ ALTER TABLE login_attempts ALTER COLUMN blocked_until TYPE TIMESTAMP WITH TIME Z - ✅ Copy-Buttons mit Clipboard-API - ✅ Toggle-Switches ändern Lizenzstatus - ✅ Bulk-Operationen vollständig implementiert -- ✅ Testdaten erfolgreich eingefügt \ No newline at end of file +- ✅ Testdaten erfolgreich eingefügt + +### 2025-06-08: UI/UX Überarbeitung - Phase 4 (Sortierbare Tabellen) + +**Problem:** +- Keine Möglichkeit, Tabellen nach verschiedenen Spalten zu sortieren +- Besonders bei großen Datenmengen schwer zu navigieren + +**Lösung - Hybrid-Ansatz:** +1. **Client-seitige Sortierung für kleine Tabellen:** + - Dashboard (3 kleine Übersichtstabellen) + - Blocked IPs (typischerweise wenige Einträge) + - Backups (begrenzte Anzahl) + - JavaScript-basierte Sortierung ohne Reload + +2. **Server-seitige Sortierung für große Tabellen:** + - Licenses (potenziell tausende Einträge) + - Customers (viele Kunden möglich) + - Audit Log (wächst kontinuierlich) + - Sessions (viele aktive/beendete Sessions) + - URL-Parameter für Sortierung mit SQL ORDER BY + +**Implementierung:** +1. **Client-seitige Sortierung:** + - Generische JavaScript-Funktion in base.html + - CSS-Klasse `.sortable-table` für betroffene Tabellen + - Sortier-Indikatoren (↑↓↕) bei Hover/Active + - Unterstützung für Text, Zahlen und deutsche Datumsformate + +2. **Server-seitige Sortierung:** + - Query-Parameter `sort` und `order` in Routes + - Whitelist für erlaubte Sortierfelder (SQL-Injection-Schutz) + - Makro-Funktionen für sortierbare Header + - Sortier-Parameter in Pagination-Links erhalten + +**Geänderte Dateien:** +- `v2_adminpanel/templates/base.html`: CSS und JavaScript für Sortierung +- `v2_adminpanel/templates/dashboard.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/blocked_ips.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/backups.html`: Client-seitige Sortierung +- `v2_adminpanel/templates/licenses.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/customers.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/audit_log.html`: Server-seitige Sortierung +- `v2_adminpanel/templates/sessions.html`: Server-seitige Sortierung (2 Tabellen) +- `v2_adminpanel/app.py`: 4 Routes erweitert für Sortierung + +**Besonderheiten:** +- Sessions-Seite hat zwei unabhängige Tabellen mit eigenen Sortierparametern +- Intelligente Datentyp-Erkennung (numeric, date) für korrekte Sortierung +- Visuelle Sortier-Indikatoren zeigen aktuelle Sortierung +- Alle anderen Filter und Suchparameter bleiben bei Sortierung erhalten + +**Status:** +- ✅ Client-seitige Sortierung für kleine Tabellen +- ✅ Server-seitige Sortierung für große Tabellen +- ✅ Sortier-Indikatoren und visuelle Rückmeldung +- ✅ SQL-Injection-Schutz durch Whitelisting +- ✅ Vollständige Integration mit bestehenden Features \ No newline at end of file diff --git a/backups/backup_v2docker_20250608_215318_encrypted.sql.gz.enc b/backups/backup_v2docker_20250608_215318_encrypted.sql.gz.enc new file mode 100644 index 0000000..0528b73 --- /dev/null +++ b/backups/backup_v2docker_20250608_215318_encrypted.sql.gz.enc @@ -0,0 +1 @@ +gAAAAABoReoubFw8bzb2UpGW5JG0QSHqHpLaibOwj76STw7RFGgQB3euKPMmTQRCUL642-uPLM_TcBNkBfGMut9ogNzCcGTCO7fadBRFhv2yjq8x0fYYBER0F7w9RDdMPl30B8QIYgGHqIp3JDwpUvG5pVw_qJRmVIinwJiryElrecIgvj4ma2bUmu1bmVhEs8_NmjseHD_SgLzKnc9kmsTSxkOi8ejN-QzV1VxVPb86a2wAEwsIxvr1G408BaAnoSlfR1iZvLwjNncLAHeSM9VMHXE3P4C9IAnT00KcBBaJjCDjdDAraR7iR8AMOHFY7i00lDY2BYap3XQnSuDZDS6Sb23A3NGQAEAmtQ7aTbI6x2jJC-_eFSThZ1118gdG4tow8ogvpjYX0wDBt9fYfk0Y9zgu_mKyElcPvHy0Ad-ql-Wu10ni7OD_o3Zb-EHK_-5ZPi5ukglvgs8Xpzax2ubCOK2lzpXKL8MRM7l3Pu9K-OdcMw8Wncof5DAIuZHnyyl6oYxsrw4necEmVVSZKnRbki3au_Qso4jtLDaP1RigbP9gQDT1LM3wfZM8pD6XGItkxg3jpYOi2HCRsWbE4_1bjvNCxQK2kaJrRrXzNv9LfoWpR7DGOYshA7QaIMP-FvFIX7YAlQhgC9_UqsIhu5F9rZWQS_VXG7bAYZvgva8mdOnS6SXPrvZ5_479LHwBqv-2i1Qpu-HOlbN1WLc-Zwhr9GeWYcr2ikN_fThUxv1z3FpPiFGpQfjgCshdoSdX6FSqefA0El1CnsfGi_mLg0iliMXLpyq2pZ-EO_Um2TLVEUaOlZNk62wblu54v0Bjxe7qKV-jURSHYbTPlP9a19Jjk2gRB2VPfgvw98kiXXbRoqRbNvisu3YAEuzn2D6_V23jEaXDzDvWG-SXfs4TbWCZhMn56OU5UIUqA0cvFebAVLA1-nN2qL60vhMcu5L91jFuy_TmhSyxhSEuqPMo3ATdzv_LegYjlqj3V2jhoW1ddtkeia_5aeKx833nRbS18p21rF0rgWGDWdlb4T1zhddqoJOIaQirWmrSkrCKWhHYLtAg9HIcWjsGtlnLj8H1ptXF53Oq95Up7Rw5l9PNk1bN2A7VpLG4h_7CEbftNHQI_pY0UZGa3Bk-Sh7TJJ2X0T6B-FTX3iAAnmbWbmyUks4V1ceDWgwhfiXK488x2OewiytrvWFTstj_optYi1tm8GUMIe7rqSzzxlhdDxOwBMi1pCBgyKptZaJHzFttV3JOCTWOZSot_qhQAaP-efBlpTZDvXwnigDqf82DMvmtRfj13oEv-SPyrJx3upMnwQggX5ddKVEyP1M9epRBmNofTFL9LJ1b-uzStYNEcAN_Vz6r_GKLc6EvgqsMMN53X8_aEH9D-LKtQvXRS04f45sgpsKijenD380IzFI4fqql8q8B4N9A2fYWGQpAkJZqaWtoxB9XiC9ezh8V2A3OYgzPSpZ8sZyuA1JDekHRlO985iqe0b3JA-emVwCmRJg5eLmXiSYoS5_jz4Q92IinD35WZWUTR6Yxx-XiocvTEmYYW6oVeqCceO2nVf6Nd6VCbtuya7LARf55EL7JSyW_-51OrE3SbRaQWODCZLi37nrcPMk45kkFDCMvm3iYhnt6HUZlFykPDWjJI-PTTBcPBdtQw9mBKHs1Mhj3oVHAHJtuvyqDUvFikStDMT9CvQlXnKMV7V-ZvXLIjmXiR1nthdeRWxBiJPBCbrBgueVE9C9Md8amIwFTAe0_XIP6uMzF2r7mqa90nS0Ba5XzzOL9EavkxoI5XHnMYT_b2DwoLEw8SLGb3I68boc4FuhK6Va236aZsAPTfppd8ueOqKbK_Npe4FMiAw2-sMAWKAO7RiseF7SqnuZddHBh2oTRmj08dpsWwBJDB-aBmVq5Ttm6CgAitzpguXhY2M2gVne0nB5FCltGixNkMYOWzajvnbtk7L42Zk8aNFvhIL3LTGX5RlsxHMCNmMj8WuItGTN5YGQfOrTM9miHot5WQB8-_5CYDtpOhvHnuyGZgoSXPlsWlf-Yfe3iDlbkVetpsTTyp5-g1Hqvh6k658qEAi9eEoQN2MK2dBEsJ8l65rtjYw-Q9GxCTXIyB8tRpDbxmiGtnZvBeKkkNuj7vNlHN7F-Z4gkiv3uhaybfTCoBwqYKya9Mj4okroDQYYYU1po34mECJrz4SnUdrp7OhJ9xpkE1WyAsmfxW0600lm1JLweSJLK_aVVokcDeOjjSRElyVP07bF_l90f5Xe6W43Sh_CWmy9mp5EY_pMqZ7WaMh5Lm5DL5SKIHpUQ7dwtlOq1DQ7ms6AO0Ipp9u8y8PfxBI1ql1KpX79hA3GnKGbZp9NiC2U9DlEWfJIRZmDVHOSEinZPdCuhch2zVUK8jO-MLvIIYn_qFben6PEt9ozfrNQVjGjp9XGEUlXGxWfve13eKt7U-10Q7K-gm0D_GU24d4u7vWUMWehrAg79WwJTS6AVwvrgQzvOXmstPf6uGvzLYKt5cMMl2AlRp3tlgNg6WYYJ-BqcszmW_7jgrRMVDHuCFhYsMIh5x3UqlNdnYKz2-CPBVFTvK0yIfboejApqUcT1Xsl2EGJFm0Fwo_EhJAeVVMxqAHbTLpsVRtP2tae3GiAT8GV2ME7XO1p3n1hRnPdkinY9t31-BuvofE7GHqYlsKqfgESfblBOazFsvnlxJldotLIp6j1RLp4tGhxFVOmMoNrXdPyYHdNe7tM--4ynJ1gQefvuDcui53geQseGDUZ2sjoeLxQjGYXqXXSMlJ0mhHY_ZyhadIZOuGJ5POFb7nP5qIQ8ADlYQA22Y0Jy5CFxjbjDblR1DH4wDm6u76Qz5YSclUoHPPZDTlszl9g7vklETM3RreQXoEa34UHG09rXyNNPSkEY7aDybXOzLcVwMf2KYp6FHVyHJf8jRPT0223iSGlB-Vvq3a9mtIY4miawedHfdzg68j9b3rRt0oschrctW4poOOhzz48IEzjQR-6Mbjc42AGPEwc7FJxJmjZ2piDuZ_A_LSBOlmkISQqp7yARUs-Y4NYrrRG3X0GTQtsk3VGbIokJLq7H3j2-8188q3KFAY35GZDaDr9zZSFKRUZPGSkbl_s7t5mhiPImGkPQGt1BVaJhmfplfrtRWhs3s2l0T1WrcRe-NpGfLYQG8D1kokDAMwVtJ1gNajGH0cVTZSS7TKAcwVCdtnPEk5Yn5KiklHzBGB_5jCiLIGlLRGpvHgH1j7yTYkTTMGgGpqmF-KjBr_2reG93xhQXPjDfOpVJaa5IEoVIRrIptoyjxu-9fqN2rSF8YuWLWu8MuMoq3uYGAD6itXR8DLgEkn3JsmG1eXbKLcCY8T0y0G3x1xu_wjnQrlZZvU-d6kI-gR_v9nwbvsXbzWt7I9WKuFd5YE6VP0Ko5j4CFtQs6vfWjgwKkhmBGQ9E4DxuNRqyE_GchTDA7E5j_T5rtq7Jj_13DMhjCFpzFiWDGjvdxsOEZza2VJ2EN5qIRcFdlTNpw5YKMpLxNPuywXafQ1SeUNOaOTOTCIT5a_E-GczkngXqunOrxVVFyOOtmuD83wV3eyn3TJFZB_V0JcLD0jUflO2itM6rng03vhz6cCBl114MEXx2f1X2H20R0-8Djxvt9EcxjuY3fqWMhIWmusx3gOPUzBtJ80EKnkTfafnjdOAutzeXdrVUg9DZL-cLoHXvaJBcVul-hhEMZhBhLhnkopdU6Sf4rSesyrT_X99JfZKOP3sdSWFQrMkg6zxu6l5V7M0D6T7QW7nNBY5wi2DkEd_JQCvzCdi-A_RVpALIqHLe7XTlB29SErd9AUcDI0pxRr_uYhV-9u03Tcub-vXFPZvAgjUDReu2T-7FGc1FC6FdhW_WLlg8Z-6Zf9Ld6vU6Ngj8x7IYfHouRgUOqCCozHVaKded2AhsteqvmzYlFw12g7djnJIgxVHE9FnGhDWTobCIZgSryc7Y05_fIVLlM98S5K29qSl-yZ1apfL9K1zwcm22HQYJd75YNGZVunDtLhs2s6qJSYSZ9KHYbHj3IoDjuJ6qYbD3A5kJlktHexQt-vEYKdyvFdPPvwvkKn-o897weQkDc2bJ5yvWI9g5Ss_nGXb0ASb9K7DmB6AAAuAxIanAQJbC-4hb1rQtf1lZIkZql0p4W5FQyEzw_vhEe6leFXc7WJjrFcVcyIthtl3MO5R8cf_mmqf--KRh-pj4KHNAhL0pAdXimkaPgOwxwfHYDsO9G2dXvpxKGSa2v3MOuo9_CAdvPKDs8B7zvDvaIhV5nKg8gC-_Mmxg5hvvkDEskq91cf5m3qchviKH6IzH88QmbQ24V9ZbnZILK1OpzRQKtHsgoYfCvwCgGFl5AvZQkbjNcIWDWf6fihrCD0UMn0rGdQBSj9uU7Fjc5sJBsL9aLX62HwgeVIjGdZjh1WFzMUI2EW2zLGnY_-qDY2yCizX4alCQAfwfB5Nn6w6Ah9t5eDSgER72FRgli9jsEutg2bNo-CjNdIL2dNILQJ860Xd8jWMGOSMxn8fzbWkCTWrgI3oa3XkZUkg4IJb4rugLcgKrBLwujMLTzLDknXi3SzbaozgKtdpz3U6G2lUbsYRhoEjCSE4i6VoCDpViwYQbg0EeBGSNhjHIZdp0kmTxYvzdrbkWJw_TRy5pmiFJkYGSDXijP_a92cj06y2HiwZ2dSfMWrZvt0WSj-T3ZcgJy34yJ1pYsTJ3ZgaRq2QWMqfTo8LAk4FV9xITbxfTW6NNDaoMTS8nbkxOqxE-9qDrsOOTLSFIq-gg8i01xOAe57s7mXbX6eDGkrW0miFDUYmWfcOW_VRlvZcIqBeHoa9GGAF7AOvk0Eywe7ZhXg3_adfzAyD0LlLIX8snkhoYzTtWgEFODBtDsvf5H8IcToI2MVSK8xlnBYF12QLaIioejO_UPdjCUCn7zfiZXIQ5oYc0cm2IfrJjh4NMLdiDxUZ35RDWydiZmcFxzitKfKbPxs4tiZmbpUKWZFCNb4e2BU6qnWxm0sUI4c3JIVgTECC7oyc9K4MgoinXUFrhrFDwzWqDHBj6UsfFpaJnkhQY02UhLS4DcMoNoxz9HD0Y3lqVSJC_eahA-8IYA6TgwPO3B3gMkY0SvvYiRXl2yVUiKLSKCp1DDAR5OTuKjBgw5glaFXrlL9x3nGNdvSUD5a5t0IKVAYtSeWIuCag3RrttuawCOSITeKzC0NZUmKMTd5WWK_2PcbnxDHUhQ7ta7UU= \ No newline at end of file diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 7aff548..2bf5324 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -1323,8 +1323,31 @@ def licenses(): filter_type = request.args.get('type', '') filter_status = request.args.get('status', '') page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'valid_until') + order = request.args.get('order', 'desc') per_page = 20 + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'id': 'l.id', + 'license_key': 'l.license_key', + 'customer': 'c.name', + 'email': 'c.email', + 'type': 'l.license_type', + 'valid_from': 'l.valid_from', + 'valid_until': 'l.valid_until', + 'status': 'status', + 'active': 'l.is_active' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'valid_until' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + # SQL Query mit optionaler Suche und Filtern query = """ SELECT l.id, l.license_key, c.name, c.email, l.license_type, @@ -1374,7 +1397,20 @@ def licenses(): # Pagination offset = (page - 1) * per_page - query += " ORDER BY l.valid_until DESC LIMIT %s OFFSET %s" + + # Spezialbehandlung für berechnete Felder + if sort == 'status': + # Für Status müssen wir die CASE-Bedingung in ORDER BY wiederholen + query += f""" ORDER BY + CASE + WHEN l.is_active = FALSE THEN 'deaktiviert' + WHEN l.valid_until < CURRENT_DATE THEN 'abgelaufen' + WHEN l.valid_until < CURRENT_DATE + INTERVAL '30 days' THEN 'läuft bald ab' + ELSE 'aktiv' + END {order.upper()} LIMIT %s OFFSET %s""" + else: + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" + params.extend([per_page, offset]) cur.execute(query, params) @@ -1394,6 +1430,8 @@ def licenses(): page=page, total_pages=total_pages, total=total, + sort=sort, + order=order, username=session.get('username')) @app.route("/license/edit/", methods=["GET", "POST"]) @@ -1508,8 +1546,28 @@ def customers(): # Parameter search = request.args.get('search', '').strip() page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'created_at') + order = request.args.get('order', 'desc') per_page = 20 + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'id': 'c.id', + 'name': 'c.name', + 'email': 'c.email', + 'created_at': 'c.created_at', + 'licenses': 'license_count', + 'active_licenses': 'active_licenses' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'created_at' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + # SQL Query mit optionaler Suche base_query = """ SELECT c.id, c.name, c.email, c.created_at, @@ -1544,9 +1602,9 @@ def customers(): # Pagination offset = (page - 1) * per_page - query = base_query + """ + query = base_query + f""" GROUP BY c.id, c.name, c.email, c.created_at - ORDER BY c.created_at DESC + ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s """ params.extend([per_page, offset]) @@ -1566,6 +1624,8 @@ def customers(): page=page, total_pages=total_pages, total=total, + sort=sort, + order=order, username=session.get('username')) @app.route("/customer/edit/", methods=["GET", "POST"]) @@ -1678,8 +1738,44 @@ def sessions(): conn = get_connection() cur = conn.cursor() + # Sortierparameter + active_sort = request.args.get('active_sort', 'last_heartbeat') + active_order = request.args.get('active_order', 'desc') + ended_sort = request.args.get('ended_sort', 'ended_at') + ended_order = request.args.get('ended_order', 'desc') + + # Whitelist für erlaubte Sortierfelder - Aktive Sessions + active_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'last_heartbeat': 's.last_heartbeat', + 'inactive': 'minutes_inactive' + } + + # Whitelist für erlaubte Sortierfelder - Beendete Sessions + ended_sort_fields = { + 'customer': 'c.name', + 'license': 'l.license_key', + 'ip': 's.ip_address', + 'started': 's.started_at', + 'ended_at': 's.ended_at', + 'duration': 'duration_minutes' + } + + # Validierung + if active_sort not in active_sort_fields: + active_sort = 'last_heartbeat' + if ended_sort not in ended_sort_fields: + ended_sort = 'ended_at' + if active_order not in ['asc', 'desc']: + active_order = 'desc' + if ended_order not in ['asc', 'desc']: + ended_order = 'desc' + # Aktive Sessions abrufen - cur.execute(""" + cur.execute(f""" SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, s.user_agent, s.started_at, s.last_heartbeat, EXTRACT(EPOCH FROM (NOW() - s.last_heartbeat))/60 as minutes_inactive @@ -1687,12 +1783,12 @@ def sessions(): JOIN licenses l ON s.license_id = l.id JOIN customers c ON l.customer_id = c.id WHERE s.is_active = TRUE - ORDER BY s.last_heartbeat DESC + ORDER BY {active_sort_fields[active_sort]} {active_order.upper()} """) active_sessions = cur.fetchall() # Inaktive Sessions der letzten 24 Stunden - cur.execute(""" + cur.execute(f""" SELECT s.id, s.session_id, l.license_key, c.name, s.ip_address, s.started_at, s.ended_at, EXTRACT(EPOCH FROM (s.ended_at - s.started_at))/60 as duration_minutes @@ -1701,7 +1797,7 @@ def sessions(): JOIN customers c ON l.customer_id = c.id WHERE s.is_active = FALSE AND s.ended_at > NOW() - INTERVAL '24 hours' - ORDER BY s.ended_at DESC + ORDER BY {ended_sort_fields[ended_sort]} {ended_order.upper()} LIMIT 50 """) recent_sessions = cur.fetchall() @@ -1712,6 +1808,10 @@ def sessions(): return render_template("sessions.html", active_sessions=active_sessions, recent_sessions=recent_sessions, + active_sort=active_sort, + active_order=active_order, + ended_sort=ended_sort, + ended_order=ended_order, username=session.get('username')) @app.route("/session/end/", methods=["POST"]) @@ -1914,8 +2014,27 @@ def audit_log(): filter_action = request.args.get('action', '').strip() filter_entity = request.args.get('entity', '').strip() page = request.args.get('page', 1, type=int) + sort = request.args.get('sort', 'timestamp') + order = request.args.get('order', 'desc') per_page = 50 + # Whitelist für erlaubte Sortierfelder + allowed_sort_fields = { + 'timestamp': 'timestamp', + 'username': 'username', + 'action': 'action', + 'entity': 'entity_type', + 'ip': 'ip_address' + } + + # Validierung + if sort not in allowed_sort_fields: + sort = 'timestamp' + if order not in ['asc', 'desc']: + order = 'desc' + + sort_field = allowed_sort_fields[sort] + # SQL Query mit optionalen Filtern query = """ SELECT id, timestamp, username, action, entity_type, entity_id, @@ -1946,7 +2065,7 @@ def audit_log(): # Pagination offset = (page - 1) * per_page - query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" + query += f" ORDER BY {sort_field} {order.upper()} LIMIT %s OFFSET %s" params.extend([per_page, offset]) cur.execute(query, params) @@ -1974,6 +2093,8 @@ def audit_log(): page=page, total_pages=total_pages, total=total, + sort=sort, + order=order, username=session.get('username')) @app.route("/backups") diff --git a/v2_adminpanel/templates/audit_log.html b/v2_adminpanel/templates/audit_log.html index bc411cc..e6f4e56 100644 --- a/v2_adminpanel/templates/audit_log.html +++ b/v2_adminpanel/templates/audit_log.html @@ -2,6 +2,27 @@ {% block title %}Log{% endblock %} +{% macro sortable_header(label, field, current_sort, current_order) %} + + {% if current_sort == field %} + + {% else %} + + {% endif %} + {{ label }} + + {% if current_sort == field %} + {% if current_order == 'asc' %}↑{% else %}↓{% endif %} + {% else %} + ↕ + {% endif %} + + + +{% endmacro %} + {% block extra_css %} @@ -314,6 +369,81 @@ // Initial Heartbeat extendSession(); + + // Client-side table sorting + document.addEventListener('DOMContentLoaded', function() { + // Initialize all sortable tables + const sortableTables = document.querySelectorAll('.sortable-table'); + + sortableTables.forEach(table => { + const headers = table.querySelectorAll('th.sortable'); + + headers.forEach((header, index) => { + header.addEventListener('click', function() { + sortTable(table, index, header); + }); + }); + }); + }); + + function sortTable(table, columnIndex, header) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + const isNumeric = header.dataset.type === 'numeric'; + const isDate = header.dataset.type === 'date'; + + // Determine sort direction + let direction = 'asc'; + if (header.classList.contains('asc')) { + direction = 'desc'; + } + + // Remove all sort classes from headers + table.querySelectorAll('th.sortable').forEach(th => { + th.classList.remove('asc', 'desc'); + }); + + // Add appropriate class to clicked header + header.classList.add(direction); + + // Sort rows + rows.sort((a, b) => { + let aValue = a.cells[columnIndex].textContent.trim(); + let bValue = b.cells[columnIndex].textContent.trim(); + + // Handle different data types + if (isNumeric) { + aValue = parseFloat(aValue.replace(/[^0-9.-]/g, '')) || 0; + bValue = parseFloat(bValue.replace(/[^0-9.-]/g, '')) || 0; + } else if (isDate) { + // Parse German date format (DD.MM.YYYY HH:MM) + aValue = parseGermanDate(aValue); + bValue = parseGermanDate(bValue); + } else { + // Text comparison with locale support for umlauts + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + + if (aValue < bValue) return direction === 'asc' ? -1 : 1; + if (aValue > bValue) return direction === 'asc' ? 1 : -1; + return 0; + }); + + // Reorder rows in DOM + rows.forEach(row => tbody.appendChild(row)); + } + + function parseGermanDate(dateStr) { + // Handle DD.MM.YYYY HH:MM format + const parts = dateStr.match(/(\d{2})\.(\d{2})\.(\d{4})\s*(\d{2}:\d{2})?/); + if (parts) { + const [_, day, month, year, time] = parts; + const timeStr = time || '00:00'; + return new Date(`${year}-${month}-${day}T${timeStr}`); + } + return new Date(0); + } {% block extra_js %}{% endblock %} diff --git a/v2_adminpanel/templates/blocked_ips.html b/v2_adminpanel/templates/blocked_ips.html index 7df8354..db5f500 100644 --- a/v2_adminpanel/templates/blocked_ips.html +++ b/v2_adminpanel/templates/blocked_ips.html @@ -18,17 +18,17 @@
{% if blocked_ips %}
- +
- - - - - - - - + + + + + + + + diff --git a/v2_adminpanel/templates/customers.html b/v2_adminpanel/templates/customers.html index ada11df..3ff73e8 100644 --- a/v2_adminpanel/templates/customers.html +++ b/v2_adminpanel/templates/customers.html @@ -2,6 +2,27 @@ {% block title %}Kundenverwaltung{% endblock %} +{% macro sortable_header(label, field, current_sort, current_order) %} + +{% endmacro %} + {% block content %}
@@ -51,11 +72,11 @@
IP-AdresseVersucheErster VersuchLetzter VersuchGesperrt bisLetzter UserLetzte MeldungStatusIP-AdresseVersucheErster VersuchLetzter VersuchGesperrt bisLetzter UserLetzte MeldungStatus Aktionen
+ {% if current_sort == field %} + + {% else %} + + {% endif %} + {{ label }} + + {% if current_sort == field %} + {% if current_order == 'asc' %}↑{% else %}↓{% endif %} + {% else %} + ↕ + {% endif %} + + +
- - - - - + {{ sortable_header('ID', 'id', sort, order) }} + {{ sortable_header('Name', 'name', sort, order) }} + {{ sortable_header('E-Mail', 'email', sort, order) }} + {{ sortable_header('Erstellt am', 'created_at', sort, order) }} + {{ sortable_header('Lizenzen (Aktiv/Gesamt)', 'licenses', sort, order) }} @@ -105,31 +126,31 @@

diff --git a/v2_adminpanel/templates/dashboard.html b/v2_adminpanel/templates/dashboard.html index eaccc73..b6debe5 100644 --- a/v2_adminpanel/templates/dashboard.html +++ b/v2_adminpanel/templates/dashboard.html @@ -221,14 +221,14 @@

-
IDNameE-MailErstellt amLizenzen (Aktiv/Gesamt)Aktionen
+
- - - - - + + + + + @@ -266,12 +266,12 @@
{% if stats.expiring_licenses %}
-
ZeitIP-AdresseVersucheFehlermeldungStatusZeitIP-AdresseVersucheFehlermeldungStatus
+
- - - + + + @@ -301,12 +301,12 @@
{% if stats.recent_licenses %}
-
KundeLizenzTageKundeLizenzTage
+
- - - + + + diff --git a/v2_adminpanel/templates/licenses.html b/v2_adminpanel/templates/licenses.html index ade7346..85b4d1c 100644 --- a/v2_adminpanel/templates/licenses.html +++ b/v2_adminpanel/templates/licenses.html @@ -2,6 +2,27 @@ {% block title %}Lizenzübersicht{% endblock %} +{% macro sortable_header(label, field, current_sort, current_order) %} + +{% endmacro %} + {% block extra_css %} {% endblock %} @@ -82,15 +103,15 @@ - - - - - - - - - + {{ sortable_header('ID', 'id', sort, order) }} + {{ sortable_header('Lizenzschlüssel', 'license_key', sort, order) }} + {{ sortable_header('Kunde', 'customer', sort, order) }} + {{ sortable_header('E-Mail', 'email', sort, order) }} + {{ sortable_header('Typ', 'type', sort, order) }} + {{ sortable_header('Gültig von', 'valid_from', sort, order) }} + {{ sortable_header('Gültig bis', 'valid_until', sort, order) }} + {{ sortable_header('Status', 'status', sort, order) }} + {{ sortable_header('Aktiv', 'active', sort, order) }} @@ -169,31 +190,31 @@

diff --git a/v2_adminpanel/templates/sessions.html b/v2_adminpanel/templates/sessions.html index 14990bb..d5c5ed7 100644 --- a/v2_adminpanel/templates/sessions.html +++ b/v2_adminpanel/templates/sessions.html @@ -2,6 +2,48 @@ {% block title %}Session-Tracking{% endblock %} +{% macro active_sortable_header(label, field, current_sort, current_order) %} +

+{% endmacro %} + +{% macro ended_sortable_header(label, field, current_sort, current_order) %} + +{% endmacro %} + {% block extra_css %}
KundeLizenzStatusKundeLizenzStatus
+ {% if current_sort == field %} + + {% else %} + + {% endif %} + {{ label }} + + {% if current_sort == field %} + {% if current_order == 'asc' %}↑{% else %}↓{% endif %} + {% else %} + ↕ + {% endif %} + + + IDLizenzschlüsselKundeE-MailTypGültig vonGültig bisStatusAktivAktionen
+ {% if current_sort == field %} + + {% else %} + + {% endif %} + {{ label }} + + {% if current_sort == field %} + {% if current_order == 'asc' %}↑{% else %}↓{% endif %} + {% else %} + ↕ + {% endif %} + + + + {% if current_sort == field %} + + {% else %} + + {% endif %} + {{ label }} + + {% if current_sort == field %} + {% if current_order == 'asc' %}↑{% else %}↓{% endif %} + {% else %} + ↕ + {% endif %} + + +