🎉 Improved form testing infrastructure with new files: 'simple_form_tester.py', 'ui_components.html' templates, and updated test suite in 'form_test_automator.py'. 📚

This commit is contained in:
2025-06-18 08:49:23 +02:00
parent 9a03e52209
commit f1e3a2cfea
5 changed files with 919 additions and 490 deletions

View File

@ -30,6 +30,10 @@ try:
except ImportError:
PLAYWRIGHT_AVAILABLE = False
print("⚠️ Playwright nicht verfügbar. Installiere mit: pip install playwright")
# Fallback-Klassen für Type Hints
class Page: pass
class Browser: pass
class BrowserContext: pass
# Test-Daten-Generierung
try:
@ -38,6 +42,20 @@ try:
except ImportError:
FAKER_AVAILABLE = False
print("⚠️ Faker nicht verfügbar. Installiere mit: pip install faker")
# Fallback-Klasse
class Faker:
def __init__(self, locale='de_DE'): pass
def email(self): return "test@example.com"
def name(self): return "Test User"
def first_name(self): return "Test"
def last_name(self): return "User"
def company(self): return "Test Company"
def city(self): return "Test City"
def street_address(self): return "Test Street 123"
def phone_number(self): return "+49 123 4567890"
def url(self): return "https://example.com"
def date(self): return "2024-06-18"
def text(self, max_nb_chars=200): return "Test text"
# HTML-Parsing
try:
@ -46,6 +64,9 @@ try:
except ImportError:
BS4_AVAILABLE = False
print("⚠️ BeautifulSoup nicht verfügbar. Installiere mit: pip install beautifulsoup4")
# Fallback-Klasse
class BeautifulSoup:
def __init__(self, *args, **kwargs): pass
# Rich Console für schöne Ausgabe
try:
@ -59,6 +80,13 @@ try:
except ImportError:
RICH_AVAILABLE = False
console = None
# Fallback-Klassen
class Console:
def print(self, *args, **kwargs): print(*args)
class Table:
def __init__(self, *args, **kwargs): pass
def add_column(self, *args, **kwargs): pass
def add_row(self, *args, **kwargs): pass
class TestStatus(Enum):

View File

@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""
Vereinfachter HTML-Formular Tester für MYP System
===============================================
Ein schlanker Formular-Tester ohne externe Dependencies,
der grundlegende Formular-Validierung mit Standard-Python-Libraries testet.
Autor: Till Tomczak
Mercedes-Benz Projektarbeit MYP
"""
import asyncio
import json
import time
import urllib.request
import urllib.parse
import urllib.error
from urllib.parse import urljoin, urlparse
from html.parser import HTMLParser
from dataclasses import dataclass
from typing import Dict, List, Optional
import sys
import re
@dataclass
class FormField:
"""Repräsentiert ein Formular-Feld"""
name: str
field_type: str
required: bool = False
pattern: str = ""
placeholder: str = ""
value: str = ""
@dataclass
class Form:
"""Repräsentiert ein HTML-Formular"""
action: str
method: str
fields: List[FormField]
name: str = ""
class FormParser(HTMLParser):
"""Parst HTML und extrahiert Formular-Informationen"""
def __init__(self):
super().__init__()
self.forms = []
self.current_form = None
self.in_form = False
def handle_starttag(self, tag, attrs):
attrs_dict = dict(attrs)
if tag == 'form':
self.in_form = True
self.current_form = Form(
action=attrs_dict.get('action', ''),
method=attrs_dict.get('method', 'GET').upper(),
fields=[],
name=attrs_dict.get('name', attrs_dict.get('id', f'form_{len(self.forms)}'))
)
elif self.in_form and tag in ['input', 'textarea', 'select']:
field = FormField(
name=attrs_dict.get('name', attrs_dict.get('id', f'field_{len(self.current_form.fields)}')),
field_type=attrs_dict.get('type', tag),
required='required' in attrs_dict,
pattern=attrs_dict.get('pattern', ''),
placeholder=attrs_dict.get('placeholder', ''),
value=attrs_dict.get('value', '')
)
self.current_form.fields.append(field)
def handle_endtag(self, tag):
if tag == 'form' and self.in_form:
self.in_form = False
if self.current_form:
self.forms.append(self.current_form)
self.current_form = None
class SimpleFormTester:
"""
Vereinfachter Formular-Tester ohne Browser-Dependencies.
Führt HTTP-basierte Tests durch.
"""
def __init__(self, base_url: str):
self.base_url = base_url.rstrip('/')
self.session_cookies = {}
def fetch_page(self, path: str) -> str:
"""Lädt eine Seite und gibt den HTML-Inhalt zurück"""
url = urljoin(self.base_url, path)
try:
request = urllib.request.Request(url)
# Cookies hinzufügen
if self.session_cookies:
cookie_header = '; '.join([f'{k}={v}' for k, v in self.session_cookies.items()])
request.add_header('Cookie', cookie_header)
with urllib.request.urlopen(request) as response:
html = response.read().decode('utf-8')
# Cookies aus Response extrahieren
cookie_header = response.getheader('Set-Cookie')
if cookie_header:
self._parse_cookies(cookie_header)
return html
except urllib.error.URLError as e:
print(f"❌ Fehler beim Laden von {url}: {e}")
return ""
def _parse_cookies(self, cookie_header: str):
"""Parst Set-Cookie Header"""
for cookie in cookie_header.split(','):
if '=' in cookie:
name, value = cookie.split('=', 1)
self.session_cookies[name.strip()] = value.split(';')[0].strip()
def find_forms(self, html: str) -> List[Form]:
"""Findet alle Formulare in HTML"""
parser = FormParser()
parser.feed(html)
return parser.forms
def generate_test_data(self, field: FormField) -> str:
"""Generiert Test-Daten für ein Feld"""
if field.field_type == 'email':
return "test@mercedes-benz.com"
elif field.field_type == 'password':
return "TestPassword123!"
elif field.field_type == 'tel':
return "+49 711 17-0"
elif field.field_type == 'url':
return "https://www.mercedes-benz.com"
elif field.field_type == 'number':
return "42"
elif field.field_type == 'date':
return "2024-06-18"
elif field.field_type == 'checkbox':
return "on"
elif field.field_type == 'radio':
return field.value or "option1"
elif 'name' in field.name.lower():
return "Test Benutzer"
elif 'username' in field.name.lower():
return "admin"
elif 'ip' in field.name.lower():
return "192.168.1.100"
elif 'port' in field.name.lower():
return "80"
else:
return f"Test_{field.name}"
def submit_form(self, form: Form, test_data: Dict[str, str], base_url: str) -> Dict:
"""Sendet Formular-Daten und gibt Response zurück"""
# Action URL bestimmen
if form.action.startswith('http'):
url = form.action
else:
url = urljoin(base_url, form.action)
# Daten vorbereiten
form_data = {}
for field in form.fields:
if field.name in test_data:
form_data[field.name] = test_data[field.name]
elif field.field_type not in ['submit', 'button']:
form_data[field.name] = self.generate_test_data(field)
try:
if form.method == 'GET':
# GET-Request mit Query-Parametern
query_string = urllib.parse.urlencode(form_data)
full_url = f"{url}?{query_string}"
request = urllib.request.Request(full_url)
else:
# POST-Request
data = urllib.parse.urlencode(form_data).encode('utf-8')
request = urllib.request.Request(url, data=data)
request.add_header('Content-Type', 'application/x-www-form-urlencoded')
# Cookies hinzufügen
if self.session_cookies:
cookie_header = '; '.join([f'{k}={v}' for k, v in self.session_cookies.items()])
request.add_header('Cookie', cookie_header)
with urllib.request.urlopen(request) as response:
response_html = response.read().decode('utf-8')
status_code = response.getcode()
return {
'success': 200 <= status_code < 400,
'status_code': status_code,
'html': response_html,
'form_data': form_data
}
except urllib.error.HTTPError as e:
return {
'success': False,
'status_code': e.code,
'error': str(e),
'form_data': form_data
}
except Exception as e:
return {
'success': False,
'error': str(e),
'form_data': form_data
}
def validate_field(self, field: FormField, value: str) -> Dict:
"""Validiert ein Feld mit gegebenem Wert"""
errors = []
# Required-Validierung
if field.required and not value.strip():
errors.append(f"Feld '{field.name}' ist erforderlich")
# Pattern-Validierung
if field.pattern and value:
try:
if not re.match(field.pattern, value):
errors.append(f"Feld '{field.name}' entspricht nicht dem Pattern '{field.pattern}'")
except re.error:
errors.append(f"Ungültiges Pattern für Feld '{field.name}': {field.pattern}")
# Type-spezifische Validierung
if value and field.field_type == 'email':
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(email_pattern, value):
errors.append(f"Ungültige Email-Adresse: {value}")
elif value and field.field_type == 'url':
try:
parsed = urlparse(value)
if not parsed.scheme or not parsed.netloc:
errors.append(f"Ungültige URL: {value}")
except:
errors.append(f"Ungültige URL: {value}")
elif value and field.field_type == 'number':
try:
float(value)
except ValueError:
errors.append(f"'{value}' ist keine gültige Zahl")
return {
'valid': len(errors) == 0,
'errors': errors,
'field': field.name,
'value': value
}
def test_form_validation(self, form: Form) -> List[Dict]:
"""Testet Formular-Validierung mit verschiedenen Daten"""
validation_results = []
# 1. Test mit leeren Required-Feldern
for field in form.fields:
if field.required:
result = self.validate_field(field, "")
validation_results.append({
'test_type': 'required_field_empty',
'field': field.name,
'expected_invalid': True,
'actual_valid': result['valid'],
'passed': not result['valid'], # Sollte ungültig sein
'errors': result['errors']
})
# 2. Test mit ungültigen Email-Adressen
for field in form.fields:
if field.field_type == 'email':
invalid_emails = ['invalid-email', 'test@', '@domain.com', 'test..test@domain.com']
for invalid_email in invalid_emails:
result = self.validate_field(field, invalid_email)
validation_results.append({
'test_type': 'invalid_email',
'field': field.name,
'value': invalid_email,
'expected_invalid': True,
'actual_valid': result['valid'],
'passed': not result['valid'],
'errors': result['errors']
})
# 3. Test mit gültigen Daten
valid_test_data = {}
for field in form.fields:
if field.field_type not in ['submit', 'button']:
valid_test_data[field.name] = self.generate_test_data(field)
for field in form.fields:
if field.name in valid_test_data:
result = self.validate_field(field, valid_test_data[field.name])
validation_results.append({
'test_type': 'valid_data',
'field': field.name,
'value': valid_test_data[field.name],
'expected_invalid': False,
'actual_valid': result['valid'],
'passed': result['valid'],
'errors': result['errors']
})
return validation_results
def test_form_submission(self, form: Form, base_url: str) -> Dict:
"""Testet Formular-Submission"""
print(f"🧪 Teste Formular: {form.name} ({form.method}{form.action})")
# Test-Daten generieren
test_data = {}
for field in form.fields:
if field.field_type not in ['submit', 'button']:
test_data[field.name] = self.generate_test_data(field)
print(f" 📝 Test-Daten: {list(test_data.keys())}")
# Formular senden
start_time = time.time()
result = self.submit_form(form, test_data, base_url)
execution_time = time.time() - start_time
# Validierungs-Tests
validation_results = self.test_form_validation(form)
return {
'form_name': form.name,
'method': form.method,
'action': form.action,
'fields_count': len(form.fields),
'test_data': test_data,
'submission_result': result,
'validation_results': validation_results,
'execution_time': execution_time,
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S')
}
def test_page_forms(self, path: str) -> List[Dict]:
"""Testet alle Formulare auf einer Seite"""
print(f"🔍 Scanne Formulare auf: {self.base_url}{path}")
# Seite laden
html = self.fetch_page(path)
if not html:
return []
# Formulare finden
forms = self.find_forms(html)
print(f"{len(forms)} Formulare gefunden")
# Jedes Formular testen
results = []
for form in forms:
result = self.test_form_submission(form, self.base_url + path)
results.append(result)
return results
def generate_report(self, test_results: List[Dict], output_file: str = "simple_form_test_report.html"):
"""Generiert einen einfachen HTML-Report"""
total_forms = len(test_results)
successful_submissions = len([r for r in test_results if r['submission_result'].get('success', False)])
# Validierungs-Statistiken
total_validations = sum(len(r['validation_results']) for r in test_results)
passed_validations = sum(len([v for v in r['validation_results'] if v['passed']]) for r in test_results)
html_report = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MYP Formular Test Report</title>
<style>
body {{
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}}
.container {{
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
text-align: center;
margin: -20px -20px 20px -20px;
border-radius: 8px 8px 0 0;
}}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}}
.stat-card {{
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
text-align: center;
border: 1px solid #dee2e6;
}}
.stat-number {{
font-size: 2em;
font-weight: bold;
color: #333;
}}
.form-result {{
border: 1px solid #dee2e6;
border-radius: 6px;
margin-bottom: 20px;
overflow: hidden;
}}
.form-header {{
background: #e9ecef;
padding: 15px;
border-bottom: 1px solid #dee2e6;
}}
.form-content {{
padding: 15px;
}}
.success {{ color: #28a745; }}
.failure {{ color: #dc3545; }}
.warning {{ color: #ffc107; }}
.test-data {{
background: #f8f9fa;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
font-family: monospace;
font-size: 0.9em;
}}
.validation-result {{
margin: 5px 0;
padding: 5px 10px;
border-radius: 4px;
border-left: 4px solid #dee2e6;
}}
.validation-passed {{
background: #d4edda;
border-left-color: #28a745;
}}
.validation-failed {{
background: #f8d7da;
border-left-color: #dc3545;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 MYP Formular Test Report</h1>
<p>Generiert am {time.strftime('%d.%m.%Y um %H:%M:%S')}</p>
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-number">{total_forms}</div>
<div>Formulare getestet</div>
</div>
<div class="stat-card">
<div class="stat-number {'success' if successful_submissions == total_forms else 'warning' if successful_submissions > 0 else 'failure'}">{successful_submissions}</div>
<div>Erfolgreiche Submissions</div>
</div>
<div class="stat-card">
<div class="stat-number">{total_validations}</div>
<div>Validierungs-Tests</div>
</div>
<div class="stat-card">
<div class="stat-number {'success' if passed_validations == total_validations else 'warning' if passed_validations > 0 else 'failure'}">{passed_validations}</div>
<div>Validierungen bestanden</div>
</div>
</div>
<h2>📋 Detaillierte Ergebnisse</h2>
"""
# Formular-spezifische Ergebnisse
for result in test_results:
submission = result['submission_result']
success_class = 'success' if submission.get('success', False) else 'failure'
html_report += f"""
<div class="form-result">
<div class="form-header">
<h3>📝 {result['form_name']}</h3>
<p><strong>Method:</strong> {result['method']} | <strong>Action:</strong> {result['action']} | <strong>Felder:</strong> {result['fields_count']}</p>
</div>
<div class="form-content">
<h4>Submission-Ergebnis: <span class="{success_class}">{'✅ Erfolgreich' if submission.get('success', False) else '❌ Fehlgeschlagen'}</span></h4>
{f'<p><strong>Status Code:</strong> {submission.get("status_code", "N/A")}</p>' if 'status_code' in submission else ''}
{f'<p><strong>Fehler:</strong> {submission.get("error", "N/A")}</p>' if 'error' in submission else ''}
<h4>Test-Daten:</h4>
<div class="test-data">{json.dumps(result['test_data'], indent=2, ensure_ascii=False)}</div>
<h4>Validierungs-Ergebnisse:</h4>
"""
# Validierungs-Ergebnisse
for validation in result['validation_results']:
validation_class = 'validation-passed' if validation['passed'] else 'validation-failed'
status_icon = '' if validation['passed'] else ''
html_report += f"""
<div class="validation-result {validation_class}">
<strong>{status_icon} {validation['test_type']}</strong> - Feld: {validation['field']}
{f"<br>Wert: <code>{validation.get('value', 'N/A')}</code>" if 'value' in validation else ''}
{f"<br>Fehler: {', '.join(validation['errors'])}" if validation['errors'] else ''}
</div>
"""
html_report += f"""
<p><small>⏱️ Ausführungszeit: {result['execution_time']:.2f}s</small></p>
</div>
</div>
"""
html_report += """
</div>
</body>
</html>"""
try:
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html_report)
print(f"📊 Report erstellt: {output_file}")
return output_file
except Exception as e:
print(f"❌ Fehler beim Report-Erstellen: {e}")
return ""
def main():
"""Haupt-CLI-Interface"""
if len(sys.argv) < 2:
print("""
🧪 Vereinfachter MYP Formular Tester
Verwendung:
python simple_form_tester.py <base_url> [path]
Beispiele:
python simple_form_tester.py http://localhost:5000
python simple_form_tester.py http://localhost:5000 /login
python simple_form_tester.py http://localhost:5000 /admin/add_printer
Testet alle Formulare auf der angegebenen Seite und generiert einen HTML-Report.
""")
return
base_url = sys.argv[1]
path = sys.argv[2] if len(sys.argv) > 2 else '/'
print(f"""
{'='*60}
🧪 MYP FORMULAR TESTER (Vereinfacht)
{'='*60}
🎯 Basis-URL: {base_url}
📍 Pfad: {path}
🕒 Gestartet: {time.strftime('%d.%m.%Y um %H:%M:%S')}
{'='*60}
""")
# Tester initialisieren
tester = SimpleFormTester(base_url)
# Tests ausführen
try:
results = tester.test_page_forms(path)
if results:
# Report generieren
report_file = tester.generate_report(results)
# Zusammenfassung
total_forms = len(results)
successful = len([r for r in results if r['submission_result'].get('success', False)])
print(f"""
{'='*60}
🎉 TESTS ABGESCHLOSSEN
{'='*60}
📊 ZUSAMMENFASSUNG:
• Formulare getestet: {total_forms}
• Erfolgreiche Submissions: {successful}
• Erfolgsrate: {successful/total_forms*100:.1f}%
📋 Report: {report_file}
{'='*60}
""")
else:
print("⚠️ Keine Formulare gefunden oder Fehler beim Laden der Seite")
except Exception as e:
print(f"❌ Fehler beim Testen: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

View File

@ -336,11 +336,13 @@
System-Info
</a>
<div class="border-t border-white/10 my-2"></div>
<button onclick="handleLogout()"
class="w-full flex items-center px-3 py-2 rounded-lg hover:bg-red-500/20 text-red-600 dark:text-red-400">
<i class="fas fa-sign-out-alt w-4 mr-3"></i>
Abmelden
</button>
<form method="POST" action="{{ url_for('auth.logout') }}" class="w-full">
{{ csrf_token() }}
<button type="submit" class="w-full flex items-center px-3 py-2 rounded-lg hover:bg-red-500/20 text-red-600 dark:text-red-400">
<i class="fas fa-sign-out-alt w-4 mr-3"></i>
Abmelden
</button>
</form>
</div>
</div>
</div>
@ -438,16 +440,11 @@
<div class="flex-1">
<p class="text-sm font-medium">{{ message }}</p>
</div>
<button onclick="this.parentElement.parentElement.remove()" class="ml-3 hover:opacity-70">
<button type="button" class="ml-3 hover:opacity-70 close-flash-btn">
<i class="fas fa-times text-sm"></i>
</button>
</div>
</div>
<script>
setTimeout(() => {
document.getElementById('flash-{{ loop.index }}')?.remove();
}, 5000);
</script>
{% endfor %}
</div>
{% endif %}
@ -727,12 +724,11 @@
const payload = JSON.parse(notification.payload || '{}');
return `
<div class="mt-3 flex space-x-2">
<button onclick="window.notificationManager.viewGuestRequest(${payload.request_id})"
class="px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600">
<a href="/admin/guest-requests?highlight=${payload.request_id}"
class="inline-block px-3 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600">
Anzeigen
</button>
<button onclick="window.notificationManager.markAsRead(${notification.id})"
class="px-3 py-1 bg-gray-500 text-white text-xs rounded hover:bg-gray-600">
</a>
<button data-notification-id="${notification.id}" class="mark-read-btn px-3 py-1 bg-gray-500 text-white text-xs rounded hover:bg-gray-600">
Als gelesen markieren
</button>
</div>
@ -748,10 +744,7 @@
// Event Listeners werden über onclick direkt gesetzt
}
async viewGuestRequest(requestId) {
// Weiterleitung zur Admin-Gastanfragen-Seite
window.location.href = `/admin/guest-requests?highlight=${requestId}`;
}
// Replaced by direct links in templates
async markAsRead(notificationId) {
try {
@ -862,26 +855,19 @@
e.stopPropagation();
});
// Logout Handler
function handleLogout() {
if (confirm('Möchten Sie sich wirklich abmelden?')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '{{ url_for("auth.logout") }}';
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = csrfToken.getAttribute('content');
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
}
// Flash-Message close buttons
document.querySelectorAll('.close-flash-btn').forEach(btn => {
btn.addEventListener('click', function() {
this.parentElement.parentElement.remove();
});
});
// Auto-hide flash messages after 5 seconds
document.querySelectorAll('[id^="flash-"]').forEach((flash, index) => {
setTimeout(() => {
flash?.remove();
}, 5000 + (index * 500)); // Staggered removal
});
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(anchor => {

View File

@ -269,13 +269,13 @@
</div>
</div>
<div class="flex flex-wrap gap-3">
<button id="refreshDashboard"
class="btn-secondary flex items-center gap-2">
<a href="{{ url_for('dashboard') }}"
class="btn-secondary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Aktualisieren</span>
</button>
</a>
<a href="{{ url_for('jobs_page') }}"
class="btn-primary flex items-center gap-2">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -401,7 +401,7 @@
<div class="text-xs text-right mt-1 text-slate-500 dark:text-slate-400">{{ job.progress }}%</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<a href="{{ url_for('api_get_job', job_id=job.id) }}" class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</a>
<a href="{{ url_for('jobs_page') }}#job-{{ job.id }}" class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</a>
</td>
</tr>
{% endfor %}
@ -499,454 +499,51 @@
{% block extra_js %}
<script>
class DashboardManager {
constructor() {
this.updateInterval = 30000; // 30 Sekunden
this.autoUpdateTimer = null;
this.isUpdating = false;
this.wsConnection = null;
this.init();
}
init() {
this.setupEventListeners();
this.setupAutoUpdate();
this.setupWebSocket();
this.animateCounters();
console.log('🚀 Dashboard Manager initialisiert');
}
setupEventListeners() {
// Refresh Button
const refreshBtn = document.getElementById('refreshDashboard');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
this.refreshDashboard();
});
}
// Job-Zeilen klickbar machen für Details
this.setupJobRowClicks();
// Drucker-Karten klickbar machen
this.setupPrinterClicks();
// Dark Mode Updates
window.addEventListener('darkModeChanged', (e) => {
this.updateThemeElements(e.detail.isDark);
});
// Visibility Change Detection für intelligente Updates
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.pauseAutoUpdate();
} else {
this.resumeAutoUpdate();
this.refreshDashboard(); // Einmalige Aktualisierung bei Rückkehr
}
});
}
setupJobRowClicks() {
const jobRows = document.querySelectorAll('tbody tr[data-job-id]');
jobRows.forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
const jobId = row.dataset.jobId;
if (jobId) {
window.location.href = `/jobs/${jobId}`;
}
});
// Hover-Effekt verstärken
row.addEventListener('mouseenter', () => {
row.style.transform = 'scale(1.01)';
row.style.transition = 'transform 0.2s ease';
});
row.addEventListener('mouseleave', () => {
row.style.transform = 'scale(1)';
});
});
}
setupPrinterClicks() {
const printerCards = document.querySelectorAll('[data-printer-id]');
printerCards.forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
const printerId = card.dataset.printerId;
if (printerId) {
window.location.href = `/printers/${printerId}`;
}
});
});
}
setupAutoUpdate() {
this.autoUpdateTimer = setInterval(() => {
if (!document.hidden && !this.isUpdating) {
this.updateDashboardData();
}
}, this.updateInterval);
}
pauseAutoUpdate() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
this.autoUpdateTimer = null;
}
}
resumeAutoUpdate() {
if (!this.autoUpdateTimer) {
this.setupAutoUpdate();
}
}
setupWebSocket() {
// WebSocket für Real-time Updates
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`;
this.wsConnection = new WebSocket(wsUrl);
this.wsConnection.onopen = () => {
console.log('📡 WebSocket Verbindung zu Dashboard hergestellt');
};
this.wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handleWebSocketUpdate(data);
} catch (error) {
console.error('Fehler beim Verarbeiten der WebSocket-Nachricht:', error);
}
};
this.wsConnection.onclose = () => {
console.log('📡 WebSocket Verbindung geschlossen - versuche Wiederverbindung in 5s');
setTimeout(() => {
this.setupWebSocket();
}, 5000);
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket Fehler:', error);
};
} catch (error) {
console.log('WebSocket nicht verfügbar, verwende Polling-Updates');
}
}
handleWebSocketUpdate(data) {
console.log('📡 Erhaltenes WebSocket-Update:', data);
switch (data.type) {
case 'job_status_update':
this.updateJobStatus(data.job_id, data.status, data.progress);
break;
case 'printer_status_update':
this.updatePrinterStatus(data.printer_id, data.status);
break;
case 'new_activity':
this.addNewActivity(data.activity);
break;
case 'stats_update':
this.updateStats(data.stats);
break;
default:
console.log('Unbekannter WebSocket-Update-Typ:', data.type);
}
}
async refreshDashboard() {
if (this.isUpdating) return;
this.isUpdating = true;
const refreshBtn = document.getElementById('refreshDashboard');
try {
// Button-Status aktualisieren
if (refreshBtn) {
refreshBtn.disabled = true;
refreshBtn.innerHTML = `
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>Aktualisiert...</span>
`;
}
// Vollständige Seitenaktualisierung
window.location.reload();
} catch (error) {
console.error('Fehler beim Aktualisieren:', error);
this.showToast('Fehler beim Aktualisieren des Dashboards', 'error');
} finally {
this.isUpdating = false;
// Button zurücksetzen
if (refreshBtn) {
refreshBtn.disabled = false;
refreshBtn.innerHTML = `
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>Aktualisieren</span>
`;
}
}
}
async updateDashboardData() {
if (this.isUpdating) return;
this.isUpdating = true;
try {
// Stille Hintergrund-Updates ohne vollständige Neuladen
const responses = await Promise.all([
fetch('/api/dashboard/stats'),
fetch('/api/dashboard/active-jobs'),
fetch('/api/dashboard/printers'),
fetch('/api/dashboard/activities')
]);
const [statsData, jobsData, printersData, activitiesData] = await Promise.all(
responses.map(r => r.json())
);
// UI-Updates
this.updateStats(statsData);
this.updateActiveJobs(jobsData);
this.updatePrinters(printersData);
this.updateActivities(activitiesData);
console.log('🔄 Dashboard-Daten erfolgreich aktualisiert');
} catch (error) {
console.error('Fehler beim Laden der Dashboard-Daten:', error);
// Fehlschlag stumm - verwende WebSocket oder warte auf nächste Aktualisierung
} finally {
this.isUpdating = false;
}
}
updateJobStatus(jobId, status, progress) {
const jobRow = document.querySelector(`tr[data-job-id="${jobId}"]`);
if (jobRow) {
// Status-Indikator aktualisieren
const statusIndicator = jobRow.querySelector('.mb-status-indicator');
const statusText = jobRow.querySelector('.text-sm.font-medium');
const progressBar = jobRow.querySelector('.mb-progress-bar');
const progressText = jobRow.querySelector('.text-xs.text-right');
if (statusIndicator) {
statusIndicator.className = `mb-status-indicator ${this.getStatusClass(status)}`;
}
if (statusText) {
statusText.textContent = this.getStatusText(status);
}
if (progressBar && progress !== undefined) {
progressBar.style.width = `${progress}%`;
}
if (progressText && progress !== undefined) {
progressText.textContent = `${progress}%`;
}
// Animation für Updates
jobRow.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
setTimeout(() => {
jobRow.style.backgroundColor = '';
}, 1000);
}
}
updatePrinterStatus(printerId, status) {
const printerCard = document.querySelector(`[data-printer-id="${printerId}"]`);
if (printerCard) {
const statusIndicator = printerCard.querySelector('.mb-status-indicator');
const statusText = printerCard.querySelector('.text-sm.font-medium:last-child');
if (statusIndicator) {
statusIndicator.className = `mb-status-indicator ${this.getStatusClass(status)}`;
}
if (statusText) {
statusText.textContent = this.getStatusText(status);
}
// Animation für Updates
printerCard.style.transform = 'scale(1.02)';
setTimeout(() => {
printerCard.style.transform = 'scale(1)';
}, 300);
}
}
addNewActivity(activity) {
const activitiesContainer = document.querySelector('.space-y-3');
if (activitiesContainer) {
const newActivity = document.createElement('div');
newActivity.className = 'mb-activity-item pl-4 py-3 opacity-0';
newActivity.innerHTML = `
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-sm text-slate-700 dark:text-slate-300">${activity.description}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">${activity.time}</p>
</div>
</div>
`;
// Am Anfang einfügen
activitiesContainer.insertBefore(newActivity, activitiesContainer.firstChild);
// Animation
setTimeout(() => {
newActivity.style.opacity = '1';
newActivity.style.transition = 'opacity 0.5s ease';
}, 100);
// Alte Aktivitäten entfernen (max 10)
const activities = activitiesContainer.querySelectorAll('.mb-activity-item');
if (activities.length > 10) {
activities[activities.length - 1].remove();
}
}
}
updateStats(stats) {
// Statistik-Karten aktualisieren mit Animation
const statValues = document.querySelectorAll('.stat-value');
const statsMapping = [
{ element: statValues[0], value: stats.active_jobs_count },
{ element: statValues[1], value: stats.available_printers_count },
{ element: statValues[2], value: stats.total_jobs_count },
{ element: statValues[3], value: `${stats.success_rate}%` }
];
statsMapping.forEach(({ element, value }) => {
if (element && element.textContent !== value.toString()) {
this.animateValueChange(element, value);
}
});
}
animateValueChange(element, newValue) {
element.style.transform = 'scale(1.1)';
element.style.transition = 'transform 0.3s ease';
setTimeout(() => {
element.textContent = newValue;
element.style.transform = 'scale(1)';
}, 150);
}
animateCounters() {
// Initialanimation für Counter
const counters = document.querySelectorAll('.stat-value');
counters.forEach((counter, index) => {
const finalValue = parseInt(counter.textContent) || 0;
if (finalValue > 0) {
let currentValue = 0;
const increment = finalValue / 30; // 30 Schritte
const timer = setInterval(() => {
currentValue += increment;
if (currentValue >= finalValue) {
counter.textContent = finalValue + (counter.textContent.includes('%') ? '%' : '');
clearInterval(timer);
} else {
counter.textContent = Math.floor(currentValue) + (counter.textContent.includes('%') ? '%' : '');
}
}, 50 + (index * 100)); // Verzögerung zwischen Countern
}
});
}
getStatusClass(status) {
const statusClasses = {
'running': 'mb-status-busy',
'completed': 'mb-status-online',
'failed': 'mb-status-offline',
'paused': 'mb-status-idle',
'queued': 'mb-status-idle',
'online': 'mb-status-online',
'offline': 'mb-status-offline',
'busy': 'mb-status-busy',
'idle': 'mb-status-idle'
};
return statusClasses[status] || 'mb-status-idle';
}
getStatusText(status) {
const statusTexts = {
'running': 'Druckt',
'completed': 'Abgeschlossen',
'failed': 'Fehlgeschlagen',
'paused': 'Pausiert',
'queued': 'Warteschlange',
'online': 'Online',
'offline': 'Offline',
'busy': 'Beschäftigt',
'idle': 'Bereit'
};
return statusTexts[status] || 'Unbekannt';
}
updateThemeElements(isDark) {
// Theme-spezifische Updates
console.log(`🎨 Dashboard Theme aktualisiert: ${isDark ? 'Dark' : 'Light'} Mode`);
// Spezielle Animationen für Theme-Wechsel
const cards = document.querySelectorAll('.dashboard-card');
cards.forEach(card => {
card.style.transition = 'all 0.3s ease';
});
}
showToast(message, type = 'info') {
// Toast-Benachrichtigung anzeigen
if (window.MYP && window.MYP.UI && window.MYP.UI.ToastManager) {
const toast = new window.MYP.UI.ToastManager();
toast.show(message, type, 5000);
} else {
console.log(`Toast: ${message}`);
}
}
// Cleanup beim Verlassen der Seite
cleanup() {
if (this.autoUpdateTimer) {
clearInterval(this.autoUpdateTimer);
}
if (this.wsConnection) {
this.wsConnection.close();
}
}
}
// Dashboard Manager initialisieren
// Vereinfachtes Dashboard mit minimaler JavaScript-Abhängigkeit
document.addEventListener('DOMContentLoaded', function() {
window.dashboardManager = new DashboardManager();
// Cleanup beim Verlassen
window.addEventListener('beforeunload', () => {
if (window.dashboardManager) {
window.dashboardManager.cleanup();
}
// Klickbare Drucker-Karten
const printerCards = document.querySelectorAll('[data-printer-id]');
printerCards.forEach(card => {
card.style.cursor = 'pointer';
card.addEventListener('click', () => {
const printerId = card.dataset.printerId;
if (printerId) {
window.location.href = `/printers/${printerId}`;
}
});
});
// Job-Zeilen klickbar machen
const jobRows = document.querySelectorAll('tbody tr[data-job-id]');
jobRows.forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
const jobId = row.dataset.jobId;
if (jobId) {
window.location.href = `/jobs#job-${jobId}`;
}
});
});
// Einfache Hover-Effekte
const cards = document.querySelectorAll('.dashboard-card');
cards.forEach(card => {
card.addEventListener('mouseenter', () => {
card.style.transform = 'translateY(-2px)';
});
card.addEventListener('mouseleave', () => {
card.style.transform = 'translateY(0)';
});
});
console.log('✅ Vereinfachtes Dashboard geladen');
});
// Auto-Refresh alle 60 Sekunden (optional)
{% if config.get('AUTO_REFRESH_DASHBOARD', False) %}
setTimeout(() => {
window.location.reload();
}, 60000);
{% endif %}
</script>
{% endblock %}

View File

@ -0,0 +1,183 @@
{# Jinja-Makros für UI-Komponenten zur JavaScript-Ersetzung #}
{# Status-Indikator mit CSS-Animation #}
{% macro status_indicator(status, text="") %}
{% set status_classes = {
'online': 'mb-status-online',
'offline': 'mb-status-offline',
'busy': 'mb-status-busy',
'idle': 'mb-status-idle',
'running': 'mb-status-busy',
'completed': 'mb-status-online',
'failed': 'mb-status-offline',
'paused': 'mb-status-idle',
'queued': 'mb-status-idle'
} %}
<div class="flex items-center">
<div class="mb-status-indicator {{ status_classes.get(status, 'mb-status-idle') }}"></div>
{% if text %}
<span class="ml-2 text-sm font-medium text-slate-700 dark:text-slate-300">{{ text }}</span>
{% endif %}
</div>
{% endmacro %}
{# Fortschrittsbalken mit CSS-Animation #}
{% macro progress_bar(progress, show_text=True) %}
<div class="mb-progress-container">
<div class="mb-progress-bar" style="width: {{ progress }}%"></div>
</div>
{% if show_text %}
<div class="text-xs text-right mt-1 text-slate-500 dark:text-slate-400">{{ progress }}%</div>
{% endif %}
{% endmacro %}
{# Klickbare Karte #}
{% macro clickable_card(url, class="dashboard-card p-6") %}
<a href="{{ url }}" class="{{ class }} block hover:transform hover:-translate-y-1 transition-transform">
{{ caller() }}
</a>
{% endmacro %}
{# Tab-Navigation mit serverseitiger Logik #}
{% macro tab_navigation(tabs, active_tab) %}
<div class="border-b border-gray-200 dark:border-slate-700">
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
{% for tab in tabs %}
<a href="{{ tab.url }}"
class="{% if active_tab == tab.id %}border-blue-500 text-blue-600 dark:text-blue-400{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-slate-400 dark:hover:text-slate-300{% endif %}
whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm">
{% if tab.icon %}
<i class="{{ tab.icon }} mr-2"></i>
{% endif %}
{{ tab.name }}
</a>
{% endfor %}
</nav>
</div>
{% endmacro %}
{# Drucker-Status-Karte ohne JavaScript #}
{% macro printer_card(printer, show_link=True) %}
{% if show_link %}
<a href="{{ url_for('printers_page') }}#printer-{{ printer.id }}" class="block">
{% endif %}
<div class="flex items-center justify-between p-4 rounded-xl bg-gray-50 dark:bg-slate-700/30 {% if show_link %}hover:bg-gray-100 dark:hover:bg-slate-700/50 transition-colors{% endif %}">
<div class="flex items-center">
{{ status_indicator(printer.status, printer.status_text) }}
<div class="ml-3">
<div class="text-sm font-medium text-slate-900 dark:text-white">{{ printer.name }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ printer.model }}</div>
</div>
</div>
<div class="text-right">
<div class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ printer.status_text }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ printer.location }}</div>
</div>
</div>
{% if show_link %}
</a>
{% endif %}
{% endmacro %}
{# Job-Zeile in Tabelle ohne JavaScript #}
{% macro job_row(job, show_link=True) %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 {% if show_link %}cursor-pointer{% endif %}">
{% if show_link %}
<td colspan="6" class="p-0">
<a href="{{ url_for('jobs_page') }}#job-{{ job.id }}" class="block px-6 py-4">
{{ job_row_content(job) }}
</a>
</td>
{% else %}
{{ job_row_content(job) }}
{% endif %}
</tr>
{% endmacro %}
{# Job-Zeilen-Inhalt (für Wiederverwendung) #}
{% macro job_row_content(job) %}
<td class="px-6 py-4 whitespace-nowrap">
{{ status_indicator(job.status, job.status_text) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-slate-900 dark:text-white">{{ job.name }}</div>
<div class="text-xs text-slate-500 dark:text-slate-400">{{ job.file_name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-slate-700 dark:text-slate-300">{{ job.printer }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-slate-700 dark:text-slate-300">{{ job.start_time }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{{ progress_bar(job.progress) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">
<span class="text-slate-900 dark:text-white hover:text-slate-700 dark:hover:text-slate-300 font-medium">Details</span>
</td>
{% endmacro %}
{# Benachrichtigungs-Toast mit Auto-Close via CSS #}
{% macro notification_toast(message, type="info", auto_close=True) %}
{% set type_classes = {
'success': 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-900/20 dark:text-green-300',
'error': 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300',
'warning': 'border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300',
'info': 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-300'
} %}
<div class="glass rounded-lg p-4 border {{ type_classes.get(type, type_classes['info']) }} {% if auto_close %}animate-toast{% endif %}">
<div class="flex items-start">
<i class="fas {{ 'fa-check-circle' if type == 'success' else 'fa-exclamation-circle' if type == 'error' else 'fa-exclamation-triangle' if type == 'warning' else 'fa-info-circle' }} mt-0.5 mr-3"></i>
<div class="flex-1">
<p class="text-sm font-medium">{{ message }}</p>
</div>
<form method="POST" class="ml-3">
{{ csrf_token() }}
<button type="submit" name="dismiss_notification" class="hover:opacity-70">
<i class="fas fa-times text-sm"></i>
</button>
</form>
</div>
</div>
{% endmacro %}
{# Formular-Submit-Button mit Loading-State #}
{% macro submit_button(text, loading_text="Wird verarbeitet...", class="btn-primary") %}
<button type="submit" class="{{ class }} relative" id="submit-btn">
<span class="submit-text">{{ text }}</span>
<span class="loading-text hidden">{{ loading_text }}</span>
</button>
<script>
document.getElementById('submit-btn').form.addEventListener('submit', function() {
const btn = document.getElementById('submit-btn');
btn.disabled = true;
btn.querySelector('.submit-text').classList.add('hidden');
btn.querySelector('.loading-text').classList.remove('hidden');
});
</script>
{% endmacro %}
{# Auto-Refresh Meta-Tag für periodische Seitenaktualisierung #}
{% macro auto_refresh(seconds) %}
<meta http-equiv="refresh" content="{{ seconds }}">
{% endmacro %}
{# CSS-only Dropdown-Menu #}
{% macro css_dropdown(button_text, items, button_class="btn-secondary") %}
<div class="relative group">
<button class="{{ button_class }}">
{{ button_text }}
<i class="fas fa-chevron-down ml-2"></i>
</button>
<div class="absolute right-0 mt-2 w-48 glass rounded-xl overflow-hidden opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
{% for item in items %}
<a href="{{ item.url }}" class="block px-4 py-2 text-sm hover:bg-white/10 dark:hover:bg-black/10">
{% if item.icon %}
<i class="{{ item.icon }} w-4 mr-3"></i>
{% endif %}
{{ item.text }}
</a>
{% endfor %}
</div>
</div>
{% endmacro %}