DB Sortsystem
Dieser Commit ist enthalten in:
59
JOURNAL.md
59
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
|
||||
- ✅ 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
|
||||
1
backups/backup_v2docker_20250608_215318_encrypted.sql.gz.enc
Normale Datei
1
backups/backup_v2docker_20250608_215318_encrypted.sql.gz.enc
Normale Datei
Dateidiff unterdrückt, weil mindestens eine Zeile zu lang ist
@@ -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/<int:license_id>", 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/<int:customer_id>", 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/<int:session_id>", 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")
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
{% block title %}Log{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('audit_log', sort=field, order='asc', user=filter_user, action=filter_action, entity=filter_entity, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.audit-details {
|
||||
@@ -95,12 +116,12 @@
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</th>
|
||||
<th>Entität</th>
|
||||
{{ sortable_header('Zeitstempel', 'timestamp', sort, order) }}
|
||||
{{ sortable_header('Benutzer', 'username', sort, order) }}
|
||||
{{ sortable_header('Aktion', 'action', sort, order) }}
|
||||
{{ sortable_header('Entität', 'entity', sort, order) }}
|
||||
<th>Details</th>
|
||||
<th>IP-Adresse</th>
|
||||
{{ sortable_header('IP-Adresse', 'ip', sort, order) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -194,31 +215,31 @@
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=1, user=filter_user, action=filter_action, entity=filter_entity) }}">Erste</a>
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page-1, user=filter_user, action=filter_action, entity=filter_entity) }}">←</a>
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page-1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=p, user=filter_user, action=filter_action, entity=filter_entity) }}">{{ p }}</a>
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=p, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page+1, user=filter_user, action=filter_action, entity=filter_entity) }}">→</a>
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=page+1, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=total_pages, user=filter_user, action=filter_action, entity=filter_entity) }}">Letzte</a>
|
||||
<a class="page-link" href="{{ url_for('audit_log', page=total_pages, user=filter_user, action=filter_action, entity=filter_entity, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
|
||||
@@ -58,15 +58,15 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Dateiname</th>
|
||||
<th>Größe</th>
|
||||
<th>Typ</th>
|
||||
<th>Status</th>
|
||||
<th>Erstellt von</th>
|
||||
<th class="sortable" data-type="date">Zeitstempel</th>
|
||||
<th class="sortable">Dateiname</th>
|
||||
<th class="sortable" data-type="numeric">Größe</th>
|
||||
<th class="sortable">Typ</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th class="sortable">Erstellt von</th>
|
||||
<th>Details</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
|
||||
@@ -162,6 +162,61 @@
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
|
||||
/* Sortable Table Styles */
|
||||
.sortable-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable::after {
|
||||
content: '↕';
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.sortable-table th.sortable.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Server-side sortable styles */
|
||||
.server-sortable {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.server-sortable:hover {
|
||||
color: var(--bs-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sort-indicator {
|
||||
margin-left: 5px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.sort-indicator.active {
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
@@ -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);
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
<div class="card-body">
|
||||
{% if blocked_ips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Versuche</th>
|
||||
<th>Erster Versuch</th>
|
||||
<th>Letzter Versuch</th>
|
||||
<th>Gesperrt bis</th>
|
||||
<th>Letzter User</th>
|
||||
<th>Letzte Meldung</th>
|
||||
<th>Status</th>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable" data-type="date">Erster Versuch</th>
|
||||
<th class="sortable" data-type="date">Letzter Versuch</th>
|
||||
<th class="sortable" data-type="date">Gesperrt bis</th>
|
||||
<th class="sortable">Letzter User</th>
|
||||
<th class="sortable">Letzte Meldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
{% block title %}Kundenverwaltung{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers', sort=field, order='asc', search=search, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
@@ -51,11 +72,11 @@
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Lizenzen (Aktiv/Gesamt)</th>
|
||||
{{ 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) }}
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -105,31 +126,31 @@
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=1, search=search) }}">Erste</a>
|
||||
<a class="page-link" href="{{ url_for('customers', page=1, search=search, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=page-1, search=search) }}">←</a>
|
||||
<a class="page-link" href="{{ url_for('customers', page=page-1, search=search, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=p, search=search) }}">{{ p }}</a>
|
||||
<a class="page-link" href="{{ url_for('customers', page=p, search=search, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=page+1, search=search) }}">→</a>
|
||||
<a class="page-link" href="{{ url_for('customers', page=page+1, search=search, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers', page=total_pages, search=search) }}">Letzte</a>
|
||||
<a class="page-link" href="{{ url_for('customers', page=total_pages, search=search, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
|
||||
@@ -221,14 +221,14 @@
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<table class="table table-sm mb-0 sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeit</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Versuche</th>
|
||||
<th>Fehlermeldung</th>
|
||||
<th>Status</th>
|
||||
<th class="sortable" data-type="date">Zeit</th>
|
||||
<th class="sortable">IP-Adresse</th>
|
||||
<th class="sortable" data-type="numeric">Versuche</th>
|
||||
<th class="sortable">Fehlermeldung</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -266,12 +266,12 @@
|
||||
<div class="card-body">
|
||||
{% if stats.expiring_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>Tage</th>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable" data-type="numeric">Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -301,12 +301,12 @@
|
||||
<div class="card-body">
|
||||
{% if stats.recent_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>Status</th>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
{% block title %}Lizenzübersicht{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('licenses', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('licenses', sort=field, order='asc', search=search, type=filter_type, status=filter_status, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -82,15 +103,15 @@
|
||||
<th class="checkbox-cell">
|
||||
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Status</th>
|
||||
<th>Aktiv</th>
|
||||
{{ 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) }}
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -169,31 +190,31 @@
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=1, search=search, type=filter_type, status=filter_status) }}">Erste</a>
|
||||
<a class="page-link" href="{{ url_for('licenses', page=1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page-1, search=search, type=filter_type, status=filter_status) }}">←</a>
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page-1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">←</a>
|
||||
</li>
|
||||
|
||||
<!-- Seitenzahlen -->
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p >= page - 2 and p <= page + 2 %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=p, search=search, type=filter_type, status=filter_status) }}">{{ p }}</a>
|
||||
<a class="page-link" href="{{ url_for('licenses', page=p, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page+1, search=search, type=filter_type, status=filter_status) }}">→</a>
|
||||
<a class="page-link" href="{{ url_for('licenses', page=page+1, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('licenses', page=total_pages, search=search, type=filter_type, status=filter_status) }}">Letzte</a>
|
||||
<a class="page-link" href="{{ url_for('licenses', page=total_pages, search=search, type=filter_type, status=filter_status, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
|
||||
@@ -2,6 +2,48 @@
|
||||
|
||||
{% block title %}Session-Tracking{% endblock %}
|
||||
|
||||
{% macro active_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions', active_sort=field, active_order='desc' if current_order == 'asc' else 'asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions', active_sort=field, active_order='asc', ended_sort=ended_sort, ended_order=ended_order) }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro ended_sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='desc' if current_order == 'asc' else 'asc') }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('sessions', active_sort=active_sort, active_order=active_order, ended_sort=field, ended_order='asc') }}"
|
||||
class="server-sortable">
|
||||
{% endif %}
|
||||
{{ label }}
|
||||
<span class="sort-indicator{% if current_sort == field %} active{% endif %}">
|
||||
{% if current_sort == field %}
|
||||
{% if current_order == 'asc' %}↑{% else %}↓{% endif %}
|
||||
{% else %}
|
||||
↕
|
||||
{% endif %}
|
||||
</span>
|
||||
</a>
|
||||
</th>
|
||||
{% endmacro %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.session-active { background-color: #d4edda; }
|
||||
@@ -30,12 +72,12 @@
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Letzter Heartbeat</th>
|
||||
<th>Inaktiv seit</th>
|
||||
{{ active_sortable_header('Kunde', 'customer', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Lizenz', 'license', active_sort, active_order) }}
|
||||
{{ active_sortable_header('IP-Adresse', 'ip', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Gestartet', 'started', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Letzter Heartbeat', 'last_heartbeat', active_sort, active_order) }}
|
||||
{{ active_sortable_header('Inaktiv seit', 'inactive', active_sort, active_order) }}
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -89,12 +131,12 @@
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenz</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Beendet</th>
|
||||
<th>Dauer</th>
|
||||
{{ ended_sortable_header('Kunde', 'customer', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Lizenz', 'license', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('IP-Adresse', 'ip', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Gestartet', 'started', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Beendet', 'ended_at', ended_sort, ended_order) }}
|
||||
{{ ended_sortable_header('Dauer', 'duration', ended_sort, ended_order) }}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren