Initial commit
Dieser Commit ist enthalten in:
20
v2_adminpanel/templates/404.html
Normale Datei
20
v2_adminpanel/templates/404.html
Normale Datei
@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Seite nicht gefunden{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2>Seite nicht gefunden</h2>
|
||||
<p>Die angeforderte Seite konnte nicht gefunden werden.</p>
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">Zur Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
47
v2_adminpanel/templates/500.html
Normale Datei
47
v2_adminpanel/templates/500.html
Normale Datei
@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Serverfehler{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 4rem;"></i>
|
||||
</div>
|
||||
<h1 class="display-1 text-danger">500</h1>
|
||||
<h2 class="mb-4">Interner Serverfehler</h2>
|
||||
<p class="lead mb-4">
|
||||
Es tut uns leid, aber es ist ein unerwarteter Fehler aufgetreten.
|
||||
Unser Team wurde benachrichtigt und arbeitet an einer Lösung.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-warning text-start mx-auto" style="max-width: 500px;">
|
||||
<p class="mb-0"><strong>Fehlermeldung:</strong> {{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-house"></i> Zum Dashboard
|
||||
</a>
|
||||
<button onclick="location.reload();" class="btn btn-outline-secondary btn-lg ms-2">
|
||||
<i class="bi bi-arrow-clockwise"></i> Seite neu laden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-muted">
|
||||
<small>
|
||||
Fehler-ID: <code>{{ request_id or 'Nicht verfügbar' }}</code><br>
|
||||
Zeitstempel: {{ timestamp or 'Nicht verfügbar' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
439
v2_adminpanel/templates/add_resources.html
Normale Datei
439
v2_adminpanel/templates/add_resources.html
Normale Datei
@ -0,0 +1,439 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Ressourcen hinzufügen{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Card Styling */
|
||||
.main-card {
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Preview Section */
|
||||
.preview-card {
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.preview-card.active {
|
||||
border-color: #28a745;
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
|
||||
/* Format Examples */
|
||||
.example-card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.example-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.example-code {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Resource Type Selector */
|
||||
.resource-type-selector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.resource-type-option {
|
||||
padding: 1.5rem;
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #fff;
|
||||
}
|
||||
.resource-type-option:hover {
|
||||
border-color: #007bff;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.resource-type-option.selected {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
.resource-type-option .icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Textarea Styling */
|
||||
.resource-input {
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px solid #dee2e6;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
.resource-input:focus {
|
||||
background-color: #fff;
|
||||
border-color: #80bdff;
|
||||
}
|
||||
|
||||
/* Stats Display */
|
||||
.stats-display {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Ressourcen hinzufügen</h1>
|
||||
<p class="text-muted mb-0">Fügen Sie neue Domains, IPs oder Telefonnummern zum Pool hinzu</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources.resources', show_test=show_test) }}" class="btn btn-secondary">
|
||||
← Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('resources.add_resources', show_test=show_test) }}" id="addResourceForm">
|
||||
<!-- Resource Type Selection -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">1️⃣ Ressourcentyp wählen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="hidden" name="resource_type" id="resource_type" required>
|
||||
<div class="resource-type-selector">
|
||||
<div class="resource-type-option" data-type="domain">
|
||||
<div class="icon">🌐</div>
|
||||
<h6 class="mb-0">Domain</h6>
|
||||
<small class="text-muted">Webseiten-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="ipv4">
|
||||
<div class="icon">🖥️</div>
|
||||
<h6 class="mb-0">IPv4</h6>
|
||||
<small class="text-muted">IP-Adressen</small>
|
||||
</div>
|
||||
<div class="resource-type-option" data-type="phone">
|
||||
<div class="icon">📱</div>
|
||||
<h6 class="mb-0">Telefon</h6>
|
||||
<small class="text-muted">Telefonnummern</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Input -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">2️⃣ Ressourcen eingeben</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="resources_text" class="form-label">
|
||||
Ressourcen (eine pro Zeile)
|
||||
</label>
|
||||
<textarea name="resources_text"
|
||||
id="resources_text"
|
||||
class="form-control resource-input"
|
||||
rows="12"
|
||||
required
|
||||
placeholder="Bitte wählen Sie zuerst einen Ressourcentyp aus..."></textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Geben Sie jede Ressource in eine neue Zeile ein. Duplikate werden automatisch übersprungen.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Preview -->
|
||||
<div class="preview-card p-3" id="preview">
|
||||
<h6 class="mb-3">📊 Live-Vorschau</h6>
|
||||
<div class="stats-display">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="validCount">0</div>
|
||||
<div class="stat-label">Gültig</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-warning" id="duplicateCount">0</div>
|
||||
<div class="stat-label">Duplikate</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value text-danger" id="invalidCount">0</div>
|
||||
<div class="stat-label">Ungültig</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errorList" class="mt-3" style="display: none;">
|
||||
<div class="alert alert-danger">
|
||||
<strong>Fehler gefunden:</strong>
|
||||
<ul id="errorMessages" class="mb-0"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format Examples -->
|
||||
<div class="card main-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">💡 Format-Beispiele</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🌐</span> Domains
|
||||
</h6>
|
||||
<pre class="example-code">example.com
|
||||
test-domain.net
|
||||
meine-seite.de
|
||||
subdomain.example.org
|
||||
my-website.io</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Ohne http(s)://<br>
|
||||
<strong>Erlaubt:</strong> Buchstaben, Zahlen, Punkt, Bindestrich
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">🖥️</span> IPv4-Adressen
|
||||
</h6>
|
||||
<pre class="example-code">192.168.1.10
|
||||
192.168.1.11
|
||||
10.0.0.1
|
||||
172.16.0.5
|
||||
8.8.8.8</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> xxx.xxx.xxx.xxx<br>
|
||||
<strong>Bereich:</strong> 0-255 pro Oktett
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card example-card h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">
|
||||
<span class="text-primary">📱</span> Telefonnummern
|
||||
</h6>
|
||||
<pre class="example-code">+491701234567
|
||||
+493012345678
|
||||
+33123456789
|
||||
+441234567890
|
||||
+12125551234</pre>
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<strong>Format:</strong> Mit Ländervorwahl<br>
|
||||
<strong>Start:</strong> Immer mit +
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Data Option -->
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="is_test" name="is_test" {% if show_test %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_test">
|
||||
Als Testdaten markieren
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<button type="button" class="btn btn-secondary" onclick="window.location.href='{{ url_for('resources.resources', show_test=show_test) }}'">
|
||||
<i class="fas fa-times"></i> Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success btn-lg" id="submitBtn" disabled>
|
||||
<i class="fas fa-plus-circle"></i> Ressourcen hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const typeOptions = document.querySelectorAll('.resource-type-option');
|
||||
const typeInput = document.getElementById('resource_type');
|
||||
const textArea = document.getElementById('resources_text');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const form = document.getElementById('addResourceForm');
|
||||
|
||||
// Preview elements
|
||||
const validCount = document.getElementById('validCount');
|
||||
const duplicateCount = document.getElementById('duplicateCount');
|
||||
const invalidCount = document.getElementById('invalidCount');
|
||||
const errorList = document.getElementById('errorList');
|
||||
const errorMessages = document.getElementById('errorMessages');
|
||||
const preview = document.getElementById('preview');
|
||||
|
||||
let selectedType = null;
|
||||
|
||||
// Placeholder texts for different types
|
||||
const placeholders = {
|
||||
domain: `example.com
|
||||
test-site.net
|
||||
my-domain.org
|
||||
subdomain.example.com`,
|
||||
ipv4: `192.168.1.1
|
||||
10.0.0.1
|
||||
172.16.0.1
|
||||
8.8.8.8`,
|
||||
phone: `+491234567890
|
||||
+4930123456
|
||||
+33123456789
|
||||
+12125551234`
|
||||
};
|
||||
|
||||
// Resource type selection
|
||||
typeOptions.forEach(option => {
|
||||
option.addEventListener('click', function() {
|
||||
typeOptions.forEach(opt => opt.classList.remove('selected'));
|
||||
this.classList.add('selected');
|
||||
selectedType = this.dataset.type;
|
||||
typeInput.value = selectedType;
|
||||
textArea.placeholder = placeholders[selectedType] || '';
|
||||
textArea.disabled = false;
|
||||
updatePreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Validation functions
|
||||
function validateDomain(domain) {
|
||||
const domainRegex = /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9][a-zA-Z0-9-_]+\.[a-zA-Z]{2,}$/;
|
||||
return domainRegex.test(domain);
|
||||
}
|
||||
|
||||
function validateIPv4(ip) {
|
||||
const ipRegex = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
return ipRegex.test(ip);
|
||||
}
|
||||
|
||||
function validatePhone(phone) {
|
||||
const phoneRegex = /^\+[1-9]\d{6,14}$/;
|
||||
return phoneRegex.test(phone);
|
||||
}
|
||||
|
||||
// Update preview function
|
||||
function updatePreview() {
|
||||
if (!selectedType) {
|
||||
preview.classList.remove('active');
|
||||
submitBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = textArea.value.split('\n').filter(line => line.trim() !== '');
|
||||
const uniqueResources = new Set();
|
||||
const errors = [];
|
||||
let valid = 0;
|
||||
let duplicates = 0;
|
||||
let invalid = 0;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const trimmed = line.trim();
|
||||
if (uniqueResources.has(trimmed)) {
|
||||
duplicates++;
|
||||
return;
|
||||
}
|
||||
|
||||
let isValid = false;
|
||||
switch(selectedType) {
|
||||
case 'domain':
|
||||
isValid = validateDomain(trimmed);
|
||||
break;
|
||||
case 'ipv4':
|
||||
isValid = validateIPv4(trimmed);
|
||||
break;
|
||||
case 'phone':
|
||||
isValid = validatePhone(trimmed);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
valid++;
|
||||
uniqueResources.add(trimmed);
|
||||
} else {
|
||||
invalid++;
|
||||
errors.push(`Zeile ${index + 1}: "${trimmed}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Update counts
|
||||
validCount.textContent = valid;
|
||||
duplicateCount.textContent = duplicates;
|
||||
invalidCount.textContent = invalid;
|
||||
|
||||
// Show/hide error list
|
||||
if (errors.length > 0) {
|
||||
errorList.style.display = 'block';
|
||||
errorMessages.innerHTML = errors.map(err => `<li>${err}</li>`).join('');
|
||||
} else {
|
||||
errorList.style.display = 'none';
|
||||
}
|
||||
|
||||
// Enable/disable submit button
|
||||
submitBtn.disabled = valid === 0 || invalid > 0;
|
||||
|
||||
// Update preview appearance
|
||||
if (lines.length > 0) {
|
||||
preview.classList.add('active');
|
||||
} else {
|
||||
preview.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Live validation
|
||||
textArea.addEventListener('input', updatePreview);
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (submitBtn.disabled) {
|
||||
e.preventDefault();
|
||||
alert('Bitte beheben Sie alle Fehler bevor Sie fortfahren.');
|
||||
}
|
||||
});
|
||||
|
||||
// Initial state
|
||||
textArea.disabled = true;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
391
v2_adminpanel/templates/audit_log.html
Normale Datei
391
v2_adminpanel/templates/audit_log.html
Normale Datei
@ -0,0 +1,391 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Log{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('admin.audit_log', sort=field, order='desc' if current_order == 'asc' else 'asc', user=filter_user, action=action_filter, entity=entity_filter, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin.audit_log', sort=field, order='asc', user=filter_user, action=action_filter, entity=entity_filter, 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 {
|
||||
font-size: 0.85em;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.json-display {
|
||||
background-color: #f8f9fa;
|
||||
padding: 5px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* CRUD-Operationen */
|
||||
.action-CREATE { color: #28a745; }
|
||||
.action-UPDATE { color: #007bff; }
|
||||
.action-DELETE { color: #dc3545; }
|
||||
.action-IMPORT { color: #6f42c1; }
|
||||
|
||||
/* Authentifizierung */
|
||||
.action-LOGIN { color: #17a2b8; }
|
||||
.action-LOGIN_SUCCESS { color: #28a745; }
|
||||
.action-LOGIN_FAILED { color: #dc3545; }
|
||||
.action-LOGIN_BLOCKED { color: #b91c1c; }
|
||||
.action-LOGOUT { color: #6c757d; }
|
||||
.action-AUTO_LOGOUT { color: #fd7e14; }
|
||||
|
||||
/* 2FA */
|
||||
.action-LOGIN_2FA_SUCCESS { color: #00a86b; }
|
||||
.action-LOGIN_2FA_BACKUP { color: #059862; }
|
||||
.action-LOGIN_2FA_FAILED { color: #e53e3e; }
|
||||
.action-2FA_ENABLED { color: #38a169; }
|
||||
.action-2FA_DISABLED { color: #e53e3e; }
|
||||
|
||||
/* Lizenzverwaltung */
|
||||
.action-GENERATE_KEY { color: #20c997; }
|
||||
.action-CREATE_BATCH { color: #6610f2; }
|
||||
.action-BATCH_UPDATE { color: #17a2b8; }
|
||||
.action-TOGGLE { color: #ffc107; }
|
||||
.action-QUICK_EDIT { color: #00bcd4; }
|
||||
.action-BULK_ACTIVATE { color: #4caf50; }
|
||||
.action-BULK_DEACTIVATE { color: #ff5722; }
|
||||
.action-BULK_DELETE { color: #f44336; }
|
||||
|
||||
/* Geräteverwaltung */
|
||||
.action-DEVICE_REGISTER { color: #2196f3; }
|
||||
.action-DEVICE_DEACTIVATE { color: #ff9800; }
|
||||
|
||||
/* Ressourcenverwaltung */
|
||||
.action-RESOURCE_ALLOCATE { color: #009688; }
|
||||
.action-QUARANTINE { color: #ff6b6b; }
|
||||
.action-RELEASE { color: #51cf66; }
|
||||
.action-BULK_CREATE { color: #845ef7; }
|
||||
|
||||
/* Session-Verwaltung */
|
||||
.action-SESSION_TERMINATE { color: #e91e63; }
|
||||
.action-SESSION_TERMINATE_ALL { color: #c2255c; }
|
||||
.action-SESSION_CLEANUP { color: #868e96; }
|
||||
|
||||
/* System-Operationen */
|
||||
.action-BACKUP { color: #5a67d8; }
|
||||
.action-BACKUP_DOWNLOAD { color: #4299e1; }
|
||||
.action-BACKUP_DELETE { color: #e53e3e; }
|
||||
.action-RESTORE { color: #4299e1; }
|
||||
.action-EXPORT { color: #ffc107; }
|
||||
.action-PASSWORD_CHANGE { color: #805ad5; }
|
||||
.action-UNBLOCK_IP { color: #22b8cf; }
|
||||
.action-CLEAR_LOGIN_ATTEMPTS { color: #37b24d; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>📝 Log</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('admin.audit_log') }}" id="auditFilterForm">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label for="user" class="form-label">Benutzer</label>
|
||||
<select class="form-select" id="user" name="user">
|
||||
<option value="">Alle Benutzer</option>
|
||||
<option value="rac00n" {% if filter_user == 'rac00n' %}selected{% endif %}>rac00n</option>
|
||||
<option value="w@rh@mm3r" {% if filter_user == 'w@rh@mm3r' %}selected{% endif %}>w@rh@mm3r</option>
|
||||
<option value="system" {% if filter_user == 'system' %}selected{% endif %}>System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="action" class="form-label">Aktion</label>
|
||||
<select class="form-select" id="action" name="action">
|
||||
<option value="">Alle Aktionen</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action }}" {% if action_filter == action %}selected{% endif %}>
|
||||
{% if action == 'CREATE' %}➕ Erstellt
|
||||
{% elif action == 'UPDATE' %}✏️ Bearbeitet
|
||||
{% elif action == 'DELETE' %}🗑️ Gelöscht
|
||||
{% elif action == 'IMPORT' %}📤 Import
|
||||
{% elif action == 'LOGIN' %}🔑 Anmeldung
|
||||
{% elif action == 'LOGIN_SUCCESS' %}✅ Anmeldung erfolgreich
|
||||
{% elif action == 'LOGIN_FAILED' %}❌ Anmeldung fehlgeschlagen
|
||||
{% elif action == 'LOGIN_BLOCKED' %}🚫 Login blockiert
|
||||
{% elif action == 'LOGOUT' %}🚪 Abmeldung
|
||||
{% elif action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
|
||||
{% elif action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
|
||||
{% elif action == 'LOGIN_2FA_FAILED' %}⛔ 2FA fehlgeschlagen
|
||||
{% elif action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
|
||||
{% elif action == '2FA_ENABLED' %}✅ 2FA aktiviert
|
||||
{% elif action == '2FA_DISABLED' %}❌ 2FA deaktiviert
|
||||
{% elif action == 'GENERATE_KEY' %}🔑 Key generiert
|
||||
{% elif action == 'CREATE_BATCH' %}📦 Batch erstellt
|
||||
{% elif action == 'BATCH_UPDATE' %}🔄 Batch Update
|
||||
{% elif action == 'TOGGLE' %}🔄 Status geändert
|
||||
{% elif action == 'QUICK_EDIT' %}⚡ Schnellbearbeitung
|
||||
{% elif action == 'BULK_ACTIVATE' %}✅ Bulk-Aktivierung
|
||||
{% elif action == 'BULK_DEACTIVATE' %}❌ Bulk-Deaktivierung
|
||||
{% elif action == 'BULK_DELETE' %}🗑️ Bulk-Löschung
|
||||
{% elif action == 'DEVICE_REGISTER' %}📱 Gerät registriert
|
||||
{% elif action == 'DEVICE_DEACTIVATE' %}📵 Gerät deaktiviert
|
||||
{% elif action == 'RESOURCE_ALLOCATE' %}📎 Ressource zugewiesen
|
||||
{% elif action == 'QUARANTINE' %}⚠️ In Quarantäne
|
||||
{% elif action == 'RELEASE' %}✅ Freigegeben
|
||||
{% elif action == 'BULK_CREATE' %}➕ Bulk-Erstellung
|
||||
{% elif action == 'SESSION_TERMINATE' %}🛑 Session beendet
|
||||
{% elif action == 'SESSION_TERMINATE_ALL' %}🛑 Alle Sessions beendet
|
||||
{% elif action == 'SESSION_CLEANUP' %}🧹 Session-Bereinigung
|
||||
{% elif action == 'BACKUP' %}💾 Backup
|
||||
{% elif action == 'BACKUP_DOWNLOAD' %}⬇️ Backup Download
|
||||
{% elif action == 'BACKUP_DELETE' %}🗑️ Backup gelöscht
|
||||
{% elif action == 'RESTORE' %}🔄 Wiederhergestellt
|
||||
{% elif action == 'EXPORT' %}📥 Export
|
||||
{% elif action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
|
||||
{% elif action == 'UNBLOCK_IP' %}🔓 IP entsperrt
|
||||
{% elif action == 'CLEAR_LOGIN_ATTEMPTS' %}🧹 Login-Versuche gelöscht
|
||||
{% else %}{{ action }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="entity" class="form-label">Entität</label>
|
||||
<select class="form-select" id="entity" name="entity">
|
||||
<option value="">Alle Entitäten</option>
|
||||
{% for entity in entities %}
|
||||
<option value="{{ entity }}" {% if entity_filter == entity %}selected{% endif %}>
|
||||
{% if entity == 'license' %}Lizenz
|
||||
{% elif entity == 'customer' %}Kunde
|
||||
{% elif entity == 'user' %}Benutzer
|
||||
{% elif entity == 'session' %}Session
|
||||
{% elif entity == 'resource' %}Ressource
|
||||
{% elif entity == 'backup' %}Backup
|
||||
{% elif entity == 'database' %}Datenbank
|
||||
{% elif entity == 'system' %}System
|
||||
{% else %}{{ entity|title }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.audit_log') }}" class="btn btn-outline-secondary">Zurücksetzen</a>
|
||||
<!-- Export Buttons -->
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('export.export_audit', format='excel', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Excel
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_audit', format='csv', user=filter_user, action=action_filter, entity=entity_filter) }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> CSV
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Log Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Benutzer</th>
|
||||
<th>Aktion</th>
|
||||
<th>Entität</th>
|
||||
<th>Details</th>
|
||||
<th>IP-Adresse</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.timestamp.strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td><strong>{{ log.username }}</strong></td>
|
||||
<td>
|
||||
<span class="action-{{ log.action }}">
|
||||
{% if log.action == 'CREATE' %}➕ Erstellt
|
||||
{% elif log.action == 'UPDATE' %}✏️ Bearbeitet
|
||||
{% elif log.action == 'DELETE' %}🗑️ Gelöscht
|
||||
{% elif log.action == 'LOGIN' %}🔑 Anmeldung
|
||||
{% elif log.action == 'LOGOUT' %}🚪 Abmeldung
|
||||
{% elif log.action == 'AUTO_LOGOUT' %}⏰ Auto-Logout
|
||||
{% elif log.action == 'EXPORT' %}📥 Export
|
||||
{% elif log.action == 'GENERATE_KEY' %}🔑 Key generiert
|
||||
{% elif log.action == 'CREATE_BATCH' %}🔑 Batch erstellt
|
||||
{% elif log.action == 'BACKUP' %}💾 Backup erstellt
|
||||
{% elif log.action == 'LOGIN_2FA_SUCCESS' %}🔐 2FA-Anmeldung
|
||||
{% elif log.action == 'LOGIN_2FA_BACKUP' %}🔒 2FA-Backup-Code
|
||||
{% elif log.action == 'LOGIN_2FA_FAILED' %}⛔ 2FA-Fehlgeschlagen
|
||||
{% elif log.action == 'LOGIN_BLOCKED' %}🚫 Login-Blockiert
|
||||
{% elif log.action == 'RESTORE' %}🔄 Wiederhergestellt
|
||||
{% elif log.action == 'PASSWORD_CHANGE' %}🔐 Passwort geändert
|
||||
{% elif log.action == '2FA_ENABLED' %}✅ 2FA aktiviert
|
||||
{% elif log.action == '2FA_DISABLED' %}❌ 2FA deaktiviert
|
||||
{% else %}{{ log.action }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{ log.entity_type }}
|
||||
{% if log.entity_id %}
|
||||
<small class="text-muted">#{{ log.entity_id }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="audit-details">
|
||||
{% if log.additional_info %}
|
||||
<div class="mb-1"><small class="text-muted">{{ log.additional_info }}</small></div>
|
||||
{% endif %}
|
||||
|
||||
{% if log.old_values and log.action == 'DELETE' %}
|
||||
<details>
|
||||
<summary>Gelöschte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log.old_values.items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log.old_values and log.new_values and log.action == 'UPDATE' %}
|
||||
<details>
|
||||
<summary>Änderungen anzeigen</summary>
|
||||
<div class="json-display">
|
||||
<strong>Vorher:</strong><br>
|
||||
{% for key, value in log.old_values.items() %}
|
||||
{% if log.new_values[key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<hr class="my-1">
|
||||
<strong>Nachher:</strong><br>
|
||||
{% for key, value in log.new_values.items() %}
|
||||
{% if log.old_values[key] != value %}
|
||||
{{ key }}: {{ value }}<br>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% elif log.new_values and log.action == 'CREATE' %}
|
||||
<details>
|
||||
<summary>Erstellte Werte</summary>
|
||||
<div class="json-display">
|
||||
{% for key, value in log.new_values.items() %}
|
||||
<strong>{{ key }}:</strong> {{ value }}<br>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ log.ip_address or '-' }}</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not logs %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Keine Audit-Log-Einträge gefunden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('admin.audit_log', page=1, user=filter_user, action=action_filter, entity=entity_filter, 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('admin.audit_log', page=page-1, user=filter_user, action=action_filter, entity=entity_filter, 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('admin.audit_log', page=p, user=filter_user, action=action_filter, entity=entity_filter, 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('admin.audit_log', page=page+1, user=filter_user, action=action_filter, entity=entity_filter, 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('admin.audit_log', page=total_pages, user=filter_user, action=action_filter, entity=entity_filter, sort=sort, order=order) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Einträge
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Filtering für Audit Log
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterForm = document.getElementById('auditFilterForm');
|
||||
const userInput = document.getElementById('user');
|
||||
const actionSelect = document.getElementById('action');
|
||||
const entitySelect = document.getElementById('entity');
|
||||
|
||||
// Debounce timer für Textfelder
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Filter für Benutzer-Dropdown (sofort)
|
||||
userInput.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
// Live-Filter für Dropdowns (sofort)
|
||||
actionSelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
|
||||
entitySelect.addEventListener('change', function() {
|
||||
filterForm.submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
228
v2_adminpanel/templates/backup_codes.html
Normale Datei
@ -0,0 +1,228 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Codes{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
animation: bounce 0.5s ease-in-out;
|
||||
}
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
.backup-code {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
padding: 8px 15px;
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
.backup-codes-container {
|
||||
background-color: #fff;
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.action-buttons .btn {
|
||||
margin: 5px;
|
||||
}
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
.backup-codes-container {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<!-- Success Message -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="success-icon text-success">✅</div>
|
||||
<h1 class="mt-3">2FA erfolgreich aktiviert!</h1>
|
||||
<p class="lead text-muted">Ihre Zwei-Faktor-Authentifizierung ist jetzt aktiv.</p>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Card -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h4 class="mb-0">
|
||||
<span style="font-size: 1.5rem; vertical-align: middle;">⚠️</span>
|
||||
Wichtig: Ihre Backup-Codes
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-4">
|
||||
<strong>Was sind Backup-Codes?</strong><br>
|
||||
Diese Codes ermöglichen Ihnen den Zugang zu Ihrem Account, falls Sie keinen Zugriff auf Ihre Authenticator-App haben.
|
||||
<strong>Jeder Code kann nur einmal verwendet werden.</strong>
|
||||
</div>
|
||||
|
||||
<!-- Backup Codes Display -->
|
||||
<div class="backup-codes-container text-center">
|
||||
<h5 class="mb-4">Ihre 8 Backup-Codes:</h5>
|
||||
<div class="row justify-content-center">
|
||||
{% for code in backup_codes %}
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="backup-code">{{ code }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="text-center action-buttons no-print">
|
||||
<button type="button" class="btn btn-primary btn-lg" onclick="downloadCodes()">
|
||||
💾 Als Datei speichern
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary btn-lg" onclick="printCodes()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
<button type="button" class="btn btn-info btn-lg" onclick="copyCodes()">
|
||||
📋 Alle kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Security Tips -->
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-danger">
|
||||
<h6>❌ Nicht empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Im selben Passwort-Manager wie Ihr Passwort</li>
|
||||
<li>Als Foto auf Ihrem Handy</li>
|
||||
<li>In einer unverschlüsselten Datei</li>
|
||||
<li>Per E-Mail an sich selbst</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="alert alert-success">
|
||||
<h6>✅ Empfohlen:</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li>Ausgedruckt in einem Safe</li>
|
||||
<li>In einem separaten Passwort-Manager</li>
|
||||
<li>Verschlüsselt auf einem USB-Stick</li>
|
||||
<li>An einem sicheren Ort zu Hause</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmation -->
|
||||
<div class="text-center mt-4 no-print">
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="confirmSaved" onchange="checkConfirmation()">
|
||||
<label class="form-check-label" for="confirmSaved">
|
||||
Ich habe die Backup-Codes sicher gespeichert
|
||||
</label>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-lg btn-success" id="continueBtn" style="display: none;">
|
||||
✅ Weiter zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = {{ backup_codes | tojson }};
|
||||
|
||||
function checkConfirmation() {
|
||||
const checkbox = document.getElementById('confirmSaved');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
continueBtn.style.display = checkbox.checked ? 'inline-block' : 'none';
|
||||
}
|
||||
|
||||
function downloadCodes() {
|
||||
const content = `V2 Admin Panel - Backup Codes
|
||||
=====================================
|
||||
Generiert am: ${new Date().toLocaleString('de-DE')}
|
||||
|
||||
WICHTIG: Bewahren Sie diese Codes sicher auf!
|
||||
Jeder Code kann nur einmal verwendet werden.
|
||||
|
||||
Ihre 8 Backup-Codes:
|
||||
--------------------
|
||||
${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}
|
||||
|
||||
Sicherheitshinweise:
|
||||
-------------------
|
||||
✓ Bewahren Sie diese Codes getrennt von Ihrem Passwort auf
|
||||
✓ Speichern Sie sie an einem sicheren physischen Ort
|
||||
✓ Teilen Sie diese Codes niemals mit anderen
|
||||
✓ Jeder Code funktioniert nur einmal
|
||||
|
||||
Bei Verlust Ihrer Authenticator-App:
|
||||
------------------------------------
|
||||
1. Gehen Sie zur Login-Seite
|
||||
2. Geben Sie Benutzername und Passwort ein
|
||||
3. Klicken Sie auf "Backup-Code verwenden"
|
||||
4. Geben Sie einen dieser Codes ein
|
||||
|
||||
Nach Verwendung eines Codes sollten Sie neue Backup-Codes generieren.`;
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `v2-backup-codes-${new Date().toISOString().split('T')[0]}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Heruntergeladen!';
|
||||
btn.classList.remove('btn-primary');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function printCodes() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
function copyCodes() {
|
||||
const codesText = backupCodes.join('\n');
|
||||
navigator.clipboard.writeText(codesText).then(function() {
|
||||
// Visual feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '✅ Kopiert!';
|
||||
btn.classList.remove('btn-info');
|
||||
btn.classList.add('btn-success');
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalText;
|
||||
btn.classList.remove('btn-success');
|
||||
btn.classList.add('btn-info');
|
||||
}, 2000);
|
||||
}).catch(function(err) {
|
||||
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
301
v2_adminpanel/templates/backups.html
Normale Datei
301
v2_adminpanel/templates/backups.html
Normale Datei
@ -0,0 +1,301 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Backup-Verwaltung{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.status-success { color: #28a745; }
|
||||
.status-failed { color: #dc3545; }
|
||||
.status-in_progress { color: #ffc107; }
|
||||
.backup-actions { white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>💾 Backup-Verwaltung</h2>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Info -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">📅 Letztes erfolgreiches Backup</h5>
|
||||
{% if last_backup %}
|
||||
<p class="mb-1"><strong>Zeitpunkt:</strong> {{ last_backup.id.strftime('%d.%m.%Y %H:%M:%S') }}</p>
|
||||
<p class="mb-1"><strong>Größe:</strong> {{ (last_backup.filename / 1024 / 1024)|round(2) }} MB</p>
|
||||
<p class="mb-0"><strong>Dauer:</strong> {{ last_backup.filesize|round(1) }} Sekunden</p>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔧 Backup-Aktionen</h5>
|
||||
<button id="createBackupBtn" class="btn btn-primary" onclick="createBackup()">
|
||||
💾 Backup jetzt erstellen
|
||||
</button>
|
||||
<p class="text-muted mt-2 mb-0">
|
||||
<small>Automatische Backups: Täglich um 03:00 Uhr</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Historie -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Backup-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in backups %}
|
||||
<tr>
|
||||
<td>{{ backup.created_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td>
|
||||
<small>{{ backup.filename }}</small>
|
||||
{% if backup.is_encrypted %}
|
||||
<span class="badge bg-info ms-1">🔒 Verschlüsselt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.filesize %}
|
||||
{{ (backup.filesize / 1024 / 1024)|round(2) }} MB
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.backup_type == 'manual' %}
|
||||
<span class="badge bg-primary">Manuell</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Automatisch</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if backup.status == 'success' %}
|
||||
<span class="status-success">✅ Erfolgreich</span>
|
||||
{% elif backup.status == 'failed' %}
|
||||
<span class="status-failed" title="{{ backup.error_message }}">❌ Fehlgeschlagen</span>
|
||||
{% else %}
|
||||
<span class="status-in_progress">⏳ In Bearbeitung</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ backup.created_by }}</td>
|
||||
<td>
|
||||
{% if backup.tables_count and backup.records_count %}
|
||||
<small>
|
||||
{{ backup.tables_count }} Tabellen<br>
|
||||
{{ backup.records_count }} Datensätze<br>
|
||||
{% if backup.duration_seconds %}
|
||||
{{ backup.duration_seconds|round(1) }}s
|
||||
{% endif %}
|
||||
</small>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="backup-actions">
|
||||
{% if backup.status == 'success' %}
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('admin.download_backup', backup_id=backup.id) }}"
|
||||
class="btn btn-outline-primary"
|
||||
title="Backup herunterladen">
|
||||
📥 Download
|
||||
</a>
|
||||
<button class="btn btn-outline-success"
|
||||
onclick="restoreBackup({{ backup.id }}, '{{ backup.filename }}')"
|
||||
title="Backup wiederherstellen">
|
||||
🔄 Wiederherstellen
|
||||
</button>
|
||||
<button class="btn btn-outline-danger"
|
||||
onclick="deleteBackup({{ backup.id }}, '{{ backup.filename }}')"
|
||||
title="Backup löschen">
|
||||
🗑️ Löschen
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not backups %}
|
||||
<div class="text-center py-5">
|
||||
<p class="text-muted">Noch keine Backups vorhanden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wiederherstellungs-Modal -->
|
||||
<div class="modal fade" id="restoreModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">🔄 Backup wiederherstellen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>⚠️ Warnung:</strong> Bei der Wiederherstellung werden alle aktuellen Daten überschrieben!
|
||||
</div>
|
||||
<p>Backup: <strong id="restoreFilename"></strong></p>
|
||||
<form id="restoreForm">
|
||||
<input type="hidden" id="restoreBackupId">
|
||||
<div class="mb-3">
|
||||
<label for="encryptionKey" class="form-label">Verschlüsselungs-Passwort (optional)</label>
|
||||
<input type="password" class="form-control" id="encryptionKey"
|
||||
placeholder="Leer lassen für Standard-Passwort">
|
||||
<small class="text-muted">
|
||||
Falls das Backup mit einem anderen Passwort verschlüsselt wurde
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmRestore()">
|
||||
⚠️ Wiederherstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function createBackup() {
|
||||
const btn = document.getElementById('createBackupBtn');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '⏳ Backup wird erstellt...';
|
||||
|
||||
fetch('{{ url_for('admin.create_backup_route') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Erstellen des Backups: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function restoreBackup(backupId, filename) {
|
||||
document.getElementById('restoreBackupId').value = backupId;
|
||||
document.getElementById('restoreFilename').textContent = filename;
|
||||
document.getElementById('encryptionKey').value = '';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('restoreModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function confirmRestore() {
|
||||
if (!confirm('Wirklich wiederherstellen? Alle aktuellen Daten werden überschrieben!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const backupId = document.getElementById('restoreBackupId').value;
|
||||
const encryptionKey = document.getElementById('encryptionKey').value;
|
||||
|
||||
const formData = new FormData();
|
||||
if (encryptionKey) {
|
||||
formData.append('encryption_key', encryptionKey);
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
bootstrap.Modal.getInstance(document.getElementById('restoreModal')).hide();
|
||||
|
||||
// Loading anzeigen
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'position-fixed top-50 start-50 translate-middle';
|
||||
loadingDiv.innerHTML = '<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading...</span></div>';
|
||||
document.body.appendChild(loadingDiv);
|
||||
|
||||
fetch(`/backup/restore/${backupId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message + '\n\nDie Seite wird neu geladen...');
|
||||
window.location.href = '{{ url_for('admin.dashboard') }}';
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler bei der Wiederherstellung: ' + error);
|
||||
})
|
||||
.finally(() => {
|
||||
document.body.removeChild(loadingDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteBackup(backupId, filename) {
|
||||
if (!confirm(`Soll das Backup "${filename}" wirklich gelöscht werden?\n\nDieser Vorgang kann nicht rückgängig gemacht werden!`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/backup/delete/${backupId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert('✅ ' + data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('❌ ' + data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('❌ Fehler beim Löschen des Backups: ' + error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
705
v2_adminpanel/templates/base.html
Normale Datei
705
v2_adminpanel/templates/base.html
Normale Datei
@ -0,0 +1,705 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Panel{% endblock %} - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||
{% block extra_css %}{% endblock %}
|
||||
<style>
|
||||
/* Global Status Colors */
|
||||
:root {
|
||||
--status-active: #28a745;
|
||||
--status-warning: #ffc107;
|
||||
--status-danger: #dc3545;
|
||||
--status-inactive: #6c757d;
|
||||
--status-info: #17a2b8;
|
||||
--sidebar-width: 250px;
|
||||
--sidebar-collapsed: 60px;
|
||||
}
|
||||
|
||||
/* Status Classes - Global */
|
||||
.status-aktiv { color: var(--status-active) !important; }
|
||||
.status-ablaufend { color: var(--status-warning) !important; }
|
||||
.status-abgelaufen { color: var(--status-danger) !important; }
|
||||
.status-deaktiviert { color: var(--status-inactive) !important; }
|
||||
|
||||
/* Badge Variants */
|
||||
.badge-aktiv { background-color: var(--status-active) !important; }
|
||||
.badge-ablaufend { background-color: var(--status-warning) !important; color: #000 !important; }
|
||||
.badge-abgelaufen { background-color: var(--status-danger) !important; }
|
||||
.badge-deaktiviert { background-color: var(--status-inactive) !important; }
|
||||
|
||||
/* Session Timer Styles */
|
||||
#session-timer {
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.timer-normal {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.timer-warning {
|
||||
background-color: var(--status-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.timer-danger {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.timer-critical {
|
||||
background-color: var(--status-danger);
|
||||
color: white;
|
||||
animation: blink 0.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.session-warning-modal {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
/* Table Improvements */
|
||||
.table-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-sticky thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: #fff;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-sticky thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
/* Inline Actions */
|
||||
.btn-copy {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-copy:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.btn-copy.copied {
|
||||
background-color: var(--status-active);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.form-switch-custom {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input {
|
||||
cursor: pointer;
|
||||
width: 3em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
.form-switch-custom .form-check-input:checked {
|
||||
background-color: var(--status-active);
|
||||
border-color: var(--status-active);
|
||||
}
|
||||
|
||||
/* Bulk Actions Bar */
|
||||
.bulk-actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: #212529;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 -4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bulk-actions.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Checkbox Styling */
|
||||
.checkbox-cell {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.form-check-input-custom {
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Sidebar Navigation */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
height: calc(100vh - 56px);
|
||||
width: var(--sidebar-width);
|
||||
background-color: #f8f9fa;
|
||||
border-right: 1px solid #dee2e6;
|
||||
overflow-y: auto;
|
||||
transition: all 0.3s;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
background-color: #e9ecef;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-item {
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link:hover {
|
||||
background-color: #e9ecef;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link.active {
|
||||
background-color: var(--bs-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-nav .nav-link i {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav .nav-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-submenu {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: #f1f3f4;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sidebar-submenu .nav-link {
|
||||
padding-left: 2.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Arrow indicator for items with submenus */
|
||||
.nav-link.has-submenu::after {
|
||||
content: '▾';
|
||||
float: right;
|
||||
opacity: 0.5;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Main Content with Sidebar */
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
transition: all 0.3s;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.main-content.expanded {
|
||||
margin-left: var(--sidebar-collapsed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<nav class="navbar navbar-dark bg-dark navbar-expand-lg sticky-top">
|
||||
<div class="container-fluid">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="navbar-brand text-decoration-none">🎛️ AccountForger - Admin Panel</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<!-- Navigation removed - access via sidebar -->
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div id="session-timer" class="timer-normal me-3">
|
||||
⏱️ <span id="timer-display">5:00</span>
|
||||
</div>
|
||||
<span class="text-white me-3">Angemeldet als: {{ username }}</span>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-outline-light btn-sm me-2">👤 Profil</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<ul class="sidebar-nav">
|
||||
<li class="nav-item {% if request.endpoint in ['customers.customers', 'customers.customers_licenses', 'customers.edit_customer', 'customers.create_customer', 'licenses.edit_license', 'licenses.create_license', 'batch.batch_create'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'customers.customers_licenses' %}active{% endif %}" href="{{ url_for('customers.customers_licenses') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Kunden & Lizenzen</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'customers.customers' %}active{% endif %}" href="{{ url_for('customers.customers') }}">
|
||||
<i class="bi bi-list"></i>
|
||||
<span>Alle Kunden</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'licenses.licenses' %}active{% endif %}" href="{{ url_for('licenses.licenses') }}">
|
||||
<i class="bi bi-card-list"></i>
|
||||
<span>Alle Lizenzen</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'customers.create_customer' %}active{% endif %}" href="{{ url_for('customers.create_customer') }}">
|
||||
<i class="bi bi-person-plus"></i>
|
||||
<span>Neuer Kunde</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'licenses.create_license' %}active{% endif %}" href="{{ url_for('licenses.create_license') }}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
<span>Neue Lizenz</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'batch.batch_create' %}active{% endif %}" href="{{ url_for('batch.batch_create') }}">
|
||||
<i class="bi bi-stack"></i>
|
||||
<span>Batch-Erstellung</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('leads.') %}active{% endif %}" href="{{ url_for('leads.lead_management') }}">
|
||||
<i class="bi bi-people"></i>
|
||||
<span>Lead Management</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['resources.resources', 'resources.add_resources'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu {% if request.endpoint == 'resources.resources' %}active{% endif %}" href="{{ url_for('resources.resources') }}">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
<span>Ressourcen Pool</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'resources.add_resources' %}active{% endif %}" href="{{ url_for('resources.add_resources') }}">
|
||||
<i class="bi bi-plus-square"></i>
|
||||
<span>Ressourcen hinzufügen</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['monitoring.unified_monitoring', 'monitoring.live_dashboard', 'monitoring.alerts'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link {% if request.endpoint == 'monitoring.unified_monitoring' %}active{% endif %}" href="{{ url_for('monitoring.unified_monitoring') }}">
|
||||
<i class="bi bi-activity"></i>
|
||||
<span>Monitoring</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['admin.audit_log', 'admin.backups', 'admin.blocked_ips', 'admin.license_config'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu" href="{{ url_for('admin.license_config') }}">
|
||||
<i class="bi bi-tools"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Audit-Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
<span>Backups</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.blocked_ips' %}active{% endif %}" href="{{ url_for('admin.blocked_ips') }}">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
<span>Gesperrte IPs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-content" id="main-content">
|
||||
<!-- Page Content -->
|
||||
<div class="container-fluid p-4">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Warning Modal -->
|
||||
<div id="session-warning" class="session-warning-modal" style="display: none;">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<strong>⚠️ Session läuft ab!</strong><br>
|
||||
Ihre Session läuft in weniger als 1 Minute ab.<br>
|
||||
<button type="button" class="btn btn-sm btn-success mt-2" onclick="extendSession()">
|
||||
Session verlängern
|
||||
</button>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Session-Timer Konfiguration
|
||||
const SESSION_TIMEOUT = 5 * 60; // 5 Minuten in Sekunden
|
||||
let timeRemaining = SESSION_TIMEOUT;
|
||||
let timerInterval;
|
||||
let warningShown = false;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
// Timer Display Update
|
||||
function updateTimerDisplay() {
|
||||
const minutes = Math.floor(timeRemaining / 60);
|
||||
const seconds = timeRemaining % 60;
|
||||
const display = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
document.getElementById('timer-display').textContent = display;
|
||||
|
||||
// Timer-Farbe ändern
|
||||
const timerElement = document.getElementById('session-timer');
|
||||
timerElement.className = timerElement.className.replace(/timer-\w+/, '');
|
||||
|
||||
if (timeRemaining <= 30) {
|
||||
timerElement.classList.add('timer-critical');
|
||||
} else if (timeRemaining <= 60) {
|
||||
timerElement.classList.add('timer-danger');
|
||||
if (!warningShown) {
|
||||
showSessionWarning();
|
||||
warningShown = true;
|
||||
}
|
||||
} else if (timeRemaining <= 120) {
|
||||
timerElement.classList.add('timer-warning');
|
||||
} else {
|
||||
timerElement.classList.add('timer-normal');
|
||||
warningShown = false;
|
||||
hideSessionWarning();
|
||||
}
|
||||
}
|
||||
|
||||
// Session Warning anzeigen
|
||||
function showSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'block';
|
||||
}
|
||||
|
||||
// Session Warning verstecken
|
||||
function hideSessionWarning() {
|
||||
document.getElementById('session-warning').style.display = 'none';
|
||||
}
|
||||
|
||||
// Timer zurücksetzen
|
||||
function resetTimer() {
|
||||
timeRemaining = SESSION_TIMEOUT;
|
||||
lastActivity = Date.now();
|
||||
updateTimerDisplay();
|
||||
}
|
||||
|
||||
// Session verlängern
|
||||
function extendSession() {
|
||||
fetch('{{ url_for('auth.heartbeat') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
resetTimer();
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Heartbeat error:', error));
|
||||
}
|
||||
|
||||
// Timer Countdown
|
||||
function countdown() {
|
||||
timeRemaining--;
|
||||
updateTimerDisplay();
|
||||
|
||||
if (timeRemaining <= 0) {
|
||||
clearInterval(timerInterval);
|
||||
window.location.href = '{{ url_for('auth.logout') }}';
|
||||
}
|
||||
}
|
||||
|
||||
// Aktivitäts-Tracking
|
||||
function trackActivity() {
|
||||
const now = Date.now();
|
||||
// Nur wenn mehr als 5 Sekunden seit letzter Aktivität
|
||||
if (now - lastActivity > 5000) {
|
||||
lastActivity = now;
|
||||
extendSession(); // resetTimer() wird in extendSession nach erfolgreicher Response aufgerufen
|
||||
}
|
||||
}
|
||||
|
||||
// Event Listeners für Benutzeraktivität
|
||||
document.addEventListener('click', trackActivity);
|
||||
document.addEventListener('keypress', trackActivity);
|
||||
document.addEventListener('mousemove', () => {
|
||||
const now = Date.now();
|
||||
// Mausbewegung nur alle 30 Sekunden tracken
|
||||
if (now - lastActivity > 30000) {
|
||||
trackActivity();
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX Interceptor für automatische Session-Verlängerung
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = function(...args) {
|
||||
// Nur für non-heartbeat requests den Timer verlängern
|
||||
if (!args[0].includes('/heartbeat')) {
|
||||
trackActivity();
|
||||
}
|
||||
return originalFetch.apply(this, args);
|
||||
};
|
||||
|
||||
// Timer starten
|
||||
timerInterval = setInterval(countdown, 1000);
|
||||
updateTimerDisplay();
|
||||
|
||||
// Initial Heartbeat
|
||||
extendSession();
|
||||
|
||||
// Preserve show_test parameter across navigation
|
||||
function preserveShowTestParameter() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const showTest = urlParams.get('show_test');
|
||||
|
||||
if (showTest === 'true') {
|
||||
// Update all internal links to include show_test parameter
|
||||
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
// Skip if already has parameters or is just a fragment
|
||||
if (!href.includes('?') && !href.startsWith('#')) {
|
||||
link.setAttribute('href', href + '?show_test=true');
|
||||
} else if (href.includes('?') && !href.includes('show_test=')) {
|
||||
link.setAttribute('href', href + '&show_test=true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side table sorting
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Preserve show_test parameter on page load
|
||||
preserveShowTestParameter();
|
||||
|
||||
// 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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
514
v2_adminpanel/templates/batch_form.html
Normale Datei
514
v2_adminpanel/templates/batch_form.html
Normale Datei
@ -0,0 +1,514 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen erstellen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>🔑 Batch-Lizenzen erstellen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>ℹ️ Batch-Generierung:</strong> Erstellen Sie mehrere Lizenzen auf einmal für einen Kunden.
|
||||
Die Lizenzen werden automatisch generiert und können anschließend als CSV exportiert werden.
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Customer Type Indicator -->
|
||||
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('batch.batch_create') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name"
|
||||
placeholder="Firma GmbH" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kontakt@firma.de" accept-charset="UTF-8">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="quantity" class="form-label">Anzahl Lizenzen</label>
|
||||
<input type="number" class="form-control" id="quantity" name="quantity"
|
||||
min="1" max="100" value="10" required>
|
||||
<div class="form-text">Max. 100 Lizenzen pro Batch</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Gültig ab</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung pro Lizenz
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="domainsNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="ipv4Needed" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
| Benötigt: <span id="phoneNeeded" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Batch-Ressourcen:</strong> Jede Lizenz erhält die angegebene Anzahl an Ressourcen.
|
||||
Bei 10 Lizenzen mit je 2 Domains werden insgesamt 20 Domains benötigt.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit pro Lizenz
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte pro Lizenz
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Jede generierte Lizenz kann auf maximal dieser Anzahl von Geräten gleichzeitig aktiviert werden.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
🔑 Batch generieren
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewKeys()">
|
||||
👁️ Vorschau
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Vorschau der generierten Keys</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Es werden <strong id="previewQuantity">10</strong> Lizenzen im folgenden Format generiert:</p>
|
||||
<div class="bg-light p-3 rounded font-monospace" id="previewFormat">
|
||||
AF-F-YYYYMM-XXXX-YYYY-ZZZZ
|
||||
</div>
|
||||
<p class="mt-3 mb-0">Die Keys werden automatisch eindeutig generiert und in der Datenbank gespeichert.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Initialize customer is_fake map
|
||||
window.customerIsFakeMap = {};
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '/api/customers',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// Store is_fake status for each customer
|
||||
const results = data.results || [];
|
||||
results.forEach(customer => {
|
||||
if (customer.id !== 'new') {
|
||||
window.customerIsFakeMap[customer.id] = customer.is_fake || false;
|
||||
}
|
||||
});
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
|
||||
// Zeige Indikator für neuen Kunden
|
||||
showCustomerTypeIndicator('new');
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
|
||||
// Zeige Indikator basierend auf Kundendaten
|
||||
if (e.params.data.is_fake !== undefined) {
|
||||
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
|
||||
}
|
||||
}
|
||||
|
||||
// Update resource availability check when customer changes
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
hideCustomerTypeIndicator();
|
||||
window.customerIsFakeMap = {};
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count und Quantity Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('quantity').addEventListener('input', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Vorschau-Funktion
|
||||
function previewKeys() {
|
||||
const quantity = document.getElementById('quantity').value;
|
||||
const type = document.getElementById('licenseType').value;
|
||||
const typeChar = type === 'full' ? 'F' : 'T';
|
||||
const date = new Date();
|
||||
const dateStr = date.getFullYear() + ('0' + (date.getMonth() + 1)).slice(-2);
|
||||
|
||||
document.getElementById('previewQuantity').textContent = quantity;
|
||||
document.getElementById('previewFormat').textContent = `AF-${typeChar}-${dateStr}-XXXX-YYYY-ZZZZ`;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Validierung
|
||||
document.getElementById('quantity').addEventListener('input', function(e) {
|
||||
if (e.target.value > 100) {
|
||||
e.target.value = 100;
|
||||
}
|
||||
if (e.target.value < 1) {
|
||||
e.target.value = 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit für Batch
|
||||
function checkResourceAvailability() {
|
||||
const quantity = parseInt(document.getElementById('quantity').value) || 1;
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// Berechne Gesamtbedarf
|
||||
const totalDomains = domainCount * quantity;
|
||||
const totalIpv4 = ipv4Count * quantity;
|
||||
const totalPhones = phoneCount * quantity;
|
||||
|
||||
// Update "Benötigt" Anzeigen
|
||||
document.getElementById('domainsNeeded').textContent = totalDomains;
|
||||
document.getElementById('ipv4Needed').textContent = totalIpv4;
|
||||
document.getElementById('phoneNeeded').textContent = totalPhones;
|
||||
|
||||
// Get customer's is_fake status
|
||||
const selectedCustomer = document.getElementById('customer_id');
|
||||
let isFake = 'false';
|
||||
if (selectedCustomer && selectedCustomer.value && window.customerIsFakeMap) {
|
||||
isFake = window.customerIsFakeMap[selectedCustomer.value] ? 'true' : 'false';
|
||||
}
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`/api/resources/check-availability?domain=${totalDomains}&ipv4=${totalIpv4}&phone=${totalPhones}&is_fake=${isFake}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, totalDomains);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, totalIpv4);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, totalPhones);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.textContent = available;
|
||||
|
||||
const neededElement = element.parentElement.querySelector('.fw-bold:last-child');
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
neededElement.classList.add('text-danger');
|
||||
neededElement.classList.remove('text-success');
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
neededElement.classList.remove('text-danger');
|
||||
neededElement.classList.add('text-success');
|
||||
}
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit für Batch
|
||||
function updateBatchResourceStatus(data, totalDomains, totalIpv4, totalPhones, quantity) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (totalDomains > 0 && data.domain_available < totalDomains) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Domains (${data.domain_available}/${totalDomains})`;
|
||||
} else if (totalIpv4 > 0 && data.ipv4_available < totalIpv4) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend IPv4-Adressen (${data.ipv4_available}/${totalIpv4})`;
|
||||
} else if (totalPhones > 0 && data.phone_available < totalPhones) {
|
||||
hasIssue = true;
|
||||
message = `⚠️ Nicht genügend Telefonnummern (${data.phone_available}/${totalPhones})`;
|
||||
} else {
|
||||
message = `✅ Ressourcen für ${quantity} Lizenzen verfügbar`;
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
|
||||
// Disable submit button if not enough resources
|
||||
const submitButton = document.querySelector('button[type="submit"]');
|
||||
submitButton.disabled = hasIssue;
|
||||
if (hasIssue) {
|
||||
submitButton.classList.add('btn-secondary');
|
||||
submitButton.classList.remove('btn-primary');
|
||||
} else {
|
||||
submitButton.classList.add('btn-primary');
|
||||
submitButton.classList.remove('btn-secondary');
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion zur Anzeige des Kundentyp-Indikators
|
||||
function showCustomerTypeIndicator(type) {
|
||||
const indicator = document.getElementById('customerTypeIndicator');
|
||||
const message = document.getElementById('customerTypeMessage');
|
||||
|
||||
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
|
||||
|
||||
if (type === 'new') {
|
||||
indicator.classList.add('alert-info');
|
||||
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert.';
|
||||
} else if (type === 'fake') {
|
||||
indicator.classList.add('alert-warning');
|
||||
message.textContent = 'Dies ist ein TEST-Kunde. Alle Batch-Lizenzen werden automatisch als TEST-Lizenzen markiert und von der Software ignoriert.';
|
||||
} else if (type === 'real') {
|
||||
indicator.classList.add('alert-success');
|
||||
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Alle Batch-Lizenzen werden als produktive Lizenzen erstellt.';
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomerTypeIndicator() {
|
||||
document.getElementById('customerTypeIndicator').classList.add('d-none');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
156
v2_adminpanel/templates/batch_result.html
Normale Datei
156
v2_adminpanel/templates/batch_result.html
Normale Datei
@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Batch-Lizenzen generiert{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>✅ Batch-Lizenzen erfolgreich generiert</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('batch.batch_create') }}" class="btn btn-primary">🔑 Weitere Batch erstellen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success">
|
||||
<h5 class="alert-heading">🎉 {{ licenses|length }} Lizenzen wurden erfolgreich generiert!</h5>
|
||||
<p class="mb-0">Die Lizenzen wurden in der Datenbank gespeichert und dem Kunden zugeordnet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Kunden-Info -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📋 Kundeninformationen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Kunde:</strong> {{ customer }}</p>
|
||||
<p><strong>E-Mail:</strong> {{ email or 'Nicht angegeben' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p><strong>Gültig von:</strong> {{ valid_from }}</p>
|
||||
<p><strong>Gültig bis:</strong> {{ valid_until }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export-Optionen -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📥 Export-Optionen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Exportieren Sie die generierten Lizenzen für den Kunden:</p>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('batch.export_batch') }}" class="btn btn-success">
|
||||
📄 Als CSV exportieren
|
||||
</a>
|
||||
<button class="btn btn-outline-primary" onclick="copyAllKeys()">
|
||||
📋 Alle Keys kopieren
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="window.print()">
|
||||
🖨️ Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generierte Lizenzen -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🔑 Generierte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="50">#</th>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th width="120">Typ</th>
|
||||
<th width="100">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td class="font-monospace">{{ license.key }}</td>
|
||||
<td>
|
||||
{% if license.type == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyKey('{{ license.key }}')"
|
||||
title="Kopieren">
|
||||
📋
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hinweis -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<strong>💡 Tipp:</strong> Die generierten Lizenzen sind sofort aktiv und können verwendet werden.
|
||||
Sie finden alle Lizenzen auch in der <a href="{{ url_for('licenses.licenses', search=customer) }}">Lizenzübersicht</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea für Kopieren (unsichtbar) -->
|
||||
<textarea id="copyArea" style="position: absolute; left: -9999px;"></textarea>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.btn, .alert-info, .card-header h5 { display: none !important; }
|
||||
.card { border: 1px solid #000 !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Einzelnen Key kopieren
|
||||
function copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(function() {
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Alle Keys kopieren
|
||||
function copyAllKeys() {
|
||||
const keys = [];
|
||||
{% for license in licenses %}
|
||||
keys.push('{{ license.key }}');
|
||||
{% endfor %}
|
||||
|
||||
const text = keys.join('\n');
|
||||
const textarea = document.getElementById('copyArea');
|
||||
textarea.value = text;
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
// Visuelles Feedback
|
||||
event.target.textContent = '✓ Kopiert!';
|
||||
setTimeout(() => {
|
||||
event.target.textContent = '📋 Alle Keys kopieren';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// Fallback für moderne Browser
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
98
v2_adminpanel/templates/blocked_ips.html
Normale Datei
98
v2_adminpanel/templates/blocked_ips.html
Normale Datei
@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Gesperrte IPs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔒 Gesperrte IPs</h1>
|
||||
<div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">IP-Sperrverwaltung</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if blocked_ips %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<tbody>
|
||||
{% for ip in blocked_ips %}
|
||||
<tr class="{% if ip.is_active %}table-danger{% else %}table-secondary{% endif %}">
|
||||
<td><code>{{ ip.ip_address }}</code></td>
|
||||
<td><span class="badge bg-danger">{{ ip.attempt_count }}</span></td>
|
||||
<td>{{ ip.first_attempt }}</td>
|
||||
<td>{{ ip.last_attempt }}</td>
|
||||
<td>{{ ip.blocked_until }}</td>
|
||||
<td>{{ ip.last_username or '-' }}</td>
|
||||
<td><strong>{{ ip.last_error or '-' }}</strong></td>
|
||||
<td>
|
||||
{% if ip.is_active %}
|
||||
<span class="badge bg-danger">GESPERRT</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">ABGELAUFEN</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if ip.is_active %}
|
||||
<form method="post" action="{{ url_for('admin.unblock_ip') }}" class="d-inline">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('IP {{ ip.ip_address }} wirklich entsperren?')">
|
||||
🔓 Entsperren
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('admin.clear_attempts') }}" class="d-inline ms-1">
|
||||
<input type="hidden" name="ip_address" value="{{ ip.ip_address }}">
|
||||
<button type="submit" class="btn btn-warning"
|
||||
onclick="return confirm('Alle Versuche für IP {{ ip.ip_address }} zurücksetzen?')">
|
||||
🗑️ Reset
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Keine gesperrten IPs vorhanden.</strong>
|
||||
Das System läuft ohne Sicherheitsvorfälle.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">ℹ️ Informationen</h5>
|
||||
<ul class="mb-0">
|
||||
<li>IPs werden nach <strong>{{ 5 }} fehlgeschlagenen Login-Versuchen</strong> für <strong>24 Stunden</strong> gesperrt.</li>
|
||||
<li>Nach <strong>2 Versuchen</strong> wird ein CAPTCHA angezeigt.</li>
|
||||
<li>Bei <strong>5 Versuchen</strong> wird eine E-Mail-Benachrichtigung gesendet (wenn aktiviert).</li>
|
||||
<li>Gesperrte IPs können manuell entsperrt werden.</li>
|
||||
<li>Die Fehlermeldungen werden zufällig ausgewählt für zusätzliche Verwirrung.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
71
v2_adminpanel/templates/create_customer.html
Normale Datei
71
v2_adminpanel/templates/create_customer.html
Normale Datei
@ -0,0 +1,71 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Neuer Kunde{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>👤 Neuer Kunde anlegen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('customers.create_customer') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
placeholder="Firmenname oder Vor- und Nachname"
|
||||
accept-charset="UTF-8" required autofocus>
|
||||
<div class="form-text">Der Name des Kunden oder der Firma</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail <span class="text-danger">*</span></label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
placeholder="kunde@beispiel.de"
|
||||
accept-charset="UTF-8" required>
|
||||
<div class="form-text">Kontakt-E-Mail-Adresse des Kunden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isFake" name="is_fake">
|
||||
<label class="form-check-label" for="isFake">
|
||||
<i class="fas fa-flask"></i> Als Fake-Daten markieren
|
||||
<small class="text-muted">(Kunde wird von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-4" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Hinweis:</strong> Nach dem Anlegen des Kunden können Sie direkt Lizenzen für diesen Kunden erstellen.
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Kunde anlegen</button>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
185
v2_adminpanel/templates/customers.html
Normale Datei
185
v2_adminpanel/templates/customers.html
Normale Datei
@ -0,0 +1,185 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kundenverwaltung{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% if current_sort == field %}
|
||||
<a href="{{ url_for('customers.customers', sort=field, order='desc' if current_order == 'asc' else 'asc', search=search, show_fake=show_fake, page=1) }}"
|
||||
class="server-sortable">
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers.customers', sort=field, order='asc', search=search, show_fake=show_fake, 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="mb-4">
|
||||
<h2>Kundenverwaltung</h2>
|
||||
</div>
|
||||
|
||||
<!-- Suchformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('customers.customers') }}" id="customerSearchForm" class="row g-3 align-items-end">
|
||||
<div class="col-md-8">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Kundenname oder E-Mail..."
|
||||
value="{{ search }}" autofocus>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check mt-4">
|
||||
<input class="form-check-input" type="checkbox" id="show_fake" name="show_fake" value="true"
|
||||
{% if show_fake %}checked{% endif %} onchange="this.form.submit()">
|
||||
<label class="form-check-label" for="show_fake">
|
||||
🧪 Fake-Daten anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-outline-secondary w-100">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
{% if search %}
|
||||
<div class="mt-2">
|
||||
<small class="text-muted">Suchergebnisse für: <strong>{{ search }}</strong></small>
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-sm btn-outline-secondary ms-2">✖ Suche zurücksetzen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
{{ 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>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>{{ customer.id }}</td>
|
||||
<td>
|
||||
{{ customer.name }}
|
||||
{% if customer.is_test %}
|
||||
<span class="badge bg-secondary ms-1" title="Testdaten">🧪</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ customer.email or '-' }}</td>
|
||||
<td>{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ customer.active_licenses }}/{{ customer.license_count }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||
{% if customer.license_count == 0 %}
|
||||
<form method="post" action="{{ url_for('customers.delete_customer', customer_id=customer.id) }}" style="display: inline;" onsubmit="return confirm('Kunde wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button class="btn btn-outline-danger" disabled title="Kunde hat Lizenzen">🗑️ Löschen</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not customers %}
|
||||
<div class="text-center py-5">
|
||||
{% if search %}
|
||||
<p class="text-muted">Keine Kunden gefunden für: <strong>{{ search }}</strong></p>
|
||||
<a href="{{ url_for('customers.customers') }}" class="btn btn-secondary">Alle Kunden anzeigen</a>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Kunden vorhanden.</p>
|
||||
<a href="{{ url_for('licenses.create_license') }}" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<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.customers', page=1, search=search, sort=sort, order=order, show_fake=show_fake) }}">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=page-1, search=search, sort=sort, order=order, show_fake=show_fake) }}">←</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.customers', page=p, search=search, sort=sort, order=order, show_fake=show_fake) }}">{{ 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.customers', page=page+1, search=search, sort=sort, order=order, show_fake=show_fake) }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('customers.customers', page=total_pages, search=search, sort=sort, order=order, show_fake=show_fake) }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Kunden
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Live Search für Kunden
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchForm = document.getElementById('customerSearchForm');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
// Debounce timer für Suchfeld
|
||||
let searchTimeout;
|
||||
|
||||
// Live-Suche mit 300ms Verzögerung
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchForm.submit();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
1153
v2_adminpanel/templates/customers_licenses.html
Normale Datei
1153
v2_adminpanel/templates/customers_licenses.html
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
477
v2_adminpanel/templates/dashboard.html
Normale Datei
477
v2_adminpanel/templates/dashboard.html
Normale Datei
@ -0,0 +1,477 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
a:hover .stat-card {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.text-decoration-none:hover {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* Session pulse effect */
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.8; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.pulse-effect {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Progress bar styles */
|
||||
.progress-custom {
|
||||
height: 8px;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
background-color: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<h1 class="mb-4">Dashboard</h1>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-primary">👥</div>
|
||||
<div class="card-value text-primary">{{ stats.total_customers }}</div>
|
||||
<div class="card-label text-muted">Kunden Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-info">📋</div>
|
||||
<div class="card-value text-info">{{ stats.total_licenses }}</div>
|
||||
<div class="card-label text-muted">Lizenzen Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon text-success{% if stats.active_usage > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||
<div class="card-value text-success">{{ stats.active_usage }}</div>
|
||||
<div class="card-label text-muted">Aktive Nutzung</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lizenztypen -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenztypen</h5>
|
||||
<div class="row">
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-success">{{ stats.full_licenses }}</h3>
|
||||
<p class="text-muted">Vollversionen</p>
|
||||
</div>
|
||||
<div class="col-6 text-center">
|
||||
<h3 class="text-warning">{{ stats.fake_licenses }}</h3>
|
||||
<p class="text-muted">Testversionen</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if stats.fake_data_count > 0 or stats.fake_customers_count > 0 or stats.fake_resources_count > 0 %}
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<small>
|
||||
<i class="fas fa-flask"></i> Fake-Daten:
|
||||
{{ stats.fake_data_count }} Lizenzen,
|
||||
{{ stats.fake_customers_count }} Kunden,
|
||||
{{ stats.fake_resources_count }} Ressourcen
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Lizenzstatus</h5>
|
||||
<div class="row">
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-success">{{ stats.active_licenses }}</h3>
|
||||
<p class="text-muted">Aktiv</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-danger">{{ stats.expired_licenses }}</h3>
|
||||
<p class="text-muted">Abgelaufen</p>
|
||||
</div>
|
||||
<div class="col-4 text-center">
|
||||
<h3 class="text-secondary">{{ stats.inactive_licenses }}</h3>
|
||||
<p class="text-muted">Deaktiviert</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Health Status -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-heartbeat"></i> Service Status
|
||||
{% if service_health.overall_status == 'healthy' %}
|
||||
<span class="badge bg-success float-end">Alle Systeme betriebsbereit</span>
|
||||
{% elif service_health.overall_status == 'partial' %}
|
||||
<span class="badge bg-warning float-end">Teilweise Störungen</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger float-end">Kritische Störungen</span>
|
||||
{% endif %}
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for service in service_health.services %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="d-flex align-items-center p-3 border rounded"
|
||||
style="border-left: 4px solid {% if service.status == 'healthy' %}#28a745{% elif service.status == 'unhealthy' %}#ffc107{% else %}#dc3545{% endif %} !important;">
|
||||
<div class="me-3 fs-2">{{ service.icon }}</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">{{ service.name }}</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span class="badge bg-{% if service.status == 'healthy' %}success{% elif service.status == 'unhealthy' %}warning{% else %}danger{% endif %}">
|
||||
{% if service.status == 'healthy' %}Betriebsbereit{% elif service.status == 'unhealthy' %}Eingeschränkt{% else %}Ausgefallen{% endif %}
|
||||
</span>
|
||||
{% if service.response_time %}
|
||||
<small class="text-muted">{{ service.response_time }}ms</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if service.details %}
|
||||
<small class="text-muted">{{ service.details }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup-Status und Sicherheit nebeneinander -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<a href="{{ url_for('admin.backups') }}" class="text-decoration-none">
|
||||
<div class="card stat-card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">💾 Backup-Status</h5>
|
||||
{% if stats.last_backup %}
|
||||
{% if stats.last_backup[4] == 'success' %}
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<span class="text-success me-2">✅</span>
|
||||
<small>{{ stats.last_backup[0].strftime('%d.%m.%Y %H:%M') }}</small>
|
||||
</div>
|
||||
<div class="progress-custom">
|
||||
<div class="progress-bar-custom" style="width: 100%;"></div>
|
||||
</div>
|
||||
<small class="text-muted mt-1 d-block">
|
||||
{{ (stats.last_backup[1] / 1024 / 1024)|round(1) }} MB • {{ stats.last_backup[2]|round(0)|int }}s
|
||||
</small>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-danger me-2">❌</span>
|
||||
<small>Backup fehlgeschlagen</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch kein Backup vorhanden</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsstatus -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">🔒 Sicherheitsstatus</h5>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span>Sicherheitslevel:</span>
|
||||
<span class="badge bg-{{ stats.security_level }} fs-6">{{ stats.security_level_text }}</span>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-6">
|
||||
<h4 class="text-danger mb-0">{{ stats.blocked_ips_count }}</h4>
|
||||
<small class="text-muted">Gesperrte IPs</small>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h4 class="text-warning mb-0">{{ stats.failed_attempts_today }}</h4>
|
||||
<small class="text-muted">Fehlversuche heute</small>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.blocked_ips') }}" class="btn btn-sm btn-outline-danger mt-3">IP-Verwaltung →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sicherheitsereignisse -->
|
||||
{% if stats.recent_security_events %}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h6 class="mb-0">🚨 Letzte Sicherheitsereignisse</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0 sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
{% for event in stats.recent_security_events %}
|
||||
<tr>
|
||||
<td>{{ event.last_attempt }}</td>
|
||||
<td><code>{{ event.ip_address }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ event.attempt_count }}</span></td>
|
||||
<td><strong class="text-danger">{{ event.error_message }}</strong></td>
|
||||
<td>
|
||||
{% if event.blocked_until %}
|
||||
<span class="badge bg-danger">Gesperrt bis {{ event.blocked_until }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Resource Pool Status -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Resource Pool Status
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% if resource_stats %}
|
||||
{% for type, data in resource_stats.items() %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none">
|
||||
<i class="fas fa-{{ 'globe' if type == 'domain' else ('network-wired' if type == 'ipv4' else 'phone') }} fa-2x text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ url_for('resources.resources', type=type, show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-dark">{{ type|upper }}</a>
|
||||
</h6>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong class="text-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">{{ data.available }}</strong> / {{ data.total }} verfügbar
|
||||
</span>
|
||||
<span class="badge bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}">
|
||||
{{ data.available_percent }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress mt-1" style="height: 8px;">
|
||||
<div class="progress-bar bg-{{ 'success' if data.available_percent > 50 else ('warning' if data.available_percent > 20 else 'danger') }}"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} von {{ data.total }} verfügbar"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">
|
||||
<a href="{{ url_for('resources.resources', type=type, status='allocated', show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ data.allocated }} zugeteilt
|
||||
</a>
|
||||
</small>
|
||||
{% if data.quarantine > 0 %}
|
||||
<small>
|
||||
<a href="{{ url_for('resources.resources', type=type, status='quarantine', show_test=request.args.get('show_test')) }}"
|
||||
class="text-decoration-none text-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i> {{ data.quarantine }} in Quarantäne
|
||||
</a>
|
||||
</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12 text-center text-muted">
|
||||
<i class="fas fa-inbox fa-3x mb-3"></i>
|
||||
<p>Keine Ressourcen im Pool vorhanden.</p>
|
||||
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Ressourcen hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if resource_warning %}
|
||||
<div class="alert alert-danger mt-3 mb-0 d-flex justify-content-between align-items-center" role="alert">
|
||||
<div>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Kritisch:</strong> {{ resource_warning }}
|
||||
</div>
|
||||
<a href="{{ url_for('resources.add_resources', show_test=request.args.get('show_test')) }}"
|
||||
class="btn btn-sm btn-danger">
|
||||
<i class="bi bi-plus"></i> Ressourcen auffüllen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Bald ablaufende Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0">⏰ Bald ablaufende Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.expiring_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable" data-type="numeric">Tage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.expiring_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td><span class="badge bg-warning">{{ license[4] }} Tage</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine Lizenzen laufen in den nächsten 30 Tagen ab.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Lizenzen -->
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">🆕 Zuletzt erstellte Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.recent_licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm sortable-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable">Kunde</th>
|
||||
<th class="sortable">Lizenz</th>
|
||||
<th class="sortable">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in stats.recent_licenses %}
|
||||
<tr>
|
||||
<td>{{ license[2] }}</td>
|
||||
<td><small><code>{{ license[1][:8] }}...</code></small></td>
|
||||
<td>
|
||||
{% if license[4] == 'deaktiviert' %}
|
||||
<span class="status-deaktiviert">🚫 Deaktiviert</span>
|
||||
{% elif license[4] == 'abgelaufen' %}
|
||||
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||
{% elif license[4] == 'läuft bald ab' %}
|
||||
<span class="status-ablaufend">⏰ Läuft bald ab</span>
|
||||
{% else %}
|
||||
<span class="status-aktiv">✅ Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Noch keine Lizenzen erstellt.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
103
v2_adminpanel/templates/edit_customer.html
Normale Datei
103
v2_adminpanel/templates/edit_customer.html
Normale Datei
@ -0,0 +1,103 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kunde bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Kunde bearbeiten</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">👥 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('customers.edit_customer', customer_id=customer.id) }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_fake') == 'true' %}
|
||||
<input type="hidden" name="show_fake" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="name" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ customer.name }}" accept-charset="UTF-8" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ customer.email or '' }}" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label text-muted">Erstellt am</label>
|
||||
<p class="form-control-plaintext">{{ customer.created_at.strftime('%d.%m.%Y %H:%M') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check mt-3">
|
||||
<input class="form-check-input" type="checkbox" id="isTest" name="is_fake" {% if customer.is_fake %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isTest">
|
||||
<i class="fas fa-flask"></i> Als Fake-Daten markieren
|
||||
<small class="text-muted">(Kunde und seine Lizenzen werden von der Software ignoriert)</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Lizenzen des Kunden</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if licenses %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Typ</th>
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Aktiv</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td><code>{{ license[1] }}</code></td>
|
||||
<td>
|
||||
{% if license[2] == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license[3].strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ license[4].strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
{% if license[5] %}
|
||||
<span class="text-success">✓</span>
|
||||
{% else %}
|
||||
<span class="text-danger">✗</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=license[0]) }}" class="btn btn-outline-primary btn-sm">Bearbeiten</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Dieser Kunde hat noch keine Lizenzen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
88
v2_adminpanel/templates/edit_license.html
Normale Datei
88
v2_adminpanel/templates/edit_license.html
Normale Datei
@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenz bearbeiten{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Lizenz bearbeiten</h2>
|
||||
<div>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">📋 Zurück zur Übersicht</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('licenses.edit_license', license_id=license.id) }}" accept-charset="UTF-8">
|
||||
{% if request.args.get('show_fake') == 'true' %}
|
||||
<input type="hidden" name="show_fake" value="true">
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Kunde</label>
|
||||
<input type="text" class="form-control" value="{{ license.customer_name }}" disabled>
|
||||
<small class="text-muted">Kunde kann nicht geändert werden</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" value="-" disabled>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key" value="{{ license.license_key }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full" {% if license.license_type == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if license.license_type == 'test' %}selected{% endif %}>Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validFrom" class="form-label">Gültig von</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" value="{{ license.valid_from.strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="validUntil" class="form-label">Gültig bis</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" value="{{ license.valid_until.strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isActive" name="is_active" {% if license.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="isActive">
|
||||
Lizenz ist aktiv
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">Gerätelimit</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if license.get('device_limit', 3) == i %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">Maximale Anzahl gleichzeitig aktiver Geräte</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert {% if license.is_fake %}alert-warning{% else %}alert-success{% endif %} mt-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
<strong>Status:</strong>
|
||||
{% if license.is_fake %}
|
||||
TEST-Lizenz (wird von der Software ignoriert)
|
||||
{% else %}
|
||||
PRODUKTIV-Lizenz
|
||||
{% endif %}
|
||||
<br>
|
||||
<small class="text-muted">Der Status wird vom Kunden geerbt und kann nicht direkt geändert werden.</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">💾 Änderungen speichern</button>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=request.args.get('show_fake')) }}" class="btn btn-secondary">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
v2_adminpanel/templates/error.html
Normale Datei
55
v2_adminpanel/templates/error.html
Normale Datei
@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Fehler{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||
Fehler
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-danger mb-4" role="alert">
|
||||
<h5 class="alert-heading">{{ error|default('Ein Fehler ist aufgetreten') }}</h5>
|
||||
{% if error_code %}
|
||||
<hr>
|
||||
<p class="mb-0">
|
||||
<strong>Fehlercode:</strong> {{ error_code }}<br>
|
||||
{% if request_id %}
|
||||
<strong>Anfrage-ID:</strong> <code>{{ request_id }}</code>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-light p-3 rounded mb-4">
|
||||
<h6 class="text-muted">Was können Sie tun?</h6>
|
||||
<ul class="mb-0">
|
||||
<li>Versuchen Sie die Aktion erneut</li>
|
||||
<li>Überprüfen Sie Ihre Eingaben</li>
|
||||
<li>Kontaktieren Sie den Support mit der Anfrage-ID</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">
|
||||
<i class="bi bi-house"></i> Zum Dashboard
|
||||
</a>
|
||||
<button onclick="window.history.back();" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
</button>
|
||||
<button onclick="location.reload();" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise"></i> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
578
v2_adminpanel/templates/index.html
Normale Datei
578
v2_adminpanel/templates/index.html
Normale Datei
@ -0,0 +1,578 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Neue Lizenz erstellen</h2>
|
||||
<a href="{{ url_for('customers.customers_licenses') }}" class="btn btn-secondary">← Zurück zur Übersicht</a>
|
||||
</div>
|
||||
|
||||
<!-- Customer Type Indicator -->
|
||||
<div id="customerTypeIndicator" class="alert d-none mb-3" role="alert">
|
||||
<i class="fas fa-info-circle"></i> <span id="customerTypeMessage"></span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('licenses.create_license') }}" accept-charset="UTF-8">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-12">
|
||||
<label for="customerSelect" class="form-label">Kunde auswählen</label>
|
||||
<select class="form-select" id="customerSelect" name="customer_id" required>
|
||||
<option value="">🔍 Kunde suchen oder neuen Kunden anlegen...</option>
|
||||
<option value="new">➕ Neuer Kunde</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6" id="customerNameDiv" style="display: none;">
|
||||
<label for="customerName" class="form-label">Kundenname</label>
|
||||
<input type="text" class="form-control" id="customerName" name="customer_name" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-6" id="emailDiv" style="display: none;">
|
||||
<label for="email" class="form-label">E-Mail</label>
|
||||
<input type="email" class="form-control" id="email" name="email" accept-charset="UTF-8">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseKey" class="form-label">Lizenzschlüssel</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="licenseKey" name="license_key"
|
||||
placeholder="AF-F-YYYYMM-XXXX-YYYY-ZZZZ" required
|
||||
pattern="AF-[FT]-\d{6}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}"
|
||||
title="Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="generateLicenseKey()">
|
||||
🔑 Generieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text">Format: AF-F-YYYYMM-XXXX-YYYY-ZZZZ (F=Full, T=Test)</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" id="licenseType" name="license_type" required>
|
||||
<option value="full">Vollversion</option>
|
||||
<option value="test">Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validFrom" class="form-label">Kaufdatum</label>
|
||||
<input type="date" class="form-control" id="validFrom" name="valid_from" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="duration" class="form-label">Laufzeit</label>
|
||||
<input type="number" class="form-control" id="duration" name="duration" value="1" min="1" required>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label for="durationType" class="form-label">Einheit</label>
|
||||
<select class="form-select" id="durationType" name="duration_type" required>
|
||||
<option value="days">Tage</option>
|
||||
<option value="months">Monate</option>
|
||||
<option value="years" selected>Jahre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="validUntil" class="form-label">Ablaufdatum</label>
|
||||
<input type="date" class="form-control" id="validUntil" name="valid_until" readonly style="background-color: #e9ecef;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resource Pool Allocation -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-server"></i> Ressourcen-Zuweisung
|
||||
<small class="text-muted float-end" id="resourceStatus"></small>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="domainCount" class="form-label">
|
||||
<i class="fas fa-globe"></i> Domains
|
||||
</label>
|
||||
<select class="form-select" id="domainCount" name="domain_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="domainsAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ipv4Count" class="form-label">
|
||||
<i class="fas fa-network-wired"></i> IPv4-Adressen
|
||||
</label>
|
||||
<select class="form-select" id="ipv4Count" name="ipv4_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="ipv4Available" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="phoneCount" class="form-label">
|
||||
<i class="fas fa-phone"></i> Telefonnummern
|
||||
</label>
|
||||
<select class="form-select" id="phoneCount" name="phone_count" required>
|
||||
{% for i in range(11) %}
|
||||
<option value="{{ i }}" {% if i == 1 %}selected{% endif %}>{{ i }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Verfügbar: <span id="phoneAvailable" class="fw-bold">-</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info mt-3 mb-0" role="alert">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Die Ressourcen werden bei der Lizenzerstellung automatisch aus dem Pool zugewiesen.
|
||||
Wählen Sie 0, wenn für diesen Typ keine Ressourcen benötigt werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Limit -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-laptop"></i> Gerätelimit
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label for="deviceLimit" class="form-label">
|
||||
Maximale Anzahl Geräte
|
||||
</label>
|
||||
<select class="form-select" id="deviceLimit" name="device_limit" required>
|
||||
{% for i in range(1, 11) %}
|
||||
<option value="{{ i }}" {% if i == 3 %}selected{% endif %}>{{ i }} {% if i == 1 %}Gerät{% else %}Geräte{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="form-text text-muted">
|
||||
Anzahl der Geräte, auf denen die Lizenz gleichzeitig aktiviert sein kann.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="btn btn-primary">➕ Lizenz erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 11">
|
||||
{% for category, message in messages %}
|
||||
<div class="toast show align-items-center text-white bg-{{ 'danger' if category == 'error' else 'success' }} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<script>
|
||||
// License Key Generator
|
||||
function generateLicenseKey() {
|
||||
const licenseType = document.getElementById('licenseType').value;
|
||||
|
||||
// Zeige Ladeindikator
|
||||
const button = event.target;
|
||||
const originalText = button.innerHTML;
|
||||
button.disabled = true;
|
||||
button.innerHTML = '⏳ Generiere...';
|
||||
|
||||
// API-Call
|
||||
fetch('{{ url_for('api.api_generate_key') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({type: licenseType})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('licenseKey').value = data.key;
|
||||
// Visuelles Feedback
|
||||
document.getElementById('licenseKey').classList.add('border-success');
|
||||
setTimeout(() => {
|
||||
document.getElementById('licenseKey').classList.remove('border-success');
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('Fehler bei der Key-Generierung: ' + (data.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler:', error);
|
||||
alert('Netzwerkfehler bei der Key-Generierung');
|
||||
})
|
||||
.finally(() => {
|
||||
// Button zurücksetzen
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
// Event Listener für Lizenztyp-Änderung
|
||||
document.getElementById('licenseType').addEventListener('change', function() {
|
||||
const keyField = document.getElementById('licenseKey');
|
||||
if (keyField.value && keyField.value.startsWith('AF-')) {
|
||||
// Prüfe ob der Key zum neuen Typ passt
|
||||
const currentType = this.value;
|
||||
const keyType = keyField.value.charAt(3); // Position des F/T im Key (AF-F-...)
|
||||
|
||||
if ((currentType === 'full' && keyType === 'T') ||
|
||||
(currentType === 'test' && keyType === 'F')) {
|
||||
if (confirm('Der aktuelle Key passt nicht zum gewählten Lizenztyp. Neuen Key generieren?')) {
|
||||
generateLicenseKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-Uppercase für License Key Input
|
||||
document.getElementById('licenseKey').addEventListener('input', function(e) {
|
||||
e.target.value = e.target.value.toUpperCase();
|
||||
});
|
||||
|
||||
// Funktion zur Berechnung des Ablaufdatums
|
||||
function calculateValidUntil() {
|
||||
const validFrom = document.getElementById('validFrom').value;
|
||||
const duration = parseInt(document.getElementById('duration').value) || 1;
|
||||
const durationType = document.getElementById('durationType').value;
|
||||
|
||||
if (!validFrom) return;
|
||||
|
||||
const startDate = new Date(validFrom);
|
||||
let endDate = new Date(startDate);
|
||||
|
||||
switch(durationType) {
|
||||
case 'days':
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
break;
|
||||
case 'months':
|
||||
endDate.setMonth(endDate.getMonth() + duration);
|
||||
break;
|
||||
case 'years':
|
||||
endDate.setFullYear(endDate.getFullYear() + duration);
|
||||
break;
|
||||
}
|
||||
|
||||
// Kein Tag abziehen - die Lizenz ist bis einschließlich des Enddatums gültig
|
||||
|
||||
document.getElementById('validUntil').value = endDate.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Event Listener für Änderungen
|
||||
document.getElementById('validFrom').addEventListener('change', calculateValidUntil);
|
||||
document.getElementById('duration').addEventListener('input', calculateValidUntil);
|
||||
document.getElementById('durationType').addEventListener('change', calculateValidUntil);
|
||||
|
||||
// Setze heutiges Datum als Standard für valid_from
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
document.getElementById('validFrom').value = today;
|
||||
|
||||
// Berechne initiales Ablaufdatum
|
||||
calculateValidUntil();
|
||||
|
||||
// Initialisiere Select2 für Kundenauswahl
|
||||
$('#customerSelect').select2({
|
||||
theme: 'bootstrap-5',
|
||||
placeholder: '🔍 Kunde suchen oder neuen Kunden anlegen...',
|
||||
allowClear: true,
|
||||
ajax: {
|
||||
url: '{{ url_for('api.api_customers') }}',
|
||||
dataType: 'json',
|
||||
delay: 250,
|
||||
data: function (params) {
|
||||
return {
|
||||
q: params.term,
|
||||
page: params.page || 1
|
||||
};
|
||||
},
|
||||
processResults: function (data, params) {
|
||||
params.page = params.page || 1;
|
||||
|
||||
// "Neuer Kunde" Option immer oben anzeigen
|
||||
const results = data.results || [];
|
||||
if (params.page === 1) {
|
||||
results.unshift({
|
||||
id: 'new',
|
||||
text: '➕ Neuer Kunde',
|
||||
isNew: true
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
results: results,
|
||||
pagination: data.pagination
|
||||
};
|
||||
},
|
||||
cache: true
|
||||
},
|
||||
minimumInputLength: 0,
|
||||
language: {
|
||||
inputTooShort: function() { return ''; },
|
||||
noResults: function() { return 'Keine Kunden gefunden'; },
|
||||
searching: function() { return 'Suche...'; },
|
||||
loadingMore: function() { return 'Lade weitere Ergebnisse...'; }
|
||||
}
|
||||
});
|
||||
|
||||
// Vorausgewählten Kunden setzen (falls von kombinierter Ansicht kommend)
|
||||
{% if preselected_customer_id %}
|
||||
// Lade Kundendetails und setze Auswahl
|
||||
fetch('{{ url_for('api.api_customers') }}?id={{ preselected_customer_id }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.results && data.results.length > 0) {
|
||||
const customer = data.results[0];
|
||||
// Erstelle Option und setze sie als ausgewählt
|
||||
const option = new Option(customer.text, customer.id, true, true);
|
||||
$('#customerSelect').append(option).trigger('change');
|
||||
// Verstecke die Eingabefelder
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Event Handler für Kundenauswahl
|
||||
$('#customerSelect').on('select2:select', function (e) {
|
||||
const selectedValue = e.params.data.id;
|
||||
const nameDiv = document.getElementById('customerNameDiv');
|
||||
const emailDiv = document.getElementById('emailDiv');
|
||||
const nameInput = document.getElementById('customerName');
|
||||
const emailInput = document.getElementById('email');
|
||||
|
||||
if (selectedValue === 'new') {
|
||||
// Zeige Eingabefelder für neuen Kunden
|
||||
nameDiv.style.display = 'block';
|
||||
emailDiv.style.display = 'block';
|
||||
nameInput.required = true;
|
||||
emailInput.required = true;
|
||||
|
||||
// New customers are currently hardcoded as fake (TODO in backend)
|
||||
window.selectedCustomerIsFake = true;
|
||||
|
||||
// Zeige Indikator für neuen Kunden
|
||||
showCustomerTypeIndicator('new');
|
||||
} else {
|
||||
// Verstecke Eingabefelder bei bestehendem Kunden
|
||||
nameDiv.style.display = 'none';
|
||||
emailDiv.style.display = 'none';
|
||||
nameInput.required = false;
|
||||
emailInput.required = false;
|
||||
nameInput.value = '';
|
||||
emailInput.value = '';
|
||||
|
||||
// Store customer's is_fake status
|
||||
window.selectedCustomerIsFake = e.params.data.is_fake || false;
|
||||
|
||||
// Zeige Indikator basierend auf Kundendaten
|
||||
if (e.params.data.is_fake !== undefined) {
|
||||
showCustomerTypeIndicator(e.params.data.is_fake ? 'fake' : 'real');
|
||||
}
|
||||
}
|
||||
|
||||
// Update resource availability check with new customer status
|
||||
checkResourceAvailability();
|
||||
});
|
||||
|
||||
// Clear handler
|
||||
$('#customerSelect').on('select2:clear', function (e) {
|
||||
document.getElementById('customerNameDiv').style.display = 'none';
|
||||
document.getElementById('emailDiv').style.display = 'none';
|
||||
document.getElementById('customerName').required = false;
|
||||
document.getElementById('email').required = false;
|
||||
window.selectedCustomerIsFake = false;
|
||||
checkResourceAvailability();
|
||||
hideCustomerTypeIndicator();
|
||||
});
|
||||
|
||||
// Store selected customer's is_fake status
|
||||
window.selectedCustomerIsFake = false;
|
||||
|
||||
// Resource Availability Check
|
||||
checkResourceAvailability();
|
||||
|
||||
// Event Listener für Resource Count Änderungen
|
||||
document.getElementById('domainCount').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('ipv4Count').addEventListener('change', checkResourceAvailability);
|
||||
document.getElementById('phoneCount').addEventListener('change', checkResourceAvailability);
|
||||
});
|
||||
|
||||
// Funktion zur Prüfung der Ressourcen-Verfügbarkeit
|
||||
function checkResourceAvailability() {
|
||||
const domainCount = parseInt(document.getElementById('domainCount').value) || 0;
|
||||
const ipv4Count = parseInt(document.getElementById('ipv4Count').value) || 0;
|
||||
const phoneCount = parseInt(document.getElementById('phoneCount').value) || 0;
|
||||
|
||||
// Include is_fake parameter based on selected customer
|
||||
const isFake = window.selectedCustomerIsFake ? 'true' : 'false';
|
||||
|
||||
// API-Call zur Verfügbarkeitsprüfung
|
||||
fetch(`{{ url_for('api.check_resource_availability') }}?domain=${domainCount}&ipv4=${ipv4Count}&phone=${phoneCount}&is_fake=${isFake}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update der Verfügbarkeitsanzeigen
|
||||
updateAvailabilityDisplay('domainsAvailable', data.domain_available, domainCount);
|
||||
updateAvailabilityDisplay('ipv4Available', data.ipv4_available, ipv4Count);
|
||||
updateAvailabilityDisplay('phoneAvailable', data.phone_available, phoneCount);
|
||||
|
||||
// Gesamtstatus aktualisieren
|
||||
updateResourceStatus(data, domainCount, ipv4Count, phoneCount);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler bei Verfügbarkeitsprüfung:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Hilfsfunktion zur Anzeige der Verfügbarkeit
|
||||
function updateAvailabilityDisplay(elementId, available, requested) {
|
||||
const element = document.getElementById(elementId);
|
||||
const container = element.parentElement;
|
||||
|
||||
// Verfügbarkeit mit Prozentanzeige
|
||||
const percent = Math.round((available / (available + requested + 50)) * 100);
|
||||
let statusHtml = `<strong>${available}</strong>`;
|
||||
|
||||
if (requested > 0 && available < requested) {
|
||||
element.classList.remove('text-success', 'text-warning');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <i class="bi bi-exclamation-triangle"></i>`;
|
||||
|
||||
// Füge Warnung hinzu
|
||||
if (!container.querySelector('.availability-warning')) {
|
||||
const warning = document.createElement('div');
|
||||
warning.className = 'availability-warning text-danger small mt-1';
|
||||
warning.innerHTML = `<i class="bi bi-x-circle"></i> Nicht genügend Ressourcen verfügbar!`;
|
||||
container.appendChild(warning);
|
||||
}
|
||||
} else {
|
||||
// Entferne Warnung wenn vorhanden
|
||||
const warning = container.querySelector('.availability-warning');
|
||||
if (warning) warning.remove();
|
||||
|
||||
if (available < 20) {
|
||||
element.classList.remove('text-success');
|
||||
element.classList.add('text-danger');
|
||||
statusHtml += ` <span class="badge bg-danger">Kritisch</span>`;
|
||||
} else if (available < 50) {
|
||||
element.classList.remove('text-success', 'text-danger');
|
||||
element.classList.add('text-warning');
|
||||
statusHtml += ` <span class="badge bg-warning text-dark">Niedrig</span>`;
|
||||
} else {
|
||||
element.classList.remove('text-danger', 'text-warning');
|
||||
element.classList.add('text-success');
|
||||
statusHtml += ` <span class="badge bg-success">OK</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
element.innerHTML = statusHtml;
|
||||
|
||||
// Zeige Fortschrittsbalken
|
||||
updateResourceProgressBar(elementId.replace('Available', ''), available, requested);
|
||||
}
|
||||
|
||||
// Fortschrittsbalken für Ressourcen
|
||||
function updateResourceProgressBar(resourceType, available, requested) {
|
||||
const progressId = `${resourceType}Progress`;
|
||||
let progressBar = document.getElementById(progressId);
|
||||
|
||||
// Erstelle Fortschrittsbalken wenn nicht vorhanden
|
||||
if (!progressBar) {
|
||||
const container = document.querySelector(`#${resourceType}Available`).parentElement.parentElement;
|
||||
const progressDiv = document.createElement('div');
|
||||
progressDiv.className = 'mt-2';
|
||||
progressDiv.innerHTML = `
|
||||
<div class="progress" style="height: 20px;" id="${progressId}">
|
||||
<div class="progress-bar bg-success" role="progressbar" style="width: 0%">
|
||||
<span class="progress-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(progressDiv);
|
||||
progressBar = document.getElementById(progressId);
|
||||
}
|
||||
|
||||
const total = available + requested;
|
||||
const availablePercent = total > 0 ? (available / total) * 100 : 100;
|
||||
const bar = progressBar.querySelector('.progress-bar');
|
||||
const text = progressBar.querySelector('.progress-text');
|
||||
|
||||
// Setze Farbe basierend auf Verfügbarkeit
|
||||
bar.classList.remove('bg-success', 'bg-warning', 'bg-danger');
|
||||
if (requested > 0 && available < requested) {
|
||||
bar.classList.add('bg-danger');
|
||||
} else if (availablePercent < 30) {
|
||||
bar.classList.add('bg-warning');
|
||||
} else {
|
||||
bar.classList.add('bg-success');
|
||||
}
|
||||
|
||||
// Animiere Fortschrittsbalken
|
||||
bar.style.width = `${availablePercent}%`;
|
||||
text.textContent = requested > 0 ? `${available} von ${total}` : `${available} verfügbar`;
|
||||
}
|
||||
|
||||
// Gesamtstatus der Ressourcen-Verfügbarkeit
|
||||
function updateResourceStatus(data, domainCount, ipv4Count, phoneCount) {
|
||||
const statusElement = document.getElementById('resourceStatus');
|
||||
let hasIssue = false;
|
||||
let message = '';
|
||||
|
||||
if (domainCount > 0 && data.domain_available < domainCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Domains';
|
||||
} else if (ipv4Count > 0 && data.ipv4_available < ipv4Count) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend IPv4-Adressen';
|
||||
} else if (phoneCount > 0 && data.phone_available < phoneCount) {
|
||||
hasIssue = true;
|
||||
message = '⚠️ Nicht genügend Telefonnummern';
|
||||
} else {
|
||||
message = '✅ Alle Ressourcen verfügbar';
|
||||
}
|
||||
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = hasIssue ? 'text-danger' : 'text-success';
|
||||
}
|
||||
|
||||
// Funktion zur Anzeige des Kundentyp-Indikators
|
||||
function showCustomerTypeIndicator(type) {
|
||||
const indicator = document.getElementById('customerTypeIndicator');
|
||||
const message = document.getElementById('customerTypeMessage');
|
||||
|
||||
indicator.classList.remove('d-none', 'alert-info', 'alert-warning', 'alert-success');
|
||||
|
||||
if (type === 'new') {
|
||||
indicator.classList.add('alert-info');
|
||||
message.textContent = 'Neue Kunden werden in der Testphase als TEST-Kunden erstellt. Die Lizenz wird automatisch als TEST-Lizenz markiert.';
|
||||
} else if (type === 'fake') {
|
||||
indicator.classList.add('alert-warning');
|
||||
message.textContent = 'Dies ist ein TEST-Kunde. Die Lizenz wird automatisch als TEST-Lizenz markiert und von der Software ignoriert.';
|
||||
} else if (type === 'real') {
|
||||
indicator.classList.add('alert-success');
|
||||
message.textContent = 'Dies ist ein PRODUKTIV-Kunde. Die Lizenz wird als produktive Lizenz erstellt.';
|
||||
}
|
||||
}
|
||||
|
||||
function hideCustomerTypeIndicator() {
|
||||
document.getElementById('customerTypeIndicator').classList.add('d-none');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
@ -0,0 +1,445 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}License Analytics{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">License Analytics</h1>
|
||||
|
||||
<!-- Time Range Selector -->
|
||||
<div class="btn-group mb-3" role="group">
|
||||
<a href="{{ url_for('admin.license_analytics', days=7) }}"
|
||||
class="btn btn-sm {% if days == 7 %}btn-primary{% else %}btn-outline-primary{% endif %}">7 Tage</a>
|
||||
<a href="{{ url_for('admin.license_analytics', days=30) }}"
|
||||
class="btn btn-sm {% if days == 30 %}btn-primary{% else %}btn-outline-primary{% endif %}">30 Tage</a>
|
||||
<a href="{{ url_for('admin.license_analytics', days=90) }}"
|
||||
class="btn btn-sm {% if days == 90 %}btn-primary{% else %}btn-outline-primary{% endif %}">90 Tage</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Aktive Lizenzen</h5>
|
||||
<h2 class="text-primary" id="active-licenses">-</h2>
|
||||
<small class="text-muted">In den letzten {{ days }} Tagen</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Aktive Geräte</h5>
|
||||
<h2 class="text-info" id="active-devices">-</h2>
|
||||
<small class="text-muted">Unique Hardware IDs</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Validierungen</h5>
|
||||
<h2 class="text-success" id="total-validations">-</h2>
|
||||
<small class="text-muted">Gesamt in {{ days }} Tagen</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Churn Risk</h5>
|
||||
<h2 class="text-warning" id="churn-risk">-</h2>
|
||||
<small class="text-muted">Kunden mit hohem Risiko</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Trends Chart -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Nutzungstrends</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="usageTrendsChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Performance Metriken</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="performanceChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Lizenzverteilung</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="distributionChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revenue Analysis -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Revenue Analysis</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="revenue-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenztyp</th>
|
||||
<th>Anzahl Lizenzen</th>
|
||||
<th>Aktive Lizenzen</th>
|
||||
<th>Gesamtumsatz</th>
|
||||
<th>Aktiver Umsatz</th>
|
||||
<th>Inaktiver Umsatz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Performers -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top 10 Aktive Lizenzen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="top-licenses">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenz</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Churn Risk Kunden</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm" id="churn-risk-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Lizenzen</th>
|
||||
<th>Letzte Aktivität</th>
|
||||
<th>Risk Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Wird von JavaScript gefüllt -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Patterns Heatmap -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Nutzungsmuster (Heatmap)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="usagePatternsChart" height="60"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script>
|
||||
// Configuration
|
||||
const API_BASE_URL = '/api/v1/analytics';
|
||||
const DAYS = {{ days }};
|
||||
|
||||
// Fetch data from Analytics Service
|
||||
async function fetchAnalyticsData() {
|
||||
try {
|
||||
// Get JWT token first (from license server auth)
|
||||
const tokenResponse = await fetch('/api/admin/license/auth-token');
|
||||
const tokenData = await tokenResponse.json();
|
||||
const token = tokenData.token;
|
||||
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
|
||||
// Fetch all analytics data
|
||||
const [usage, performance, distribution, revenue, patterns, churnRisk] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/usage?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/performance?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/distribution`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/revenue?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/patterns`, { headers }).then(r => r.json()),
|
||||
fetch(`${API_BASE_URL}/churn-risk`, { headers }).then(r => r.json())
|
||||
]);
|
||||
|
||||
// Update UI with fetched data
|
||||
updateSummaryCards(usage.data, distribution.data, churnRisk.data);
|
||||
createUsageTrendsChart(usage.data);
|
||||
createPerformanceChart(performance.data);
|
||||
createDistributionChart(distribution.data);
|
||||
updateRevenueTable(revenue.data);
|
||||
updateChurnRiskTable(churnRisk.data);
|
||||
createUsagePatternsHeatmap(patterns.data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching analytics data:', error);
|
||||
// Fallback to database data if API is not available
|
||||
useFallbackData();
|
||||
}
|
||||
}
|
||||
|
||||
// Update summary cards
|
||||
function updateSummaryCards(usageData, distributionData, churnData) {
|
||||
if (usageData && usageData.length > 0) {
|
||||
const totalValidations = usageData.reduce((sum, day) => sum + day.total_heartbeats, 0);
|
||||
const uniqueLicenses = new Set(usageData.flatMap(day => day.active_licenses)).size;
|
||||
const uniqueDevices = new Set(usageData.flatMap(day => day.active_devices)).size;
|
||||
|
||||
document.getElementById('active-licenses').textContent = uniqueLicenses.toLocaleString();
|
||||
document.getElementById('active-devices').textContent = uniqueDevices.toLocaleString();
|
||||
document.getElementById('total-validations').textContent = totalValidations.toLocaleString();
|
||||
}
|
||||
|
||||
if (churnData && churnData.length > 0) {
|
||||
const highRiskCount = churnData.filter(c => c.churn_risk === 'high').length;
|
||||
document.getElementById('churn-risk').textContent = highRiskCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Create usage trends chart
|
||||
function createUsageTrendsChart(data) {
|
||||
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => new Date(d.date).toLocaleDateString('de-DE')),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Aktive Lizenzen',
|
||||
data: data.map(d => d.active_licenses),
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||
tension: 0.1
|
||||
},
|
||||
{
|
||||
label: 'Aktive Geräte',
|
||||
data: data.map(d => d.active_devices),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
tension: 0.1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create performance chart
|
||||
function createPerformanceChart(data) {
|
||||
const ctx = document.getElementById('performanceChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => new Date(d.hour).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit'
|
||||
})),
|
||||
datasets: [{
|
||||
label: 'Validierungen pro Stunde',
|
||||
data: data.map(d => d.validation_count),
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create distribution chart
|
||||
function createDistributionChart(data) {
|
||||
const ctx = document.getElementById('distributionChart').getContext('2d');
|
||||
|
||||
const chartData = {
|
||||
labels: data.map(d => `${d.license_type} (${d.is_test ? 'Test' : 'Prod'})`),
|
||||
datasets: [{
|
||||
label: 'Lizenzverteilung',
|
||||
data: data.map(d => d.active_count),
|
||||
backgroundColor: [
|
||||
'rgba(255, 99, 132, 0.6)',
|
||||
'rgba(54, 162, 235, 0.6)',
|
||||
'rgba(255, 206, 86, 0.6)',
|
||||
'rgba(75, 192, 192, 0.6)',
|
||||
'rgba(153, 102, 255, 0.6)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update revenue table
|
||||
function updateRevenueTable(data) {
|
||||
const tbody = document.querySelector('#revenue-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${row.license_type}</td>
|
||||
<td>${row.total_licenses}</td>
|
||||
<td>${row.total_licenses > 0 ? Math.round((row.active_revenue / row.total_revenue) * 100) : 0}%</td>
|
||||
<td>€${(row.total_revenue || 0).toFixed(2)}</td>
|
||||
<td>€${(row.active_revenue || 0).toFixed(2)}</td>
|
||||
<td>€${(row.inactive_revenue || 0).toFixed(2)}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Update churn risk table
|
||||
function updateChurnRiskTable(data) {
|
||||
const tbody = document.querySelector('#churn-risk-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.filter(d => d.churn_risk !== 'low').slice(0, 10).forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
const riskClass = row.churn_risk === 'high' ? 'danger' : 'warning';
|
||||
tr.innerHTML = `
|
||||
<td>${row.customer_id}</td>
|
||||
<td>${row.total_licenses}</td>
|
||||
<td>${row.avg_days_since_activity ? Math.round(row.avg_days_since_activity) + ' Tage' : '-'}</td>
|
||||
<td><span class="badge bg-${riskClass}">${row.churn_risk}</span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
// Create usage patterns heatmap
|
||||
function createUsagePatternsHeatmap(data) {
|
||||
// Implementation for heatmap would go here
|
||||
// For now, just log the data
|
||||
console.log('Usage patterns data:', data);
|
||||
}
|
||||
|
||||
// Fallback to use existing database data
|
||||
function useFallbackData() {
|
||||
// Use the data passed from the server template
|
||||
const usageTrends = {{ usage_trends | tojson | safe }};
|
||||
const licenseMetrics = {{ license_metrics | tojson | safe }};
|
||||
const deviceDistribution = {{ device_distribution | tojson | safe }};
|
||||
const revenueAnalysis = {{ revenue_analysis | tojson | safe }};
|
||||
|
||||
// Create charts with fallback data
|
||||
if (usageTrends) {
|
||||
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: usageTrends.map(d => d[0]),
|
||||
datasets: [{
|
||||
label: 'Aktive Lizenzen',
|
||||
data: usageTrends.map(d => d[1]),
|
||||
borderColor: 'rgb(54, 162, 235)',
|
||||
tension: 0.1
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
fetchAnalyticsData();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
@ -0,0 +1,241 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}License Anomalien{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">Anomalie-Erkennung</h1>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Schweregrad</label>
|
||||
<select name="severity" class="form-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="low" {% if request.args.get('severity') == 'low' %}selected{% endif %}>Niedrig</option>
|
||||
<option value="medium" {% if request.args.get('severity') == 'medium' %}selected{% endif %}>Mittel</option>
|
||||
<option value="high" {% if request.args.get('severity') == 'high' %}selected{% endif %}>Hoch</option>
|
||||
<option value="critical" {% if request.args.get('severity') == 'critical' %}selected{% endif %}>Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Status</label>
|
||||
<select name="resolved" class="form-select">
|
||||
<option value="false" {% if request.args.get('resolved', 'false') == 'false' %}selected{% endif %}>Ungelöst</option>
|
||||
<option value="true" {% if request.args.get('resolved') == 'true' %}selected{% endif %}>Gelöst</option>
|
||||
<option value="">Alle</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Anomalie-Typ</label>
|
||||
<select name="anomaly_type" class="form-select">
|
||||
<option value="">Alle</option>
|
||||
<option value="multiple_ips" {% if request.args.get('anomaly_type') == 'multiple_ips' %}selected{% endif %}>Multiple IPs</option>
|
||||
<option value="rapid_hardware_change" {% if request.args.get('anomaly_type') == 'rapid_hardware_change' %}selected{% endif %}>Schneller Hardware-Wechsel</option>
|
||||
<option value="suspicious_pattern" {% if request.args.get('anomaly_type') == 'suspicious_pattern' %}selected{% endif %}>Verdächtiges Muster</option>
|
||||
<option value="concurrent_use" {% if request.args.get('anomaly_type') == 'concurrent_use' %}selected{% endif %}>Gleichzeitige Nutzung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary">Filter anwenden</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-danger">Kritisch</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set critical_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'critical' %}
|
||||
{% set critical_count.value = critical_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ critical_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-warning">Hoch</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set high_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'high' %}
|
||||
{% set high_count.value = high_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ high_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-info">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-info">Mittel</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set medium_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'medium' %}
|
||||
{% set medium_count.value = medium_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ medium_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title text-muted">Niedrig</h5>
|
||||
<h2 class="mb-0">
|
||||
{% set low_count = namespace(value=0) %}
|
||||
{% for stat in anomaly_stats if stat[1] == 'low' %}
|
||||
{% set low_count.value = low_count.value + stat[2] %}
|
||||
{% endfor %}
|
||||
{{ low_count.value }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Anomaly List -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Anomalien</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Lizenz</th>
|
||||
<th>Typ</th>
|
||||
<th>Schweregrad</th>
|
||||
<th>Details</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for anomaly in anomalies %}
|
||||
<tr class="{% if anomaly[6] == 'critical' %}table-danger{% elif anomaly[6] == 'high' %}table-warning{% endif %}">
|
||||
<td>{{ anomaly[3].strftime('%d.%m.%Y %H:%M') if anomaly[3] else '-' }}</td>
|
||||
<td>
|
||||
{% if anomaly[8] %}
|
||||
<small>{{ anomaly[8][:8] }}...</small><br>
|
||||
<span class="text-muted">{{ anomaly[9] }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">Unbekannt</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ anomaly[5] }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if anomaly[6] == 'critical' %}danger{% elif anomaly[6] == 'high' %}warning{% elif anomaly[6] == 'medium' %}info{% else %}secondary{% endif %}">
|
||||
{{ anomaly[6] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-link" onclick="showDetails('{{ anomaly[7] }}')">
|
||||
Details anzeigen
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
{% if anomaly[2] %}
|
||||
<span class="badge bg-success">Gelöst</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Ungelöst</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not anomaly[2] %}
|
||||
<button class="btn btn-sm btn-success" onclick="resolveAnomaly('{{ anomaly[0] }}')">
|
||||
Lösen
|
||||
</button>
|
||||
{% else %}
|
||||
<small class="text-muted">{{ anomaly[4].strftime('%d.%m %H:%M') if anomaly[4] else '' }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">Keine Anomalien gefunden</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details Modal -->
|
||||
<div class="modal fade" id="detailsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Anomalie Details</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="anomalyDetails"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolve Modal -->
|
||||
<div class="modal fade" id="resolveModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="resolveForm" method="post">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Anomalie lösen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ergriffene Maßnahme</label>
|
||||
<textarea name="action_taken" class="form-control" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-success">Als gelöst markieren</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function showDetails(detailsJson) {
|
||||
try {
|
||||
const details = JSON.parse(detailsJson);
|
||||
document.getElementById('anomalyDetails').textContent = JSON.stringify(details, null, 2);
|
||||
new bootstrap.Modal(document.getElementById('detailsModal')).show();
|
||||
} catch (e) {
|
||||
alert('Fehler beim Anzeigen der Details');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnomaly(anomalyId) {
|
||||
const form = document.getElementById('resolveForm');
|
||||
form.action = `/lizenzserver/anomaly/${anomalyId}/resolve`;
|
||||
new bootstrap.Modal(document.getElementById('resolveModal')).show();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
373
v2_adminpanel/templates/license_config.html
Normale Datei
373
v2_adminpanel/templates/license_config.html
Normale Datei
@ -0,0 +1,373 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Administration{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h3">Administration</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Forger Configuration -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Account Forger Konfiguration</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{{ url_for('admin.update_client_config') }}" class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Aktuelle Version</label>
|
||||
<input type="text" class="form-control" name="current_version"
|
||||
value="{{ client_config[4] if client_config else '1.0.0' }}"
|
||||
pattern="^\d+\.\d+\.\d+$" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Minimum Version</label>
|
||||
<input type="text" class="form-control" name="minimum_version"
|
||||
value="{{ client_config[5] if client_config else '1.0.0' }}"
|
||||
pattern="^\d+\.\d+\.\d+$" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktive Sitzungen</h5>
|
||||
<div>
|
||||
<span class="badge bg-white text-dark" id="sessionCount">{{ active_sessions|length if active_sessions else 0 }}</span>
|
||||
<a href="{{ url_for('admin.license_sessions') }}" class="btn btn-sm btn-light ms-2">Alle anzeigen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kunde</th>
|
||||
<th>Version</th>
|
||||
<th>Letztes Heartbeat</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessionTableBody">
|
||||
{% if active_sessions %}
|
||||
{% for session in active_sessions[:5] %}
|
||||
<tr>
|
||||
<td>{{ session[3] or 'Unbekannt' }}</td>
|
||||
<td>{{ session[6] }}</td>
|
||||
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
{% if session[9] < 90 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Timeout</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System API Key Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-dark">
|
||||
<h5 class="mb-0"><i class="bi bi-key"></i> API Key für Account Forger</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if system_api_key %}
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle"></i> Dies ist der einzige API Key, den Account Forger benötigt.
|
||||
Verwenden Sie diesen Key im Header <code>X-API-Key</code> für alle API-Anfragen.
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label fw-bold">Aktueller API Key:</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control font-monospace" id="systemApiKey"
|
||||
value="{{ system_api_key.api_key }}" readonly>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copySystemApiKey()">
|
||||
<i class="bi bi-clipboard"></i> Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>Key Informationen:</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><strong>Erstellt:</strong>
|
||||
{% if system_api_key.created_at %}
|
||||
{{ system_api_key.created_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
N/A
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><strong>Erstellt von:</strong> {{ system_api_key.created_by or 'System' }}</li>
|
||||
{% if system_api_key.regenerated_at %}
|
||||
<li><strong>Zuletzt regeneriert:</strong>
|
||||
{{ system_api_key.regenerated_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
</li>
|
||||
<li><strong>Regeneriert von:</strong> {{ system_api_key.regenerated_by }}</li>
|
||||
{% else %}
|
||||
<li><strong>Zuletzt regeneriert:</strong> Nie</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6>Nutzungsstatistiken:</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><strong>Letzte Nutzung:</strong>
|
||||
{% if system_api_key.last_used_at %}
|
||||
{{ system_api_key.last_used_at.strftime('%d.%m.%Y %H:%M') }}
|
||||
{% else %}
|
||||
Noch nie genutzt
|
||||
{% endif %}
|
||||
</li>
|
||||
<li><strong>Gesamte Anfragen:</strong> {{ system_api_key.usage_count or 0 }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<form action="{{ url_for('admin.regenerate_api_key') }}" method="POST"
|
||||
onsubmit="return confirmRegenerate()">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-arrow-clockwise"></i> API Key regenerieren
|
||||
</button>
|
||||
<span class="text-muted ms-2">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Dies wird den aktuellen Key ungültig machen!
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<details>
|
||||
<summary class="text-primary" style="cursor: pointer;">Verwendungsbeispiel anzeigen</summary>
|
||||
<div class="mt-2">
|
||||
<pre class="bg-light p-3"><code>import requests
|
||||
|
||||
headers = {
|
||||
"X-API-Key": "{{ system_api_key.api_key }}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
"{{ request.url_root }}api/license/verify",
|
||||
headers=headers,
|
||||
json={"license_key": "YOUR_LICENSE_KEY"}
|
||||
)</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle"></i> Kein System API Key gefunden!
|
||||
Bitte kontaktieren Sie den Administrator.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technical Settings (collapsible) -->
|
||||
<div class="accordion mb-4" id="technicalSettings">
|
||||
<!-- Feature Flags -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#featureFlags">
|
||||
Feature Flags
|
||||
</button>
|
||||
</h2>
|
||||
<div id="featureFlags" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
|
||||
<div class="accordion-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for flag in feature_flags %}
|
||||
<tr>
|
||||
<td><strong>{{ flag[1] }}</strong></td>
|
||||
<td><small>{{ flag[2] }}</small></td>
|
||||
<td>
|
||||
{% if flag[3] %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('admin.toggle_feature_flag', flag_id=flag[0]) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm {% if flag[3] %}btn-danger{% else %}btn-success{% endif %}">
|
||||
{% if flag[3] %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted">Keine Feature Flags konfiguriert</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limits -->
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rateLimits">
|
||||
Rate Limits
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rateLimits" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
|
||||
<div class="accordion-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API Key</th>
|
||||
<th>Requests/Minute</th>
|
||||
<th>Requests/Stunde</th>
|
||||
<th>Requests/Tag</th>
|
||||
<th>Burst Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for limit in rate_limits %}
|
||||
<tr>
|
||||
<td><code>{{ limit[1][:12] }}...</code></td>
|
||||
<td>{{ limit[2] }}</td>
|
||||
<td>{{ limit[3] }}</td>
|
||||
<td>{{ limit[4] }}</td>
|
||||
<td>{{ limit[5] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Keine Rate Limits konfiguriert</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
// Show success message instead of alert
|
||||
const button = event.target.closest('button');
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function copySystemApiKey() {
|
||||
const apiKeyInput = document.getElementById('systemApiKey');
|
||||
apiKeyInput.select();
|
||||
apiKeyInput.setSelectionRange(0, 99999);
|
||||
|
||||
navigator.clipboard.writeText(apiKeyInput.value).then(function() {
|
||||
const button = event.currentTarget;
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.remove('btn-outline-secondary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-outline-secondary');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function confirmRegenerate() {
|
||||
return confirm('Sind Sie sicher, dass Sie den API Key regenerieren möchten?\n\n' +
|
||||
'Dies wird den aktuellen Key ungültig machen und alle bestehenden ' +
|
||||
'Integrationen müssen mit dem neuen Key aktualisiert werden.');
|
||||
}
|
||||
|
||||
// Auto-refresh sessions every 30 seconds
|
||||
function refreshSessions() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('sessionCount').textContent = data.active_licenses || 0;
|
||||
|
||||
// Update session table
|
||||
const tbody = document.getElementById('sessionTableBody');
|
||||
if (data.latest_sessions && data.latest_sessions.length > 0) {
|
||||
tbody.innerHTML = data.latest_sessions.map(session => `
|
||||
<tr>
|
||||
<td>${session.customer_name || 'Unbekannt'}</td>
|
||||
<td>${session.version}</td>
|
||||
<td>${session.last_heartbeat}</td>
|
||||
<td>
|
||||
${session.seconds_since < 90
|
||||
? '<span class="badge bg-success">Aktiv</span>'
|
||||
: '<span class="badge bg-warning">Timeout</span>'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td></tr>';
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error refreshing sessions:', error));
|
||||
}
|
||||
|
||||
// Refresh sessions every 30 seconds
|
||||
setInterval(refreshSessions, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Aktive Lizenzsitzungen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<h1>Lizenzsitzungen</h1>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Aktive Sitzungen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Hardware ID</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Version</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Letztes Heartbeat</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr>
|
||||
<td><code>{{ session[2][:8] }}...</code></td>
|
||||
<td>{{ session[3] or 'Unbekannt' }}</td>
|
||||
<td><code>{{ session[4][:12] }}...</code></td>
|
||||
<td>{{ session[5] or 'Unbekannt' }}</td>
|
||||
<td>{{ session[6] }}</td>
|
||||
<td>{{ session[7].strftime('%H:%M:%S') }}</td>
|
||||
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
{% if session[9] < 90 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% elif session[9] < 120 %}
|
||||
<span class="badge bg-warning">Timeout bald</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Timeout</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if session.get('username') in ['rac00n', 'w@rh@mm3r'] %}
|
||||
<form method="POST" action="{{ url_for('admin.terminate_session', session_id=session[0]) }}"
|
||||
style="display: inline;" onsubmit="return confirm('Sitzung wirklich beenden?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Beenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Keine aktiven Sitzungen vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5>Sitzungsverlauf (letzte 24 Stunden)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if session_history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Hardware ID</th>
|
||||
<th>IP-Adresse</th>
|
||||
<th>Version</th>
|
||||
<th>Gestartet</th>
|
||||
<th>Beendet</th>
|
||||
<th>Dauer</th>
|
||||
<th>Grund</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hist in session_history %}
|
||||
<tr>
|
||||
<td><code>{{ hist[1][:8] }}...</code></td>
|
||||
<td>{{ hist[2] or 'Unbekannt' }}</td>
|
||||
<td><code>{{ hist[3][:12] }}...</code></td>
|
||||
<td>{{ hist[4] or 'Unbekannt' }}</td>
|
||||
<td>{{ hist[5] }}</td>
|
||||
<td>{{ hist[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ hist[7].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% set duration = hist[9] %}
|
||||
{% if duration < 60 %}
|
||||
{{ duration|int }}s
|
||||
{% elif duration < 3600 %}
|
||||
{{ (duration / 60)|int }}m
|
||||
{% else %}
|
||||
{{ (duration / 3600)|round(1) }}h
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if hist[8] == 'normal' %}
|
||||
<span class="badge bg-success">Normal</span>
|
||||
{% elif hist[8] == 'timeout' %}
|
||||
<span class="badge bg-warning">Timeout</span>
|
||||
{% elif hist[8] == 'forced' %}
|
||||
<span class="badge bg-danger">Erzwungen</span>
|
||||
{% elif hist[8] == 'replaced' %}
|
||||
<span class="badge bg-info">Ersetzt</span>
|
||||
{% else %}
|
||||
{{ hist[8] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">Keine Sitzungen in den letzten 24 Stunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('admin.license_config') }}" class="btn btn-secondary">Zurück zur Konfiguration</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-refresh every 30 seconds
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
455
v2_adminpanel/templates/licenses.html
Normale Datei
455
v2_adminpanel/templates/licenses.html
Normale Datei
@ -0,0 +1,455 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenzübersicht{% endblock %}
|
||||
|
||||
{% macro sortable_header(label, field, current_sort, current_order) %}
|
||||
<th>
|
||||
{% set base_url = url_for('licenses.licenses') %}
|
||||
{% set params = [] %}
|
||||
{% if search %}{% set _ = params.append('search=' + search|urlencode) %}{% endif %}
|
||||
{% if request.args.get('data_source') %}{% set _ = params.append('data_source=' + request.args.get('data_source')|urlencode) %}{% endif %}
|
||||
{% if request.args.get('license_type') %}{% set _ = params.append('license_type=' + request.args.get('license_type')|urlencode) %}{% endif %}
|
||||
{% if request.args.get('license_status') %}{% set _ = params.append('license_status=' + request.args.get('license_status')|urlencode) %}{% endif %}
|
||||
{% set _ = params.append('sort=' + field) %}
|
||||
{% if current_sort == field %}
|
||||
{% set _ = params.append('order=' + ('desc' if current_order == 'asc' else 'asc')) %}
|
||||
{% else %}
|
||||
{% set _ = params.append('order=asc') %}
|
||||
{% endif %}
|
||||
{% set _ = params.append('page=1') %}
|
||||
|
||||
<a href="{{ base_url }}?{{ params|join('&') }}" class="server-sortable">
|
||||
{{ 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>
|
||||
.filter-group {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group h6 {
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge.bg-info {
|
||||
background-color: #0dcaf0 !important;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.active-filters .badge {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35em 0.65em;
|
||||
}
|
||||
|
||||
.active-filters a {
|
||||
text-decoration: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.active-filters a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#advancedFilters {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="mb-4">
|
||||
<h2>Lizenzübersicht</h2>
|
||||
</div>
|
||||
|
||||
<!-- Such- und Filterformular -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('licenses.licenses') }}" id="filterForm">
|
||||
<!-- Filter Controls -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<label for="dataSource" class="form-label">Datenquelle</label>
|
||||
<select class="form-select" name="data_source" id="dataSource" onchange="this.form.submit()">
|
||||
<option value="real" {% if request.args.get('data_source', 'real') == 'real' %}selected{% endif %}>Echte Lizenzen</option>
|
||||
<option value="fake" {% if request.args.get('data_source') == 'fake' %}selected{% endif %}>🧪 Fake-Daten</option>
|
||||
<option value="all" {% if request.args.get('data_source') == 'all' %}selected{% endif %}>Alle Daten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="licenseType" class="form-label">Lizenztyp</label>
|
||||
<select class="form-select" name="license_type" id="licenseType" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.args.get('license_type') %}selected{% endif %}>Alle Typen</option>
|
||||
<option value="full" {% if request.args.get('license_type') == 'full' %}selected{% endif %}>Vollversion</option>
|
||||
<option value="test" {% if request.args.get('license_type') == 'test' %}selected{% endif %}>Testversion</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="licenseStatus" class="form-label">Status</label>
|
||||
<select class="form-select" name="license_status" id="licenseStatus" onchange="this.form.submit()">
|
||||
<option value="" {% if not request.args.get('license_status') %}selected{% endif %}>Alle Status</option>
|
||||
<option value="active" {% if request.args.get('license_status') == 'active' %}selected{% endif %}>✅ Aktiv</option>
|
||||
<option value="expired" {% if request.args.get('license_status') == 'expired' %}selected{% endif %}>⚠️ Abgelaufen</option>
|
||||
<option value="inactive" {% if request.args.get('license_status') == 'inactive' %}selected{% endif %}>❌ Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<a href="{{ url_for('licenses.licenses') }}" class="btn btn-secondary w-100">
|
||||
<i class="bi bi-arrow-clockwise"></i> Filter zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Field -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-9">
|
||||
<label for="search" class="form-label">🔍 Suchen</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
placeholder="Lizenzschlüssel, Kunde, E-Mail..."
|
||||
value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i> Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden fields for sorting and pagination -->
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="order" value="{{ order }}">
|
||||
<!-- Note: Other filters are preserved by the form elements themselves -->
|
||||
</form>
|
||||
{% if search or request.args.get('data_source') != 'real' or request.args.get('license_type') or request.args.get('license_status') %}
|
||||
<div class="mt-2">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-muted">
|
||||
Gefiltert: {{ total }} Ergebnisse
|
||||
</small>
|
||||
<div class="active-filters">
|
||||
{% if search %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
<i class="bi bi-search"></i> {{ search }}
|
||||
{% set clear_search_params = [] %}
|
||||
{% for type in filter_types %}{% set _ = clear_search_params.append('types[]=' + type|urlencode) %}{% endfor %}
|
||||
{% for status in filter_statuses %}{% set _ = clear_search_params.append('statuses[]=' + status|urlencode) %}{% endfor %}
|
||||
{% if show_fake %}{% set _ = clear_search_params.append('show_fake=1') %}{% endif %}
|
||||
{% set _ = clear_search_params.append('sort=' + sort) %}
|
||||
{% set _ = clear_search_params.append('order=' + order) %}
|
||||
<a href="{{ url_for('licenses.licenses') }}?{{ clear_search_params|join('&') }}" class="text-white ms-1">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table table-hover table-sticky mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-cell">
|
||||
<input type="checkbox" class="form-check-input form-check-input-custom" id="selectAll">
|
||||
</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>
|
||||
<tbody>
|
||||
{% for license in licenses %}
|
||||
<tr>
|
||||
<td class="checkbox-cell">
|
||||
<input type="checkbox" class="form-check-input form-check-input-custom license-checkbox" value="{{ license.id }}">
|
||||
</td>
|
||||
<td>{{ license.id }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ license.license_key }}</code>
|
||||
<button class="btn btn-sm btn-outline-secondary btn-copy" onclick="copyToClipboard('{{ license.license_key }}', this)" title="Kopieren">
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{{ license.customer_name }}
|
||||
{% if license.is_fake %}
|
||||
<span class="badge bg-secondary ms-1" title="Fake-Daten">🧪</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>-</td>
|
||||
<td>
|
||||
{% if license.license_type == 'full' %}
|
||||
<span class="badge bg-success">Vollversion</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Testversion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ license.valid_from.strftime('%d.%m.%Y') }}</td>
|
||||
<td>{{ license.valid_until.strftime('%d.%m.%Y') }}</td>
|
||||
<td>
|
||||
{% if not license.is_active %}
|
||||
<span class="status-deaktiviert">❌ Deaktiviert</span>
|
||||
{% elif license.valid_until < now().date() %}
|
||||
<span class="status-abgelaufen">⚠️ Abgelaufen</span>
|
||||
{% else %}
|
||||
<span class="status-aktiv">✅ Aktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="form-check form-switch form-switch-custom">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
id="active_{{ license.id }}"
|
||||
{{ 'checked' if license.is_active else '' }}
|
||||
onchange="toggleLicenseStatus({{ license.id }}, this.checked)">
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=license.id) }}" class="btn btn-outline-primary">✏️ Bearbeiten</a>
|
||||
<form method="post" action="{{ url_for('licenses.delete_license', license_id=license.id) }}" style="display: inline;" onsubmit="return confirm('Wirklich löschen?');">
|
||||
<button type="submit" class="btn btn-outline-danger">🗑️ Löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if not licenses %}
|
||||
<div class="text-center py-5">
|
||||
{% if search %}
|
||||
<p class="text-muted">Keine Lizenzen gefunden für: <strong>{{ search }}</strong></p>
|
||||
<a href="{{ url_for('licenses.licenses') }}" class="btn btn-secondary">Alle Lizenzen anzeigen</a>
|
||||
{% else %}
|
||||
<p class="text-muted">Noch keine Lizenzen vorhanden.</p>
|
||||
<a href="{{ url_for('licenses.create_license') }}" class="btn btn-primary">Erste Lizenz erstellen</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav aria-label="Seitennavigation" class="mt-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% set base_url = url_for('licenses.licenses') %}
|
||||
{% set base_params = [] %}
|
||||
{% if search %}{% set _ = base_params.append('search=' + search|urlencode) %}{% endif %}
|
||||
{% for type in filter_types %}{% set _ = base_params.append('types[]=' + type|urlencode) %}{% endfor %}
|
||||
{% for status in filter_statuses %}{% set _ = base_params.append('statuses[]=' + status|urlencode) %}{% endfor %}
|
||||
{% set _ = base_params.append('sort=' + sort) %}
|
||||
{% set _ = base_params.append('order=' + order) %}
|
||||
|
||||
<!-- Erste Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page=1">Erste</a>
|
||||
</li>
|
||||
|
||||
<!-- Vorherige Seite -->
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ page-1 }}">←</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="{{ base_url }}?{{ base_params|join('&') }}&page={{ p }}">{{ p }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Nächste Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ page+1 }}">→</a>
|
||||
</li>
|
||||
|
||||
<!-- Letzte Seite -->
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ base_url }}?{{ base_params|join('&') }}&page={{ total_pages }}">Letzte</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted">
|
||||
Seite {{ page }} von {{ total_pages }} | Gesamt: {{ total }} Lizenzen
|
||||
</p>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Bar -->
|
||||
<div class="bulk-actions" id="bulkActionsBar">
|
||||
<div>
|
||||
<span id="selectedCount">0</span> Lizenzen ausgewählt
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success btn-sm me-2" onclick="bulkActivate()">✅ Aktivieren</button>
|
||||
<button class="btn btn-warning btn-sm me-2" onclick="bulkDeactivate()">⏸️ Deaktivieren</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="bulkDelete()">🗑️ Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
|
||||
// Live Filtering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const filterForm = document.getElementById('filterForm');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
});
|
||||
|
||||
// Copy to Clipboard
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
button.classList.add('copied');
|
||||
button.innerHTML = '✅';
|
||||
setTimeout(function() {
|
||||
button.classList.remove('copied');
|
||||
button.innerHTML = '📋';
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle License Status
|
||||
function toggleLicenseStatus(licenseId, isActive) {
|
||||
// Build URL manually to avoid template rendering issues
|
||||
const baseUrl = '{{ url_for("api.toggle_license", license_id=999999) }}'.replace('999999', licenseId);
|
||||
fetch(baseUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Optional: Show success message
|
||||
} else {
|
||||
// Revert toggle on error
|
||||
document.getElementById(`active_${licenseId}`).checked = !isActive;
|
||||
alert('Fehler beim Ändern des Status');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk Selection
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const checkboxes = document.querySelectorAll('.license-checkbox');
|
||||
const bulkActionsBar = document.getElementById('bulkActionsBar');
|
||||
const selectedCount = document.getElementById('selectedCount');
|
||||
|
||||
selectAll.addEventListener('change', function() {
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
updateBulkActions();
|
||||
});
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.addEventListener('change', updateBulkActions);
|
||||
});
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkedBoxes = document.querySelectorAll('.license-checkbox:checked');
|
||||
const count = checkedBoxes.length;
|
||||
|
||||
if (count > 0) {
|
||||
bulkActionsBar.classList.add('show');
|
||||
selectedCount.textContent = count;
|
||||
} else {
|
||||
bulkActionsBar.classList.remove('show');
|
||||
}
|
||||
|
||||
// Update select all checkbox
|
||||
selectAll.checked = count === checkboxes.length && count > 0;
|
||||
selectAll.indeterminate = count > 0 && count < checkboxes.length;
|
||||
}
|
||||
|
||||
// Bulk Actions
|
||||
function getSelectedIds() {
|
||||
return Array.from(document.querySelectorAll('.license-checkbox:checked'))
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkActivate() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen aktivieren?`)) {
|
||||
performBulkAction('{{ url_for('api.bulk_activate_licenses') }}', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDeactivate() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen deaktivieren?`)) {
|
||||
performBulkAction('{{ url_for('api.bulk_deactivate_licenses') }}', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const ids = getSelectedIds();
|
||||
if (confirm(`${ids.length} Lizenzen wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden!`)) {
|
||||
performBulkAction('{{ url_for('api.bulk_delete_licenses') }}', ids);
|
||||
}
|
||||
}
|
||||
|
||||
function performBulkAction(url, ids) {
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ ids: ids })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Show warnings if any licenses were skipped
|
||||
if (data.warnings && data.warnings.length > 0) {
|
||||
let warningMessage = data.message + '\n\n';
|
||||
warningMessage += 'Warnungen:\n';
|
||||
data.warnings.forEach(warning => {
|
||||
warningMessage += '⚠️ ' + warning + '\n';
|
||||
});
|
||||
alert(warningMessage);
|
||||
}
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler bei der Bulk-Aktion: ' + (data.error || data.message));
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
125
v2_adminpanel/templates/login.html
Normale Datei
125
v2_adminpanel/templates/login.html
Normale Datei
@ -0,0 +1,125 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login - Lizenzverwaltung</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.error-failed {
|
||||
background-color: #dc3545 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.5rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
text-transform: uppercase !important;
|
||||
animation: shake 0.5s;
|
||||
box-shadow: 0 0 20px rgba(220, 53, 69, 0.5);
|
||||
}
|
||||
|
||||
.error-blocked {
|
||||
background-color: #6f42c1 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.error-captcha {
|
||||
background-color: #fd7e14 !important;
|
||||
color: white !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 1.2rem !important;
|
||||
text-align: center !important;
|
||||
padding: 1rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-10px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(10px); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.attempts-warning {
|
||||
background-color: #ffc107;
|
||||
color: #000;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.security-info {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container">
|
||||
<div class="row min-vh-100 align-items-center justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<h2 class="text-center mb-4">🔐 Admin Login</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-{{ error_type|default('failed') }}">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if attempts_left is defined and attempts_left > 0 and attempts_left < 5 %}
|
||||
<div class="attempts-warning">
|
||||
⚠️ Noch {{ attempts_left }} Versuch(e) bis zur IP-Sperre!
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Benutzername</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<div class="mb-3">
|
||||
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<div class="security-info">
|
||||
🛡️ Geschützt durch Rate-Limiting und IP-Sperre
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_captcha and recaptcha_site_key %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
322
v2_adminpanel/templates/monitoring/alerts.html
Normale Datei
322
v2_adminpanel/templates/monitoring/alerts.html
Normale Datei
@ -0,0 +1,322 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Alerts{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.alert-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 5px solid #dee2e6;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.alert-critical {
|
||||
border-left-color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
.alert-high {
|
||||
border-left-color: #fd7e14;
|
||||
background-color: #ffe5d1;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: #ffc107;
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
border-left-color: #17a2b8;
|
||||
background-color: #d1ecf1;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 15px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.severity-high {
|
||||
background-color: #fd7e14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.severity-medium {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.severity-low {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.alert-timestamp {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
background: rgba(0,0,0,0.05);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert-stats {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.filter-pills {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-pill {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-pill.active {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-exclamation-triangle"></i> Alerts</h2>
|
||||
<p class="text-muted">Aktive Warnungen und Anomalien</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-primary" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-danger">{{ alerts|selectattr('severity', 'equalto', 'critical')|list|length }}</div>
|
||||
<div class="text-muted">Kritisch</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-warning">{{ alerts|selectattr('severity', 'equalto', 'high')|list|length }}</div>
|
||||
<div class="text-muted">Hoch</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-info">{{ alerts|selectattr('severity', 'equalto', 'medium')|list|length }}</div>
|
||||
<div class="text-muted">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="alert-stats">
|
||||
<div class="stat-number text-success">{{ alerts|selectattr('severity', 'equalto', 'low')|list|length }}</div>
|
||||
<div class="text-muted">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Pills -->
|
||||
<div class="filter-pills">
|
||||
<span class="badge bg-secondary filter-pill active me-2" onclick="filterAlerts('all')">
|
||||
Alle ({{ alerts|length }})
|
||||
</span>
|
||||
<span class="badge bg-danger filter-pill me-2" onclick="filterAlerts('critical')">
|
||||
Kritisch
|
||||
</span>
|
||||
<span class="badge bg-warning filter-pill me-2" onclick="filterAlerts('high')">
|
||||
Hoch
|
||||
</span>
|
||||
<span class="badge bg-info filter-pill me-2" onclick="filterAlerts('medium')">
|
||||
Mittel
|
||||
</span>
|
||||
<span class="badge bg-success filter-pill me-2" onclick="filterAlerts('low')">
|
||||
Niedrig
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Alerts List -->
|
||||
<div id="alerts-container">
|
||||
{% for alert in alerts %}
|
||||
<div class="alert-card alert-{{ alert.severity }}" data-severity="{{ alert.severity }}">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h5 class="mb-0 me-3">
|
||||
{% if alert.anomaly_type == 'multiple_ips' %}
|
||||
<i class="bi bi-geo-alt-fill"></i> Mehrere IP-Adressen erkannt
|
||||
{% elif alert.anomaly_type == 'rapid_hardware_change' %}
|
||||
<i class="bi bi-laptop"></i> Schneller Hardware-Wechsel
|
||||
{% elif alert.anomaly_type == 'suspicious_pattern' %}
|
||||
<i class="bi bi-shield-exclamation"></i> Verdächtiges Muster
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-circle"></i> {{ alert.anomaly_type }}
|
||||
{% endif %}
|
||||
</h5>
|
||||
<span class="severity-badge severity-{{ alert.severity }}">
|
||||
{{ alert.severity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if alert.company_name %}
|
||||
<div class="mb-2">
|
||||
<strong>Kunde:</strong> {{ alert.company_name }}
|
||||
{% if alert.license_key %}
|
||||
<span class="text-muted">({{ alert.license_key[:8] }}...)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="alert-timestamp">
|
||||
<i class="bi bi-clock"></i> {{ alert.detected_at|default(alert.startsAt) }}
|
||||
</div>
|
||||
|
||||
{% if alert.details %}
|
||||
<div class="alert-details">
|
||||
<strong>Details:</strong><br>
|
||||
{{ alert.details }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="alert-actions">
|
||||
{% if not alert.resolved %}
|
||||
<button class="btn btn-sm btn-success w-100 mb-2" onclick="resolveAlert('{{ alert.id }}')">
|
||||
<i class="bi bi-check-circle"></i> Als gelöst markieren
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning w-100 mb-2" onclick="investigateAlert('{{ alert.id }}')">
|
||||
<i class="bi bi-search"></i> Untersuchen
|
||||
</button>
|
||||
{% if alert.severity in ['critical', 'high'] %}
|
||||
<button class="btn btn-sm btn-danger w-100" onclick="blockLicense('{{ alert.license_id }}')">
|
||||
<i class="bi bi-shield-lock"></i> Lizenz blockieren
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="text-success text-center">
|
||||
<i class="bi bi-check-circle-fill"></i> Gelöst
|
||||
{% if alert.resolved_at %}
|
||||
<div class="small">{{ alert.resolved_at }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-shield-check" style="font-size: 4rem; color: #28a745;"></i>
|
||||
<h4 class="mt-3">Keine aktiven Alerts</h4>
|
||||
<p class="text-muted">Alle Systeme laufen normal</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Filter alerts by severity
|
||||
function filterAlerts(severity) {
|
||||
// Update active pill
|
||||
document.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Filter alert cards
|
||||
document.querySelectorAll('.alert-card').forEach(card => {
|
||||
if (severity === 'all' || card.dataset.severity === severity) {
|
||||
card.style.display = 'block';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve alert
|
||||
async function resolveAlert(alertId) {
|
||||
if (!confirm('Möchten Sie diesen Alert als gelöst markieren?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/alerts/${alertId}/resolve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Alert wurde als gelöst markiert');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Markieren des Alerts');
|
||||
}
|
||||
}
|
||||
|
||||
// Investigate alert
|
||||
function investigateAlert(alertId) {
|
||||
// In production, this would open a detailed investigation view
|
||||
alert('Detaillierte Untersuchung wird geöffnet...');
|
||||
}
|
||||
|
||||
// Block license
|
||||
async function blockLicense(licenseId) {
|
||||
if (!confirm('WARNUNG: Möchten Sie diese Lizenz wirklich blockieren? Der Kunde kann die Software nicht mehr nutzen!')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/licenses/${licenseId}/block`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Lizenz wurde blockiert');
|
||||
location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Fehler beim Blockieren der Lizenz');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh alerts every 60 seconds
|
||||
setInterval(() => {
|
||||
location.reload();
|
||||
}, 60000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
453
v2_adminpanel/templates/monitoring/analytics.html
Normale Datei
453
v2_adminpanel/templates/monitoring/analytics.html
Normale Datei
@ -0,0 +1,453 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Analytics & Lizenzserver Status{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Analytics Styles */
|
||||
.analytics-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.date-range-selector {
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* License Monitor Styles */
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.validation-timeline {
|
||||
height: 300px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-bar-chart-line"></i> Analytics & Lizenzserver Status</h1>
|
||||
<div>
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-3" onclick="toggleAutoRefresh()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Auto-Refresh: <span id="refresh-status">AN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Selector -->
|
||||
<div class="date-range-selector">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<label>Zeitraum auswählen:</label>
|
||||
<select class="form-select" id="date-range" onchange="updateAnalytics()">
|
||||
<option value="today">Heute</option>
|
||||
<option value="week" selected>Letzte 7 Tage</option>
|
||||
<option value="month">Letzte 30 Tage</option>
|
||||
<option value="quarter">Letztes Quartal</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 text-end">
|
||||
<button class="btn btn-outline-primary" onclick="refreshAnalytics()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="active-licenses">
|
||||
{{ live_stats[0] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-validations">
|
||||
{{ live_stats[1] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-devices">
|
||||
{{ live_stats[2] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-ips">
|
||||
{{ live_stats[3] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Validation Timeline -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge badge-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="analytics-card">
|
||||
<h5>Berichte exportieren</h5>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
|
||||
<i class="bi bi-file-excel"></i> Excel Export
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="exportReport('csv')">
|
||||
<i class="bi bi-file-text"></i> CSV Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let autoRefresh = true;
|
||||
let refreshInterval;
|
||||
let validationChart;
|
||||
|
||||
// Initialize validation chart
|
||||
const ctx = document.getElementById('validationChart').getContext('2d');
|
||||
validationChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: [],
|
||||
borderColor: 'rgb(40, 167, 69)',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update chart with validation rates
|
||||
{% if validation_rates %}
|
||||
const rates = {{ validation_rates|tojson }};
|
||||
validationChart.data.labels = rates.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse();
|
||||
validationChart.data.datasets[0].data = rates.map(r => r[1]).reverse();
|
||||
validationChart.update();
|
||||
{% endif %}
|
||||
|
||||
function loadAnalyticsData() {
|
||||
// Load basic statistics from database
|
||||
fetch('/monitoring/api/live-stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses || '0';
|
||||
document.getElementById('total-validations').textContent = data.validations_last_minute || '0';
|
||||
document.getElementById('unique-devices').textContent = data.active_devices || '0';
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading analytics:', error);
|
||||
document.getElementById('active-licenses').textContent = '0';
|
||||
document.getElementById('total-validations').textContent = '0';
|
||||
document.getElementById('unique-devices').textContent = '0';
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch live statistics
|
||||
function fetchLiveStats() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update statistics
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses;
|
||||
document.getElementById('total-validations').textContent = data.validations_per_minute;
|
||||
document.getElementById('unique-devices').textContent = data.active_devices;
|
||||
|
||||
// Update validation stream
|
||||
const stream = document.getElementById('validation-stream');
|
||||
const newEntries = data.latest_validations.map(v =>
|
||||
`<div class="d-flex justify-content-between border-bottom py-2">
|
||||
<span>
|
||||
<code>${v.license_key}</code> |
|
||||
<span class="text-muted">${v.hardware_id}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-secondary">${v.ip_address}</span>
|
||||
<span class="text-muted ms-2">${v.timestamp}</span>
|
||||
</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (newEntries) {
|
||||
stream.innerHTML = newEntries + stream.innerHTML;
|
||||
// Keep only last 20 entries
|
||||
const entries = stream.querySelectorAll('div');
|
||||
if (entries.length > 20) {
|
||||
for (let i = 20; i < entries.length; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching live stats:', error));
|
||||
}
|
||||
|
||||
// Toggle auto-refresh
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = !autoRefresh;
|
||||
document.getElementById('refresh-status').textContent = autoRefresh ? 'AN' : 'AUS';
|
||||
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
} else {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnalytics() {
|
||||
const range = document.getElementById('date-range').value;
|
||||
console.log('Updating analytics for range:', range);
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function refreshAnalytics() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function exportReport(format) {
|
||||
// Redirect to export endpoint with format parameter
|
||||
const hours = 24; // Default to 24 hours
|
||||
window.location.href = `/export/monitoring?format=${format}&hours=${hours}`;
|
||||
}
|
||||
|
||||
// Start auto-refresh
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadAnalyticsData, 30000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
698
v2_adminpanel/templates/monitoring/live_dashboard.html
Normale Datei
698
v2_adminpanel/templates/monitoring/live_dashboard.html
Normale Datei
@ -0,0 +1,698 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Live Dashboard & Analytics{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Combined styles from both dashboards */
|
||||
.stats-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stats-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #28a745;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.session-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.activity-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 5px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.activity-active {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.activity-recent {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.activity-inactive {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.geo-info {
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
background: #f8f9fa;
|
||||
padding: 5px 10px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.85rem;
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* Analytics specific styles */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--bs-primary);
|
||||
background: none;
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-activity"></i> Live Dashboard & Analytics</h2>
|
||||
<p class="text-muted">Echtzeit-Übersicht und Analyse der Lizenznutzung</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<span class="text-muted ms-3">Auto-Refresh: <span id="refresh-countdown">30</span>s</span>
|
||||
<button class="btn btn-sm btn-outline-primary ms-2" onclick="refreshData()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-primary" id="active-licenses">{{ live_stats[0] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-success" id="total-validations">{{ live_stats[1] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-laptop text-info" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-info" id="unique-devices">{{ live_stats[2] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-globe text-warning" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-warning" id="unique-ips">{{ live_stats[3] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Interface -->
|
||||
<ul class="nav nav-tabs" id="dashboardTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
|
||||
<i class="bi bi-speedometer2"></i> Übersicht
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sessions-tab" data-bs-toggle="tab" data-bs-target="#sessions" type="button" role="tab">
|
||||
<i class="bi bi-people"></i> Aktive Sessions
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="analytics-tab" data-bs-toggle="tab" data-bs-target="#analytics" type="button" role="tab">
|
||||
<i class="bi bi-bar-chart-line"></i> Analytics
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="dashboardTabContent">
|
||||
<!-- Overview Tab -->
|
||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Activity Timeline Chart -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Aktivität (letzte 60 Minuten)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="activityChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Tab -->
|
||||
<div class="tab-pane fade" id="sessions" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions (letzte 5 Minuten)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="active-sessions-container">
|
||||
{% for session in active_sessions %}
|
||||
<div class="session-card">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-1">
|
||||
<span class="activity-indicator activity-active"></span>
|
||||
{{ session.company_name }}
|
||||
</h6>
|
||||
<small class="text-muted">{{ session.contact_person }}</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-1">
|
||||
<i class="bi bi-key"></i> {{ session.license_key[:8] }}...
|
||||
</div>
|
||||
<div class="device-info">
|
||||
<i class="bi bi-laptop"></i> {{ session.active_devices }} Gerät(e)
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="geo-info">
|
||||
<i class="bi bi-geo-alt"></i> {{ session.ip_address }}
|
||||
<div><small>Hardware: {{ session.hardware_id[:12] }}...</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<div class="text-muted">
|
||||
<i class="bi bi-clock"></i>
|
||||
<span class="last-activity" data-timestamp="{{ session.last_activity }}">
|
||||
vor wenigen Sekunden
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div class="tab-pane fade" id="analytics" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute (30 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Berichte exportieren</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label>Zeitraum auswählen:</label>
|
||||
<select class="form-select" id="date-range" onchange="updateAnalytics()">
|
||||
<option value="today">Heute</option>
|
||||
<option value="week" selected>Letzte 7 Tage</option>
|
||||
<option value="month">Letzte 30 Tage</option>
|
||||
<option value="quarter">Letztes Quartal</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label> </label>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
|
||||
<i class="bi bi-file-pdf"></i> PDF Export
|
||||
</button>
|
||||
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
|
||||
<i class="bi bi-file-excel"></i> Excel Export
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="exportReport('csv')">
|
||||
<i class="bi bi-file-text"></i> CSV Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden validation timeline data for chart -->
|
||||
<script id="validation-timeline-data" type="application/json">
|
||||
{{ validation_timeline|tojson }}
|
||||
</script>
|
||||
<script id="validation-rates-data" type="application/json">
|
||||
{{ validation_rates|tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
|
||||
<script>
|
||||
let activityChart;
|
||||
let validationChart;
|
||||
let refreshInterval;
|
||||
let refreshCountdown = 30;
|
||||
|
||||
// Initialize activity chart
|
||||
function initActivityChart() {
|
||||
const ctx = document.getElementById('activityChart').getContext('2d');
|
||||
const timelineData = JSON.parse(document.getElementById('validation-timeline-data').textContent);
|
||||
|
||||
// Prepare data for chart
|
||||
const labels = timelineData.map(item => {
|
||||
const date = new Date(item.minute);
|
||||
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}).reverse();
|
||||
|
||||
const data = timelineData.map(item => item.validations).reverse();
|
||||
|
||||
activityChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: data,
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize validation chart
|
||||
function initValidationChart() {
|
||||
const ctx = document.getElementById('validationChart').getContext('2d');
|
||||
const ratesData = JSON.parse(document.getElementById('validation-rates-data').textContent);
|
||||
|
||||
validationChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ratesData.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse(),
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: ratesData.map(r => r[1]).reverse(),
|
||||
borderColor: 'rgb(40, 167, 69)',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last activity times
|
||||
function updateActivityTimes() {
|
||||
document.querySelectorAll('.last-activity').forEach(el => {
|
||||
const timestamp = new Date(el.dataset.timestamp);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now - timestamp) / 1000);
|
||||
|
||||
if (seconds < 60) {
|
||||
el.textContent = 'vor wenigen Sekunden';
|
||||
} else if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
el.textContent = `vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`;
|
||||
} else {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
el.textContent = `vor ${hours} Stunde${hours > 1 ? 'n' : ''}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh data via AJAX
|
||||
async function refreshData() {
|
||||
try {
|
||||
// Get live stats
|
||||
const statsResponse = await fetch('/monitoring/api/live-stats');
|
||||
const stats = await statsResponse.json();
|
||||
|
||||
// Update stats cards
|
||||
document.getElementById('active-licenses').textContent = stats.active_licenses || 0;
|
||||
document.getElementById('unique-devices').textContent = stats.active_devices || 0;
|
||||
document.getElementById('total-validations').textContent = stats.validations_last_minute || 0;
|
||||
|
||||
// Get active sessions
|
||||
const sessionsResponse = await fetch('/monitoring/api/active-sessions');
|
||||
const sessions = await sessionsResponse.json();
|
||||
|
||||
// Update sessions display
|
||||
updateSessionsDisplay(sessions);
|
||||
|
||||
// Fetch live stats for validation stream
|
||||
fetchLiveStats();
|
||||
|
||||
// Reset countdown
|
||||
refreshCountdown = 30;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error refreshing data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update sessions display
|
||||
function updateSessionsDisplay(sessions) {
|
||||
const container = document.getElementById('active-sessions-container');
|
||||
|
||||
if (sessions.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionsHtml = sessions.map(session => {
|
||||
const secondsAgo = Math.floor(session.seconds_ago);
|
||||
let activityClass = 'activity-active';
|
||||
if (secondsAgo > 120) activityClass = 'activity-recent';
|
||||
if (secondsAgo > 240) activityClass = 'activity-inactive';
|
||||
|
||||
return `
|
||||
<div class="session-card">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-1">
|
||||
<span class="activity-indicator ${activityClass}"></span>
|
||||
${session.company_name}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-1">
|
||||
<i class="bi bi-key"></i> ${session.license_key.substring(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="geo-info">
|
||||
<i class="bi bi-geo-alt"></i> ${session.ip_address}
|
||||
<div><small>Hardware: ${session.hardware_id.substring(0, 12)}...</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<div class="text-muted">
|
||||
<i class="bi bi-clock"></i>
|
||||
vor ${formatSecondsAgo(secondsAgo)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = sessionsHtml;
|
||||
}
|
||||
|
||||
// Fetch live statistics
|
||||
function fetchLiveStats() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update validation stream
|
||||
const stream = document.getElementById('validation-stream');
|
||||
const newEntries = data.latest_validations.map(v =>
|
||||
`<div class="d-flex justify-content-between border-bottom py-2">
|
||||
<span>
|
||||
<code>${v.license_key}</code> |
|
||||
<span class="text-muted">${v.hardware_id}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-secondary">${v.ip_address}</span>
|
||||
<span class="text-muted ms-2">${v.timestamp}</span>
|
||||
</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (newEntries) {
|
||||
stream.innerHTML = newEntries + stream.innerHTML;
|
||||
// Keep only last 20 entries
|
||||
const entries = stream.querySelectorAll('div');
|
||||
if (entries.length > 20) {
|
||||
for (let i = 20; i < entries.length; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching live stats:', error));
|
||||
}
|
||||
|
||||
// Format seconds ago
|
||||
function formatSecondsAgo(seconds) {
|
||||
if (seconds < 60) return 'wenigen Sekunden';
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes} Minute${minutes > 1 ? 'n' : ''}`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
return `${hours} Stunde${hours > 1 ? 'n' : ''}`;
|
||||
}
|
||||
|
||||
function updateAnalytics() {
|
||||
const range = document.getElementById('date-range').value;
|
||||
console.log('Updating analytics for range:', range);
|
||||
refreshData();
|
||||
}
|
||||
|
||||
function exportReport(format) {
|
||||
// Redirect to export endpoint with format parameter
|
||||
const hours = 24; // Default to 24 hours
|
||||
window.location.href = `/export/monitoring?format=${format}&hours=${hours}`;
|
||||
}
|
||||
|
||||
// Countdown timer
|
||||
function updateCountdown() {
|
||||
refreshCountdown--;
|
||||
document.getElementById('refresh-countdown').textContent = refreshCountdown;
|
||||
|
||||
if (refreshCountdown <= 0) {
|
||||
refreshData();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initActivityChart();
|
||||
initValidationChart();
|
||||
updateActivityTimes();
|
||||
|
||||
// Set up auto-refresh
|
||||
refreshInterval = setInterval(() => {
|
||||
updateCountdown();
|
||||
updateActivityTimes();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Clean up on page leave
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
609
v2_adminpanel/templates/monitoring/unified_monitoring.html
Normale Datei
609
v2_adminpanel/templates/monitoring/unified_monitoring.html
Normale Datei
@ -0,0 +1,609 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Monitoring{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Header Status Bar */
|
||||
.status-header {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
padding: 0.75rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
margin: -1rem -1rem 1rem -1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator .badge {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Metric Cards */
|
||||
.metric-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-trend {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.metric-alert {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #dc3545;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Activity Stream */
|
||||
.activity-stream {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.activity-item.validation {
|
||||
border-left: 3px solid #28a745;
|
||||
}
|
||||
|
||||
.activity-item.anomaly-warning {
|
||||
border-left: 3px solid #ffc107;
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
.activity-item.anomaly-critical {
|
||||
border-left: 3px solid #dc3545;
|
||||
background-color: #f8d7da;
|
||||
}
|
||||
|
||||
/* Statistics Panel */
|
||||
.stats-panel {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.license-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
}
|
||||
|
||||
.license-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.analysis-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.analysis-tabs .nav-link.active {
|
||||
color: #0d6efd;
|
||||
border-bottom-color: #0d6efd;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Expandable sections */
|
||||
.expandable-section {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.expandable-header {
|
||||
background: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expandable-header:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.expandable-content {
|
||||
padding: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.expandable-content.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Auto-refresh indicator */
|
||||
.refresh-indicator {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.refresh-indicator.active {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.metric-card {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.activity-stream {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid p-3">
|
||||
<!-- Status Header -->
|
||||
<div class="status-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="status-indicator">
|
||||
{% if system_status == 'normal' %}
|
||||
<span class="text-success">🟢 System Normal</span>
|
||||
{% elif system_status == 'warning' %}
|
||||
<span class="text-warning">🟡 System Warning</span>
|
||||
{% else %}
|
||||
<span class="text-danger">🔴 System Critical</span>
|
||||
{% endif %}
|
||||
<span class="badge bg-{{ status_color }}">{{ active_alerts }} Aktive Alerts</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="text-muted">Letzte Aktualisierung: <span id="last-update">jetzt</span></span>
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="auto-refresh" checked>
|
||||
<label class="form-check-label" for="auto-refresh">Auto-Refresh</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Executive Summary (Collapsible) -->
|
||||
<div class="expandable-section" id="executive-summary">
|
||||
<div class="expandable-header" onclick="toggleSection('executive-summary')">
|
||||
<h5 class="mb-0">📊 Executive Summary</h5>
|
||||
<i class="bi bi-chevron-down"></i>
|
||||
</div>
|
||||
<div class="expandable-content show">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Aktive Lizenzen</div>
|
||||
<div class="metric-value text-primary">{{ live_metrics.active_licenses or 0 }}</div>
|
||||
<div class="metric-trend text-success">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
{% if live_metrics.active_licenses > 100 %}
|
||||
<div class="metric-alert">!</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Validierungen (5 Min)</div>
|
||||
<div class="metric-value text-info">{{ live_metrics.total_validations or 0 }}</div>
|
||||
<div class="metric-trend text-muted">
|
||||
<i class="bi bi-dash"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Aktive Geräte</div>
|
||||
<div class="metric-value text-success">{{ live_metrics.unique_devices or 0 }}</div>
|
||||
<div class="metric-trend text-success">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Response Zeit</div>
|
||||
<div class="metric-value text-warning">{{ (live_metrics.avg_response_time or 0)|round(1) }}ms</div>
|
||||
<div class="metric-trend text-warning">
|
||||
<i class="bi bi-arrow-up"></i> <span class="trend-value">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-12">
|
||||
<canvas id="trend-chart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="row g-3">
|
||||
<!-- Activity Stream (Left Panel) -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">🔄 Activity Stream</h5>
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-secondary active" data-filter="all">Alle</button>
|
||||
<button type="button" class="btn btn-outline-success" data-filter="normal">Normal</button>
|
||||
<button type="button" class="btn btn-outline-warning" data-filter="warning">Warnungen</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-filter="critical">Kritisch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="activity-stream">
|
||||
{% for event in activity_stream %}
|
||||
<div class="activity-item {{ event.event_type }} {% if event.event_type == 'anomaly' %}anomaly-{{ event.severity }}{% endif %}"
|
||||
data-severity="{{ event.severity or 'normal' }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
{% if event.event_type == 'validation' %}
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-exclamation-triangle text-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2"></i>
|
||||
{% endif %}
|
||||
<strong>{{ event.customer_name or 'Unbekannt' }}</strong>
|
||||
<span class="text-muted ms-2">{{ event.license_key[:8] }}...</span>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{% if event.event_type == 'anomaly' %}
|
||||
<span class="badge bg-{{ 'warning' if event.severity == 'warning' else 'danger' }} me-2">
|
||||
{{ event.anomaly_type }}
|
||||
</span>
|
||||
{{ event.description }}
|
||||
{% else %}
|
||||
Validierung von {{ event.ip_address }} • Gerät: {{ event.hardware_id[:8] }}...
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">{{ event.timestamp.strftime('%H:%M:%S') if event.timestamp else '-' }}</small>
|
||||
{% if event.event_type == 'anomaly' and event.severity == 'critical' %}
|
||||
<div class="mt-1">
|
||||
<button class="btn btn-sm btn-danger" onclick="blockIP('{{ event.ip_address }}')">
|
||||
<i class="bi bi-slash-circle"></i> Block
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not activity_stream %}
|
||||
<div class="text-center text-muted p-5">
|
||||
<i class="bi bi-inbox fs-1"></i>
|
||||
<p class="mt-2">Keine Aktivitäten in den letzten 60 Minuten</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Panel (Right Panel) -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="stats-panel mb-3">
|
||||
<h6 class="mb-3">🏆 Top Aktive Lizenzen</h6>
|
||||
{% for license in top_licenses %}
|
||||
<div class="license-item">
|
||||
<div>
|
||||
<div class="fw-bold">{{ license.customer_name }}</div>
|
||||
<small class="text-muted">{{ license.device_count }} Geräte • {{ license.validation_count }} Validierungen</small>
|
||||
</div>
|
||||
{% if license.anomaly_count > 0 %}
|
||||
<span class="badge bg-warning">{{ license.anomaly_count }} ⚠️</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">OK</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not top_licenses %}
|
||||
<p class="text-muted text-center">Keine aktiven Lizenzen</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Anomaly Distribution -->
|
||||
<div class="stats-panel mb-3">
|
||||
<h6 class="mb-3">🎯 Anomalie-Verteilung</h6>
|
||||
<canvas id="anomaly-chart" height="200"></canvas>
|
||||
{% if not anomaly_distribution %}
|
||||
<p class="text-muted text-center">Keine Anomalien erkannt</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Geographic Distribution -->
|
||||
<div class="stats-panel">
|
||||
<h6 class="mb-3">🌍 Geografische Verteilung</h6>
|
||||
<div style="max-height: 200px; overflow-y: auto;">
|
||||
{% for geo in geo_data[:5] %}
|
||||
<div class="d-flex justify-content-between align-items-center py-1">
|
||||
<span class="text-truncate">{{ geo.ip_address }}</span>
|
||||
<span class="badge bg-secondary">{{ geo.request_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if geo_data|length > 5 %}
|
||||
<small class="text-muted">+{{ geo_data|length - 5 }} weitere IPs</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analysis Tabs -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs analysis-tabs card-header-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#patterns">🔍 Patterns</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#performance">⚡ Performance</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#forensics">🔬 Forensics</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#predictions">📈 Predictions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="patterns">
|
||||
<p class="text-muted">Ungewöhnliche Verhaltensmuster werden hier angezeigt...</p>
|
||||
</div>
|
||||
<div class="tab-pane" id="performance">
|
||||
<div class="chart-container">
|
||||
<canvas id="performance-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane" id="forensics">
|
||||
<p class="text-muted">Detaillierte Analyse spezifischer Lizenzen...</p>
|
||||
</div>
|
||||
<div class="tab-pane" id="predictions">
|
||||
<p class="text-muted">Vorhersagen und Kapazitätsplanung...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Auto-refresh functionality
|
||||
let refreshInterval;
|
||||
const AUTO_REFRESH_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
function startAutoRefresh() {
|
||||
if ($('#auto-refresh').is(':checked')) {
|
||||
refreshInterval = setInterval(() => {
|
||||
location.reload();
|
||||
}, AUTO_REFRESH_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
$('#auto-refresh').change(function() {
|
||||
if (this.checked) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle expandable sections
|
||||
function toggleSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const content = section.querySelector('.expandable-content');
|
||||
const icon = section.querySelector('.bi');
|
||||
|
||||
content.classList.toggle('show');
|
||||
icon.classList.toggle('bi-chevron-down');
|
||||
icon.classList.toggle('bi-chevron-up');
|
||||
}
|
||||
|
||||
// Filter activity stream
|
||||
document.querySelectorAll('[data-filter]').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const filter = this.dataset.filter;
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => btn.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.activity-item').forEach(item => {
|
||||
if (filter === 'all') {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
const severity = item.dataset.severity;
|
||||
item.style.display = severity === filter ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Block IP function
|
||||
function blockIP(ip) {
|
||||
if (confirm(`IP-Adresse ${ip} wirklich blockieren?`)) {
|
||||
// Implementation for blocking IP
|
||||
alert(`IP ${ip} wurde blockiert.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize charts
|
||||
const trendData = {{ trend_data|tojson }};
|
||||
const anomalyData = {{ anomaly_distribution|tojson }};
|
||||
const performanceData = {{ performance_data|tojson }};
|
||||
|
||||
// Trend Chart
|
||||
if (trendData && trendData.length > 0) {
|
||||
const trendCtx = document.getElementById('trend-chart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: trendData.map(d => new Date(d.hour).toLocaleTimeString('de-DE', {hour: '2-digit'})),
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: trendData.map(d => d.validations),
|
||||
borderColor: '#0d6efd',
|
||||
backgroundColor: 'rgba(13, 110, 253, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Anomaly Distribution Chart
|
||||
if (anomalyData && anomalyData.length > 0) {
|
||||
const anomalyCtx = document.getElementById('anomaly-chart').getContext('2d');
|
||||
new Chart(anomalyCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: anomalyData.map(d => d.anomaly_type),
|
||||
datasets: [{
|
||||
data: anomalyData.map(d => d.count),
|
||||
backgroundColor: ['#ffc107', '#dc3545', '#fd7e14', '#6f42c1']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Performance Chart
|
||||
if (performanceData && performanceData.length > 0) {
|
||||
const perfCtx = document.getElementById('performance-chart').getContext('2d');
|
||||
new Chart(perfCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: performanceData.map(d => new Date(d.minute).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})),
|
||||
datasets: [{
|
||||
label: 'Avg Response Time',
|
||||
data: performanceData.map(d => d.avg_response_time),
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: 'Max Response Time',
|
||||
data: performanceData.map(d => d.max_response_time),
|
||||
borderColor: '#dc3545',
|
||||
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Response Time (ms)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update last refresh time
|
||||
function updateLastRefresh() {
|
||||
const now = new Date();
|
||||
document.getElementById('last-update').textContent = now.toLocaleTimeString('de-DE');
|
||||
}
|
||||
|
||||
// Start auto-refresh on load
|
||||
startAutoRefresh();
|
||||
updateLastRefresh();
|
||||
</script>
|
||||
{% endblock %}
|
||||
216
v2_adminpanel/templates/profile.html
Normale Datei
216
v2_adminpanel/templates/profile.html
Normale Datei
@ -0,0 +1,216 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Benutzerprofil{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.profile-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.profile-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.profile-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.security-badge {
|
||||
font-size: 2rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #6c757d;
|
||||
box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25);
|
||||
}
|
||||
.password-strength {
|
||||
height: 4px;
|
||||
margin-top: 5px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.strength-very-weak { background-color: #dc3545; width: 20%; }
|
||||
.strength-weak { background-color: #fd7e14; width: 40%; }
|
||||
.strength-medium { background-color: #ffc107; width: 60%; }
|
||||
.strength-strong { background-color: #28a745; width: 80%; }
|
||||
.strength-very-strong { background-color: #0d6efd; width: 100%; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>👤 Benutzerprofil</h1>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- User Info Stats -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-primary">👤</div>
|
||||
<h5 class="card-title">{{ user.username }}</h5>
|
||||
<p class="text-muted mb-0">{{ user.email or 'Keine E-Mail angegeben' }}</p>
|
||||
<small class="text-muted">Mitglied seit: {{ user.created_at.strftime('%d.%m.%Y') if user.created_at else 'Unbekannt' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card profile-card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="profile-icon text-info">🔐</div>
|
||||
<h5 class="card-title">Sicherheitsstatus</h5>
|
||||
{% if user.totp_enabled %}
|
||||
<span class="badge bg-success">2FA Aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">2FA Inaktiv</span>
|
||||
{% endif %}
|
||||
<p class="text-muted mb-0 mt-2">
|
||||
<small>Letztes Passwort-Update:<br>{{ user.last_password_change.strftime('%d.%m.%Y') if user.last_password_change else 'Noch nie' }}</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Change Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔑</span>
|
||||
Passwort ändern
|
||||
</h5>
|
||||
<hr>
|
||||
<form method="POST" action="{{ url_for('auth.change_password') }}">
|
||||
<div class="mb-3">
|
||||
<label for="current_password" class="form-label">Aktuelles Passwort</label>
|
||||
<input type="password" class="form-control" id="current_password" name="current_password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="new_password" class="form-label">Neues Passwort</label>
|
||||
<input type="password" class="form-control" id="new_password" name="new_password" required minlength="8">
|
||||
<div class="password-strength" id="password-strength"></div>
|
||||
<div class="form-text" id="password-help">Mindestens 8 Zeichen</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Neues Passwort bestätigen</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">🔄 Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Card -->
|
||||
<div class="card profile-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title d-flex align-items-center">
|
||||
<span class="security-badge">🔐</span>
|
||||
Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h5>
|
||||
<hr>
|
||||
{% if user.totp_enabled %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-success">Aktiv</span></h6>
|
||||
<p class="text-muted mb-0">Ihr Account ist durch 2FA geschützt</p>
|
||||
</div>
|
||||
<div class="text-success" style="font-size: 3rem;">✅</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('auth.disable_2fa') }}" onsubmit="return confirm('Sind Sie sicher, dass Sie 2FA deaktivieren möchten? Dies verringert die Sicherheit Ihres Accounts.');">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Passwort zur Bestätigung</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required placeholder="Ihr aktuelles Passwort">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">🚫 2FA deaktivieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">Status: <span class="badge bg-warning text-dark">Inaktiv</span></h6>
|
||||
<p class="text-muted mb-0">Aktivieren Sie 2FA für zusätzliche Sicherheit</p>
|
||||
</div>
|
||||
<div class="text-warning" style="font-size: 3rem;">⚠️</div>
|
||||
</div>
|
||||
<p class="text-muted">
|
||||
Mit 2FA wird bei jeder Anmeldung zusätzlich ein Code aus Ihrer Authenticator-App benötigt.
|
||||
Dies schützt Ihren Account auch bei kompromittiertem Passwort.
|
||||
</p>
|
||||
<a href="{{ url_for('auth.setup_2fa') }}" class="btn btn-success">✨ 2FA einrichten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Password strength indicator
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const password = e.target.value;
|
||||
let strength = 0;
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.match(/[a-z]/) && password.match(/[A-Z]/)) strength++;
|
||||
if (password.match(/[0-9]/)) strength++;
|
||||
if (password.match(/[^a-zA-Z0-9]/)) strength++;
|
||||
|
||||
const strengthText = ['Sehr schwach', 'Schwach', 'Mittel', 'Stark', 'Sehr stark'];
|
||||
const strengthClass = ['strength-very-weak', 'strength-weak', 'strength-medium', 'strength-strong', 'strength-very-strong'];
|
||||
const textClass = ['text-danger', 'text-warning', 'text-warning', 'text-success', 'text-primary'];
|
||||
|
||||
const strengthBar = document.getElementById('password-strength');
|
||||
const helpText = document.getElementById('password-help');
|
||||
|
||||
if (password.length > 0) {
|
||||
strengthBar.className = `password-strength ${strengthClass[strength]}`;
|
||||
strengthBar.style.display = 'block';
|
||||
helpText.textContent = `Stärke: ${strengthText[strength]}`;
|
||||
helpText.className = `form-text ${textClass[strength]}`;
|
||||
} else {
|
||||
strengthBar.style.display = 'none';
|
||||
helpText.textContent = 'Mindestens 8 Zeichen';
|
||||
helpText.className = 'form-text';
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm password validation
|
||||
document.getElementById('confirm_password').addEventListener('input', function(e) {
|
||||
const password = document.getElementById('new_password').value;
|
||||
const confirm = e.target.value;
|
||||
|
||||
if (confirm.length > 0) {
|
||||
if (password !== confirm) {
|
||||
e.target.classList.add('is-invalid');
|
||||
e.target.setCustomValidity('Passwörter stimmen nicht überein');
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid');
|
||||
e.target.classList.add('is-valid');
|
||||
e.target.setCustomValidity('');
|
||||
}
|
||||
} else {
|
||||
e.target.classList.remove('is-invalid', 'is-valid');
|
||||
}
|
||||
});
|
||||
|
||||
// Also check when password field changes
|
||||
document.getElementById('new_password').addEventListener('input', function(e) {
|
||||
const confirm = document.getElementById('confirm_password');
|
||||
if (confirm.value.length > 0) {
|
||||
confirm.dispatchEvent(new Event('input'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
365
v2_adminpanel/templates/resource_history.html
Normale Datei
365
v2_adminpanel/templates/resource_history.html
Normale Datei
@ -0,0 +1,365 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Historie{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Resource Info Card */
|
||||
.resource-info-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Resource Value Display */
|
||||
.resource-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
display: inline-block;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Status Badge Large */
|
||||
.status-badge-large {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Timeline Styling */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 30px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(to bottom, #e9ecef 0%, #dee2e6 100%);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 80px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.timeline-content:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.timeline-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 15px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
border-width: 10px 10px 10px 0;
|
||||
border-color: transparent #fff transparent transparent;
|
||||
}
|
||||
|
||||
/* Action Icons */
|
||||
.action-icon {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.action-created { background-color: #d4edda; color: #155724; }
|
||||
.action-allocated { background-color: #cce5ff; color: #004085; }
|
||||
.action-deallocated { background-color: #d1ecf1; color: #0c5460; }
|
||||
.action-quarantined { background-color: #fff3cd; color: #856404; }
|
||||
.action-released { background-color: #d4edda; color: #155724; }
|
||||
.action-deleted { background-color: #f8d7da; color: #721c24; }
|
||||
|
||||
/* Details Box */
|
||||
.details-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-top: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: #212529;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Resource Historie</h1>
|
||||
<p class="text-muted mb-0">Detaillierte Aktivitätshistorie</p>
|
||||
</div>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück zur Übersicht
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Resource Info Card -->
|
||||
<div class="card resource-info-card mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">📋 Resource Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Main Resource Info -->
|
||||
<div class="text-center mb-4">
|
||||
<div class="mb-3">
|
||||
{% if resource.resource_type == 'domain' %}
|
||||
<span class="display-1">🌐</span>
|
||||
{% elif resource.resource_type == 'ipv4' %}
|
||||
<span class="display-1">🖥️</span>
|
||||
{% else %}
|
||||
<span class="display-1">📱</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="resource-value mb-3">{{ resource.resource_value }}</div>
|
||||
<div>
|
||||
{% if resource.status == 'available' %}
|
||||
<span class="status-badge-large badge bg-success">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge-large badge bg-primary">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge-large badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Info Grid -->
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Ressourcentyp</div>
|
||||
<div class="info-value">{{ resource.resource_type|upper }}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Erstellt am</div>
|
||||
<div class="info-value">
|
||||
{{ resource.created_at.strftime('%d.%m.%Y %H:%M') if resource.created_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">Status geändert</div>
|
||||
<div class="info-value">
|
||||
{{ resource.status_changed_at.strftime('%d.%m.%Y %H:%M') if resource.status_changed_at else '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if resource.allocated_to_license %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Zugewiesen an Lizenz</div>
|
||||
<div class="info-value">
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}"
|
||||
class="text-decoration-none">
|
||||
{{ license_info.license_key if license_info else 'ID: ' + resource.allocated_to_license|string }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_reason %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne-Grund</div>
|
||||
<div class="info-value">
|
||||
<span class="badge bg-warning text-dark">{{ resource.quarantine_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if resource.quarantine_until %}
|
||||
<div class="info-item">
|
||||
<div class="info-label">Quarantäne bis</div>
|
||||
<div class="info-value">
|
||||
{{ resource.quarantine_until.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if resource.notes %}
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info mb-0">
|
||||
<h6 class="alert-heading">📝 Notizen</h6>
|
||||
<p class="mb-0">{{ resource.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Timeline -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">⏱️ Aktivitäts-Historie</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if history %}
|
||||
<div class="timeline">
|
||||
{% for event in history %}
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-marker
|
||||
{% if event.action == 'created' %}bg-success
|
||||
{% elif event.action == 'allocated' %}bg-primary
|
||||
{% elif event.action == 'deallocated' %}bg-info
|
||||
{% elif event.action == 'quarantined' %}bg-warning
|
||||
{% elif event.action == 'released' %}bg-success
|
||||
{% elif event.action == 'deleted' %}bg-danger
|
||||
{% else %}bg-secondary{% endif %}">
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
{% if event.action == 'created' %}
|
||||
<span class="action-icon action-created">
|
||||
<i class="fas fa-plus"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource erstellt</h6>
|
||||
{% elif event.action == 'allocated' %}
|
||||
<span class="action-icon action-allocated">
|
||||
<i class="fas fa-link"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">An Lizenz zugeteilt</h6>
|
||||
{% elif event.action == 'deallocated' %}
|
||||
<span class="action-icon action-deallocated">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Von Lizenz freigegeben</h6>
|
||||
{% elif event.action == 'quarantined' %}
|
||||
<span class="action-icon action-quarantined">
|
||||
<i class="fas fa-ban"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">In Quarantäne gesetzt</h6>
|
||||
{% elif event.action == 'released' %}
|
||||
<span class="action-icon action-released">
|
||||
<i class="fas fa-check"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Aus Quarantäne entlassen</h6>
|
||||
{% elif event.action == 'deleted' %}
|
||||
<span class="action-icon action-deleted">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<h6 class="mb-0">Ressource gelöscht</h6>
|
||||
{% else %}
|
||||
<h6 class="mb-0">{{ event.action }}</h6>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-muted small">
|
||||
<i class="fas fa-user"></i> {{ event.action_by }}
|
||||
{% if event.ip_address %}
|
||||
• <i class="fas fa-globe"></i> {{ event.ip_address }}
|
||||
{% endif %}
|
||||
{% if event.license_id %}
|
||||
•
|
||||
<i class="fas fa-key"></i>
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=event.license_id) }}">
|
||||
Lizenz #{{ event.license_id }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.details %}
|
||||
<div class="details-box">
|
||||
<strong>Details:</strong>
|
||||
<pre class="mb-0" style="white-space: pre-wrap;">{{ event.details|tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-end ms-3">
|
||||
<div class="badge bg-light text-dark">
|
||||
<i class="far fa-calendar"></i> {{ event.action_at.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="far fa-clock"></i> {{ event.action_at.strftime('%H:%M:%S') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-history text-muted" style="font-size: 3rem; opacity: 0.5;"></i>
|
||||
<p class="text-muted mt-3">Keine Historie-Einträge vorhanden.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
559
v2_adminpanel/templates/resource_metrics.html
Normale Datei
559
v2_adminpanel/templates/resource_metrics.html
Normale Datei
@ -0,0 +1,559 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Metriken{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Metric Cards */
|
||||
.metric-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s ease;
|
||||
height: 100%;
|
||||
}
|
||||
.metric-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
.metric-card .card-body {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.metric-value {
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.metric-label {
|
||||
font-size: 1rem;
|
||||
color: #6c757d;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.metric-sublabel {
|
||||
font-size: 0.875rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Chart Cards */
|
||||
.chart-card {
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.chart-card .card-header {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Performance Table */
|
||||
.performance-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.performance-table td {
|
||||
vertical-align: middle;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.performance-table .resource-link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.performance-table .resource-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
/* Progress Bars */
|
||||
.progress-custom {
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Icon Badges */
|
||||
.icon-badge {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.icon-badge.blue { background-color: #e7f3ff; color: #0066cc; }
|
||||
.icon-badge.green { background-color: #e8f5e9; color: #2e7d32; }
|
||||
.icon-badge.orange { background-color: #fff3e0; color: #ef6c00; }
|
||||
.icon-badge.red { background-color: #ffebee; color: #c62828; }
|
||||
|
||||
/* Trend Indicator */
|
||||
.trend-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.trend-up {
|
||||
background-color: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.trend-down {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.trend-neutral {
|
||||
background-color: #f5f5f5;
|
||||
color: #616161;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0">Performance Dashboard</h1>
|
||||
<p class="text-muted mb-0">Resource Pool Metriken und Analysen</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('resources.resource_report') }}" class="btn btn-info">
|
||||
📄 Report generieren
|
||||
</a>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
← Zurück
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge blue">
|
||||
📊
|
||||
</div>
|
||||
<div class="metric-label">Ressourcen gesamt</div>
|
||||
<div class="metric-value text-primary">{{ stats.total_resources or 0 }}</div>
|
||||
<div class="metric-sublabel">Aktive Ressourcen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge green">
|
||||
📈
|
||||
</div>
|
||||
<div class="metric-label">Ø Performance</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.avg_performance > 80 else ('warning' if stats.avg_performance > 60 else 'danger') }}">
|
||||
{{ "%.1f"|format(stats.avg_performance or 0) }}%
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
{% if stats.performance_trend %}
|
||||
<div class="mt-2">
|
||||
<span class="trend-indicator trend-{{ stats.performance_trend }}">
|
||||
{% if stats.performance_trend == 'up' %}
|
||||
<i class="fas fa-arrow-up me-1"></i> Steigend
|
||||
{% elif stats.performance_trend == 'down' %}
|
||||
<i class="fas fa-arrow-down me-1"></i> Fallend
|
||||
{% else %}
|
||||
<i class="fas fa-minus me-1"></i> Stabil
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge orange">
|
||||
💰
|
||||
</div>
|
||||
<div class="metric-label">ROI</div>
|
||||
<div class="metric-value text-{{ 'success' if stats.roi > 1 else 'danger' }}">
|
||||
{{ "%.2f"|format(stats.roi) }}x
|
||||
</div>
|
||||
<div class="metric-sublabel">Revenue / Cost</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6">
|
||||
<div class="card metric-card">
|
||||
<div class="card-body">
|
||||
<div class="icon-badge red">
|
||||
⚠️
|
||||
</div>
|
||||
<div class="metric-label">Probleme</div>
|
||||
<div class="metric-value text-{{ 'danger' if stats.total_issues > 10 else ('warning' if stats.total_issues > 5 else 'success') }}">
|
||||
{{ stats.total_issues or 0 }}
|
||||
</div>
|
||||
<div class="metric-sublabel">Letzte 30 Tage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📊 Performance nach Ressourcentyp</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="performanceByTypeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">🎯 Auslastung nach Typ</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="utilizationChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Tables -->
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🏆 Top Performer</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="140">Score</th>
|
||||
<th width="80" class="text-center">ROI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in top_performers %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success"
|
||||
style="width: {{ resource.avg_score }}%">
|
||||
{{ "%.1f"|format(resource.avg_score) }}%
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-success">
|
||||
{{ "%.2f"|format(resource.roi) }}x
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not top_performers %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine Performance-Daten verfügbar
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card chart-card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h5 class="mb-0">⚠️ Problematische Ressourcen</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table performance-table mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Ressource</th>
|
||||
<th width="80">Typ</th>
|
||||
<th width="100" class="text-center">Probleme</th>
|
||||
<th width="120">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in problem_resources %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<a href="{{ url_for('resources.resource_history', resource_id=resource.id) }}"
|
||||
class="resource-link" title="Historie anzeigen">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark">
|
||||
{% if resource.resource_type == 'domain' %}🌐{% elif resource.resource_type == 'ipv4' %}🖥️{% else %}📱{% endif %}
|
||||
{{ resource.resource_type|upper }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-danger">
|
||||
{{ resource.total_issues }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.status == 'quarantine' %}
|
||||
<span class="status-badge bg-warning text-dark">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge bg-primary text-white">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge bg-success text-white">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not problem_resources %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
Keine problematischen Ressourcen gefunden
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Chart -->
|
||||
<div class="card chart-card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">📈 30-Tage Performance Trend</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="trendChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||
<script>
|
||||
// Chart defaults
|
||||
Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
// Performance by Type Chart
|
||||
const performanceCtx = document.getElementById('performanceByTypeChart').getContext('2d');
|
||||
new Chart(performanceCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {{ performance_by_type|map(attribute=0)|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Durchschnittliche Performance',
|
||||
data: {{ performance_by_type|map(attribute=1)|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: [
|
||||
'rgb(33, 150, 243)',
|
||||
'rgb(156, 39, 176)',
|
||||
'rgb(76, 175, 80)'
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
return value + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Utilization Chart
|
||||
const utilizationCtx = document.getElementById('utilizationChart').getContext('2d');
|
||||
new Chart(utilizationCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {{ utilization_data|map(attribute='type')|list|tojson }},
|
||||
datasets: [{
|
||||
data: {{ utilization_data|map(attribute='allocated_percent')|list|tojson }},
|
||||
backgroundColor: [
|
||||
'rgba(33, 150, 243, 0.8)',
|
||||
'rgba(156, 39, 176, 0.8)',
|
||||
'rgba(76, 175, 80, 0.8)'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
return context.label + ': ' + context.parsed + '% ausgelastet';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trend Chart
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {{ daily_metrics|map(attribute='date')|list|tojson }},
|
||||
datasets: [{
|
||||
label: 'Performance Score',
|
||||
data: {{ daily_metrics|map(attribute='performance')|list|tojson }},
|
||||
borderColor: 'rgb(76, 175, 80)',
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y',
|
||||
}, {
|
||||
label: 'Probleme',
|
||||
data: {{ daily_metrics|map(attribute='issues')|list|tojson }},
|
||||
borderColor: 'rgb(244, 67, 54)',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
tension: 0.4,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
yAxisID: 'y1',
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Performance %'
|
||||
},
|
||||
beginAtZero: true,
|
||||
max: 100,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'right',
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Anzahl Probleme'
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
drawOnChartArea: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
212
v2_adminpanel/templates/resource_report.html
Normale Datei
212
v2_adminpanel/templates/resource_report.html
Normale Datei
@ -0,0 +1,212 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Report Generator{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Resource Report Generator</h1>
|
||||
<a href="{{ url_for('resources.resources') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Zurück
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Report-Einstellungen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources.resource_report') }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="report_type" class="form-label">Report-Typ</label>
|
||||
<select name="type" id="report_type" class="form-select" required>
|
||||
<option value="usage">Auslastungsreport</option>
|
||||
<option value="performance">Performance-Report</option>
|
||||
<option value="compliance">Compliance-Report</option>
|
||||
<option value="inventory">Bestands-Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="format" class="form-label">Export-Format</label>
|
||||
<select name="format" id="format" class="form-select" required>
|
||||
<option value="excel">Excel (.xlsx)</option>
|
||||
<option value="csv">CSV (.csv)</option>
|
||||
<option value="pdf">PDF (Vorschau)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_from" class="form-label">Von</label>
|
||||
<input type="date" name="from" id="date_from" class="form-control"
|
||||
value="{{ (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="date_to" class="form-label">Bis</label>
|
||||
<input type="date" name="to" id="date_to" class="form-control"
|
||||
value="{{ datetime.now().strftime('%Y-%m-%d') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h6>Report-Beschreibungen:</h6>
|
||||
<div id="report_descriptions">
|
||||
<div class="alert alert-info report-desc" data-type="usage">
|
||||
<h6><i class="fas fa-chart-line"></i> Auslastungsreport</h6>
|
||||
<p class="mb-0">Zeigt die Nutzung aller Ressourcen im gewählten Zeitraum.
|
||||
Enthält Allokations-Historie, durchschnittliche Auslastung und Trends.</p>
|
||||
</div>
|
||||
<div class="alert alert-warning report-desc" data-type="performance" style="display: none;">
|
||||
<h6><i class="fas fa-tachometer-alt"></i> Performance-Report</h6>
|
||||
<p class="mb-0">Analysiert die Performance-Metriken aller Ressourcen.
|
||||
Enthält ROI-Berechnungen, Issue-Tracking und Performance-Scores.</p>
|
||||
</div>
|
||||
<div class="alert alert-success report-desc" data-type="compliance" style="display: none;">
|
||||
<h6><i class="fas fa-shield-alt"></i> Compliance-Report</h6>
|
||||
<p class="mb-0">Überprüft Compliance-Aspekte wie Quarantäne-Gründe,
|
||||
Sicherheitsvorfälle und Policy-Verletzungen.</p>
|
||||
</div>
|
||||
<div class="alert alert-primary report-desc" data-type="inventory" style="display: none;">
|
||||
<h6><i class="fas fa-boxes"></i> Bestands-Report</h6>
|
||||
<p class="mb-0">Aktueller Bestand aller Ressourcen mit Status-Übersicht,
|
||||
Verfügbarkeit und Zuordnungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="previewReport()">
|
||||
<i class="fas fa-eye"></i> Vorschau
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" name="download" value="true">
|
||||
<i class="fas fa-download"></i> Report generieren
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Reports -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte generierte Reports</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">Auslastungsreport_2025-06-01.xlsx</h6>
|
||||
<small>vor 5 Tagen</small>
|
||||
</div>
|
||||
<p class="mb-1">Zeitraum: 01.05.2025 - 01.06.2025</p>
|
||||
<small>Generiert von: {{ username }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vorschau Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Report-Vorschau</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="previewContent">
|
||||
<p class="text-center">
|
||||
<i class="fas fa-spinner fa-spin"></i> Lade Vorschau...
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Report-Typ Beschreibungen
|
||||
document.getElementById('report_type').addEventListener('change', function() {
|
||||
const selectedType = this.value;
|
||||
document.querySelectorAll('.report-desc').forEach(desc => {
|
||||
desc.style.display = desc.dataset.type === selectedType ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// Datum-Validierung
|
||||
document.getElementById('date_from').addEventListener('change', function() {
|
||||
document.getElementById('date_to').min = this.value;
|
||||
});
|
||||
|
||||
document.getElementById('date_to').addEventListener('change', function() {
|
||||
document.getElementById('date_from').max = this.value;
|
||||
});
|
||||
|
||||
// Report-Vorschau
|
||||
function previewReport() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
const content = document.getElementById('previewContent');
|
||||
|
||||
// Simuliere Lade-Vorgang
|
||||
content.innerHTML = '<p class="text-center"><i class="fas fa-spinner fa-spin"></i> Generiere Vorschau...</p>';
|
||||
modal.show();
|
||||
|
||||
setTimeout(() => {
|
||||
// Beispiel-Vorschau
|
||||
content.innerHTML = `
|
||||
<h5>Report-Vorschau</h5>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ressourcentyp</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Verfügbar</th>
|
||||
<th>Zugeteilt</th>
|
||||
<th>Auslastung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Domain</td>
|
||||
<td>150</td>
|
||||
<td>45</td>
|
||||
<td>100</td>
|
||||
<td>66.7%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>IPv4</td>
|
||||
<td>100</td>
|
||||
<td>20</td>
|
||||
<td>75</td>
|
||||
<td>75.0%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Phone</td>
|
||||
<td>50</td>
|
||||
<td>15</td>
|
||||
<td>30</td>
|
||||
<td>60.0%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-muted">Dies ist eine vereinfachte Vorschau. Der vollständige Report enthält weitere Details.</p>
|
||||
`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Initialisierung
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
document.getElementById('date_from').value = thirtyDaysAgo.toISOString().split('T')[0];
|
||||
document.getElementById('date_to').value = today.toISOString().split('T')[0];
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
896
v2_adminpanel/templates/resources.html
Normale Datei
896
v2_adminpanel/templates/resources.html
Normale Datei
@ -0,0 +1,896 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Resource Pool{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Statistik-Karten Design wie Dashboard */
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
height: 100%;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.stat-card .card-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.stat-card .card-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.stat-card .card-label {
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Resource Type Icons */
|
||||
.resource-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.resource-icon.domain {
|
||||
background-color: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
.resource-icon.ipv4 {
|
||||
background-color: #f3e5f5;
|
||||
color: #7b1fa2;
|
||||
}
|
||||
.resource-icon.phone {
|
||||
background-color: #e8f5e9;
|
||||
color: #388e3c;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-available {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.status-allocated {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.status-quarantine {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
/* Progress Bar Custom */
|
||||
.progress-custom {
|
||||
height: 25px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.progress-bar-custom {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-custom {
|
||||
border: none;
|
||||
}
|
||||
.table-custom thead th {
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
color: #495057;
|
||||
padding: 1rem;
|
||||
}
|
||||
.table-custom tbody tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.table-custom tbody tr:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table-custom td {
|
||||
padding: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.btn-action {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
margin: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-action:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Copy Button */
|
||||
.copy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6c757d;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
color: #28a745;
|
||||
}
|
||||
.copy-btn.copied {
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
/* Filter Card */
|
||||
.filter-card {
|
||||
background-color: #f8f9fa;
|
||||
border: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
.empty-state i {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Dropdown Verbesserungen */
|
||||
.dropdown-menu {
|
||||
min-width: 220px;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
border: none;
|
||||
}
|
||||
.dropdown-item {
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.dropdown-item i {
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Quick Action Buttons */
|
||||
.btn-success {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sortable Headers */
|
||||
thead th a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding-right: 20px;
|
||||
}
|
||||
thead th a:hover {
|
||||
color: #0d6efd !important;
|
||||
}
|
||||
thead th a i {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="mb-0">Ressourcen Pool</h1>
|
||||
<p class="text-muted mb-0">Verwalten Sie Domains, IPs und Telefonnummern</p>
|
||||
</div>
|
||||
|
||||
<!-- Test/Live Mode Toggle -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="showTestResources"
|
||||
{% if show_fake %}checked{% endif %}
|
||||
onchange="toggleTestResources()">
|
||||
<label class="form-check-label" for="showTestResources">
|
||||
Fake-Ressourcen anzeigen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="row g-4 mb-4">
|
||||
{% for type, data in stats.items() %}
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="card-icon">
|
||||
{% if type == 'domain' %}
|
||||
🌐
|
||||
{% elif type == 'ipv4' %}
|
||||
🖥️
|
||||
{% else %}
|
||||
📱
|
||||
{% endif %}
|
||||
</div>
|
||||
<h5 class="text-muted mb-2">{{ type|upper }}</h5>
|
||||
<div class="card-value text-primary">{{ data.available }}</div>
|
||||
<div class="card-label text-muted mb-3">von {{ data.total }} verfügbar</div>
|
||||
|
||||
<div class="progress progress-custom">
|
||||
<div class="progress-bar bg-success progress-bar-custom"
|
||||
style="width: {{ data.available_percent }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.available }} verfügbar">
|
||||
{{ data.available_percent }}%
|
||||
</div>
|
||||
<div class="progress-bar bg-info progress-bar-custom"
|
||||
style="width: {{ (data.allocated / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.allocated }} zugeteilt">
|
||||
{% if data.allocated > 0 %}{{ data.allocated }}{% endif %}
|
||||
</div>
|
||||
<div class="progress-bar bg-warning progress-bar-custom"
|
||||
style="width: {{ (data.quarantined / data.total * 100) if data.total > 0 else 0 }}%"
|
||||
data-bs-toggle="tooltip"
|
||||
title="{{ data.quarantined }} in Quarantäne">
|
||||
{% if data.quarantined > 0 %}{{ data.quarantined }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
{% if data.available_percent < 20 %}
|
||||
<span class="badge bg-danger">⚠️ Niedriger Bestand</span>
|
||||
{% elif data.available_percent < 50 %}
|
||||
<span class="badge bg-warning text-dark">⚡ Bestand prüfen</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">✅ Gut gefüllt</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card filter-card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('resources.resources') }}" id="filterForm">
|
||||
<input type="hidden" name="show_fake" value="{{ 'true' if show_fake else 'false' }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label for="type" class="form-label">🏷️ Typ</label>
|
||||
<select name="type" id="type" class="form-select">
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="domain" {% if resource_type == 'domain' %}selected{% endif %}>🌐 Domain</option>
|
||||
<option value="ipv4" {% if resource_type == 'ipv4' %}selected{% endif %}>🖥️ IPv4</option>
|
||||
<option value="phone" {% if resource_type == 'phone' %}selected{% endif %}>📱 Telefon</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="status" class="form-label">📊 Status</label>
|
||||
<select name="status" id="status" class="form-select">
|
||||
<option value="">Alle Status</option>
|
||||
<option value="available" {% if status_filter == 'available' %}selected{% endif %}>✅ Verfügbar</option>
|
||||
<option value="allocated" {% if status_filter == 'allocated' %}selected{% endif %}>🔗 Zugeteilt</option>
|
||||
<option value="quarantine" {% if status_filter == 'quarantine' %}selected{% endif %}>⚠️ Quarantäne</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">🔍 Suche</label>
|
||||
<input type="text" name="search" id="search" class="form-control"
|
||||
placeholder="Ressource suchen..." value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label"> </label>
|
||||
<a href="{{ url_for('resources.resources', show_fake=show_fake) }}" class="btn btn-secondary w-100">
|
||||
🔄 Zurücksetzen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ressourcen-Tabelle -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">📋 Ressourcen-Liste</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
id="exportDropdown"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-download"></i> Export
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="exportDropdown">
|
||||
<li><a class="dropdown-item" href="{{ url_for('resources.resources_report', format='excel', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}">
|
||||
<i class="bi bi-file-earmark-excel text-success"></i> Excel Export</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="badge bg-secondary">{{ total }} Einträge</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if resources %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-custom mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="80">
|
||||
<a href="{{ url_for('resources.resources', sort='id', order='desc' if sort_by == 'id' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
ID
|
||||
{% if sort_by == 'id' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="120">
|
||||
<a href="{{ url_for('resources.resources', sort='type', order='desc' if sort_by == 'type' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Typ
|
||||
{% if sort_by == 'type' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="{{ url_for('resources.resources', sort='resource', order='desc' if sort_by == 'resource' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Ressource
|
||||
{% if sort_by == 'resource' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="140">
|
||||
<a href="{{ url_for('resources.resources', sort='status', order='desc' if sort_by == 'status' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Status
|
||||
{% if sort_by == 'status' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="{{ url_for('resources.resources', sort='assigned', order='desc' if sort_by == 'assigned' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Zugewiesen an
|
||||
{% if sort_by == 'assigned' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="180">
|
||||
<a href="{{ url_for('resources.resources', sort='changed', order='desc' if sort_by == 'changed' and sort_order == 'asc' else 'asc', type=resource_type, status=status_filter, search=search, show_fake=show_fake) }}"
|
||||
class="text-decoration-none text-dark sort-link">
|
||||
Letzte Änderung
|
||||
{% if sort_by == 'changed' %}
|
||||
<i class="bi bi-caret-{{ 'up' if sort_order == 'asc' else 'down' }}-fill"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-caret-down-fill text-muted opacity-25"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th width="200" class="text-center">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for resource in resources %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="text-muted">#{{ resource.id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="resource-icon {{ resource.resource_type }}">
|
||||
{% if resource.resource_type == 'domain' %}
|
||||
🌐
|
||||
{% elif resource.resource_type == 'ipv4' %}
|
||||
🖥️
|
||||
{% else %}
|
||||
📱
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<code class="me-2">{{ resource.resource_value }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('{{ resource.resource_value }}', this)"
|
||||
title="Kopieren">
|
||||
<i class="bi bi-clipboard"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.status == 'available' %}
|
||||
<span class="status-badge status-available">
|
||||
✅ Verfügbar
|
||||
</span>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<span class="status-badge status-allocated">
|
||||
🔗 Zugeteilt
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="status-badge status-quarantine">
|
||||
⚠️ Quarantäne
|
||||
</span>
|
||||
{% if resource.status_changed_by %}
|
||||
<div class="small text-muted mt-1">{{ resource.status_changed_by }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.customer_name %}
|
||||
<div>
|
||||
<a href="{{ url_for('customers.customers_licenses', show_fake=show_fake) }}"
|
||||
class="text-decoration-none">
|
||||
<strong>{{ resource.customer_name }}</strong>
|
||||
</a>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<a href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}?ref=resources{{ '&show_fake=true' if show_fake else '' }}"
|
||||
class="text-decoration-none text-muted">
|
||||
{{ resource.allocated_to_license }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if resource.status_changed_at %}
|
||||
<div class="small">
|
||||
<div>{{ resource.status_changed_at.strftime('%d.%m.%Y') }}</div>
|
||||
<div class="text-muted">{{ resource.status_changed_at.strftime('%H:%M Uhr') }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if resource.status == 'quarantine' %}
|
||||
<!-- Quick Action für Quarantäne -->
|
||||
<form method="post" action="{{ url_for('resources.release', show_fake=show_fake, type=resource_type, status=status_filter, search=search) }}"
|
||||
style="display: inline-block; margin-right: 5px;">
|
||||
<input type="hidden" name="resource_ids" value="{{ resource.id }}">
|
||||
<input type="hidden" name="show_fake" value="{{ show_fake }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm btn-success">
|
||||
<i class="bi bi-check-circle"></i> Freigeben
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Dropdown für weitere Aktionen -->
|
||||
<div class="dropdown" style="display: inline-block;">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
||||
type="button"
|
||||
id="dropdownMenuButton{{ resource.id }}"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-three-dots-vertical"></i> Aktionen
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton{{ resource.id }}">
|
||||
<!-- Historie immer verfügbar -->
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('resources.resource_history', resource_id=resource.id) }}">
|
||||
<i class="bi bi-clock-history text-info"></i> Historie anzeigen
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{% if resource.status == 'available' %}
|
||||
<!-- Aktionen für verfügbare Ressourcen -->
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="showQuarantineModal({{ resource.id }})">
|
||||
<i class="bi bi-exclamation-triangle text-warning"></i> In Quarantäne setzen
|
||||
</button>
|
||||
</li>
|
||||
{% elif resource.status == 'allocated' %}
|
||||
<!-- Aktionen für zugeteilte Ressourcen -->
|
||||
{% if resource.allocated_to_license %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('licenses.edit_license', license_id=resource.allocated_to_license) }}?ref=resources{{ '&show_fake=true' if show_fake else '' }}">
|
||||
<i class="bi bi-file-text text-primary"></i> Lizenz bearbeiten
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if resource.id %}
|
||||
<li>
|
||||
<a class="dropdown-item"
|
||||
href="{{ url_for('customers.customers_licenses', customer_id=resource.id, show_fake=show_fake) }}">
|
||||
<i class="bi bi-person text-primary"></i> Kunde anzeigen
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% elif resource.status == 'quarantine' %}
|
||||
<!-- Aktionen für Quarantäne-Ressourcen -->
|
||||
<li>
|
||||
<form method="post" action="{{ url_for('resources.release', show_fake=show_fake, type=resource_type, status=status_filter, search=search) }}"
|
||||
style="display: contents;">
|
||||
<input type="hidden" name="resource_ids" value="{{ resource.id }}">
|
||||
<input type="hidden" name="show_fake" value="{{ show_fake }}">
|
||||
<button type="submit" class="dropdown-item">
|
||||
<i class="bi bi-check-circle text-success"></i> Ressource freigeben
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% if resource[9] %}
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="extendQuarantine({{ resource.id }})">
|
||||
<i class="bi bi-calendar-plus text-warning"></i> Quarantäne verlängern
|
||||
</button>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
<!-- Kopieren immer verfügbar -->
|
||||
<li>
|
||||
<button class="dropdown-item"
|
||||
onclick="copyToClipboard('{{ resource.resource_value }}', this)">
|
||||
<i class="bi bi-clipboard text-secondary"></i> Ressource kopieren
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<h4>Keine Ressourcen gefunden</h4>
|
||||
<p>Ändern Sie Ihre Filterkriterien oder fügen Sie neue Ressourcen hinzu.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<nav class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources.resources', page=1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
|
||||
<i class="bi bi-chevron-double-left"></i> Erste
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item {% if page == 1 %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources.resources', page=page-1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
|
||||
<i class="bi bi-chevron-left"></i> Zurück
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page or (p >= page - 2 and p <= page + 2) %}
|
||||
<li class="page-item {% if p == page %}active{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources.resources', page=p, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
|
||||
{{ p }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources.resources', page=page+1, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
|
||||
Weiter <i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item {% if page == total_pages %}disabled{% endif %}">
|
||||
<a class="page-link"
|
||||
href="{{ url_for('resources.resources', page=total_pages, type=resource_type, status=status_filter, search=search, show_fake=show_fake, sort=sort_by, order=sort_order) }}">
|
||||
Letzte <i class="bi bi-chevron-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Kürzliche Aktivitäten -->
|
||||
{% if recent_activities %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">⏰ Kürzliche Aktivitäten</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="timeline">
|
||||
{% for activity in recent_activities %}
|
||||
<div class="d-flex mb-3">
|
||||
<div class="me-3">
|
||||
{% if activity[0] == 'created' %}
|
||||
<span class="badge bg-success rounded-pill">➕</span>
|
||||
{% elif activity[0] == 'allocated' %}
|
||||
<span class="badge bg-info rounded-pill">🔗</span>
|
||||
{% elif activity[0] == 'deallocated' %}
|
||||
<span class="badge bg-secondary rounded-pill">🔓</span>
|
||||
{% elif activity[0] == 'quarantined' %}
|
||||
<span class="badge bg-warning rounded-pill">⚠️</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary rounded-pill">ℹ️</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>{{ activity[4] }}</strong> ({{ activity[3] }}) - {{ activity[0] }}
|
||||
{% if activity[1] %}
|
||||
<span class="text-muted">von {{ activity[1] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
{{ activity[2].strftime('%d.%m.%Y %H:%M') if activity[2] else '' }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Quarantäne Modal -->
|
||||
<div class="modal fade" id="quarantineModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form method="post" id="quarantineForm">
|
||||
<!-- Filter-Parameter als Hidden Fields -->
|
||||
<input type="hidden" name="show_fake" value="{{ show_fake }}">
|
||||
<input type="hidden" name="type" value="{{ resource_type }}">
|
||||
<input type="hidden" name="status" value="{{ status_filter }}">
|
||||
<input type="hidden" name="search" value="{{ search }}">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">⚠️ Ressource in Quarantäne setzen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="reason" class="form-label">Grund</label>
|
||||
<select name="reason" id="reason" class="form-select" required>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="review">🔍 Überprüfung</option>
|
||||
<option value="abuse">⚠️ Missbrauch</option>
|
||||
<option value="defect">❌ Defekt</option>
|
||||
<option value="maintenance">🔧 Wartung</option>
|
||||
<option value="blacklisted">🚫 Blacklisted</option>
|
||||
<option value="expired">⏰ Abgelaufen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="until_date" class="form-label">Bis wann? (optional)</label>
|
||||
<input type="date" name="until_date" id="until_date" class="form-control"
|
||||
min="{{ datetime.now().strftime('%Y-%m-%d') }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Notizen</label>
|
||||
<textarea name="notes" id="notes" class="form-control" rows="3"
|
||||
placeholder="Zusätzliche Informationen..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-warning">
|
||||
⚠️ In Quarantäne setzen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Debug Bootstrap
|
||||
console.log('Bootstrap loaded:', typeof bootstrap !== 'undefined');
|
||||
if (typeof bootstrap !== 'undefined') {
|
||||
console.log('Bootstrap version:', bootstrap.Dropdown.VERSION);
|
||||
}
|
||||
|
||||
// Scroll-Position speichern und wiederherstellen
|
||||
(function() {
|
||||
// Speichere Scroll-Position vor dem Verlassen der Seite
|
||||
window.addEventListener('beforeunload', function() {
|
||||
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
|
||||
});
|
||||
|
||||
// Stelle Scroll-Position wieder her
|
||||
const scrollPos = sessionStorage.getItem('resourcesScrollPos');
|
||||
if (scrollPos) {
|
||||
window.scrollTo(0, parseInt(scrollPos));
|
||||
sessionStorage.removeItem('resourcesScrollPos');
|
||||
}
|
||||
})();
|
||||
|
||||
// Live-Filtering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('filterForm');
|
||||
const inputs = form.querySelectorAll('select, input[type="text"]');
|
||||
|
||||
inputs.forEach(input => {
|
||||
if (input.type === 'text') {
|
||||
let timeout;
|
||||
input.addEventListener('input', function() {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => form.submit(), 300);
|
||||
});
|
||||
} else {
|
||||
input.addEventListener('change', () => form.submit());
|
||||
}
|
||||
});
|
||||
|
||||
// Bootstrap Tooltips initialisieren
|
||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
});
|
||||
|
||||
// Bootstrap Dropdowns explizit initialisieren
|
||||
var dropdownElementList = [].slice.call(document.querySelectorAll('.dropdown-toggle'))
|
||||
var dropdownList = dropdownElementList.map(function (dropdownToggleEl) {
|
||||
return new bootstrap.Dropdown(dropdownToggleEl)
|
||||
});
|
||||
|
||||
// Export Dropdown manuell aktivieren falls nötig
|
||||
const exportBtn = document.getElementById('exportDropdown');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const dropdown = bootstrap.Dropdown.getOrCreateInstance(this);
|
||||
dropdown.toggle();
|
||||
});
|
||||
}
|
||||
|
||||
// Sort-Links: Scroll-Position speichern
|
||||
document.querySelectorAll('.sort-link').forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
sessionStorage.setItem('resourcesScrollPos', window.scrollY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Copy to Clipboard mit besserem Feedback
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Original Text speichern
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
|
||||
button.classList.add('copied');
|
||||
|
||||
// Nach 2 Sekunden zurücksetzen
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('copied');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Quarantäne Modal
|
||||
function showQuarantineModal(resourceId) {
|
||||
const modalElement = document.getElementById('quarantineModal');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
const form = modalElement.querySelector('form');
|
||||
|
||||
// URL mit aktuellen Filtern
|
||||
const currentUrl = new URL(window.location);
|
||||
const params = new URLSearchParams(currentUrl.search);
|
||||
form.setAttribute('action', `/resources/quarantine/${resourceId}?${params.toString()}`);
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Toggle Testressourcen
|
||||
function toggleTestResources() {
|
||||
const showTest = document.getElementById('showTestResources').checked;
|
||||
const currentUrl = new URL(window.location);
|
||||
currentUrl.searchParams.set('show_fake', showTest);
|
||||
window.location.href = currentUrl.toString();
|
||||
}
|
||||
|
||||
// Quarantäne verlängern
|
||||
function extendQuarantine(resourceId) {
|
||||
if (confirm('Möchten Sie die Quarantäne für diese Ressource verlängern?')) {
|
||||
// TODO: Implementiere Modal für Quarantäne-Verlängerung
|
||||
alert('Diese Funktion wird noch implementiert.');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback für Dropdowns
|
||||
document.addEventListener('click', function(e) {
|
||||
// Prüfe ob ein Dropdown-Toggle geklickt wurde
|
||||
if (e.target.closest('.dropdown-toggle')) {
|
||||
const button = e.target.closest('.dropdown-toggle');
|
||||
const dropdown = button.nextElementSibling;
|
||||
|
||||
if (dropdown && dropdown.classList.contains('dropdown-menu')) {
|
||||
// Toggle show class
|
||||
dropdown.classList.toggle('show');
|
||||
button.setAttribute('aria-expanded', dropdown.classList.contains('show'));
|
||||
|
||||
// Schließe andere Dropdowns
|
||||
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
||||
if (menu !== dropdown) {
|
||||
menu.classList.remove('show');
|
||||
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Schließe alle Dropdowns wenn außerhalb geklickt
|
||||
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
||||
menu.classList.remove('show');
|
||||
menu.previousElementSibling.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
183
v2_adminpanel/templates/sessions.html
Normale Datei
183
v2_adminpanel/templates/sessions.html
Normale Datei
@ -0,0 +1,183 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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.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.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.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.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; }
|
||||
.session-warning { background-color: #fff3cd; }
|
||||
.session-inactive { background-color: #f8f9fa; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Session-Tracking</h2>
|
||||
<!-- Export Buttons -->
|
||||
<div>
|
||||
<span class="text-muted me-2">Export:</span>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('export.export_sessions', active_only='true', format='excel') }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Aktive (Excel)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', active_only='true', format='csv') }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> Aktive (CSV)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', format='excel') }}" class="btn btn-success btn-sm">
|
||||
<i class="bi bi-file-earmark-excel"></i> Alle (Excel)
|
||||
</a>
|
||||
<a href="{{ url_for('export.export_sessions', format='csv') }}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-file-earmark-text"></i> Alle (CSV)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktive Sessions -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">🟢 Aktive Sessions ({{ active_sessions|length }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if active_sessions %}
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in active_sessions %}
|
||||
<tr class="{% if session[8] > 5 %}session-warning{% else %}session-active{% endif %}">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[7].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[8] < 1 %}
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
{% elif session[8] < 5 %}
|
||||
<span class="badge bg-warning">{{ session[8]|round|int }} Min.</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">{{ session[8]|round|int }} Min.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="{{ url_for('sessions.end_session', session_id=session[0]) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
onclick="return confirm('Session wirklich beenden?');">
|
||||
⏹️ Beenden
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
Sessions gelten als inaktiv nach 5 Minuten ohne Heartbeat
|
||||
</small>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine aktiven Sessions vorhanden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Beendete Sessions -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<h5 class="mb-0">⏸️ Beendete Sessions (letzte 24 Stunden)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_sessions %}
|
||||
<div class="table-responsive">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in recent_sessions %}
|
||||
<tr class="session-inactive">
|
||||
<td>{{ session[3] }}</td>
|
||||
<td><small><code>{{ session[2][:12] }}...</code></small></td>
|
||||
<td>{{ session[4] or '-' }}</td>
|
||||
<td>{{ session[5].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>{{ session[6].strftime('%d.%m %H:%M') }}</td>
|
||||
<td>
|
||||
{% if session[7] < 60 %}
|
||||
{{ session[7]|round|int }} Min.
|
||||
{% else %}
|
||||
{{ (session[7]/60)|round(1) }} Std.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Keine beendeten Sessions in den letzten 24 Stunden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
210
v2_adminpanel/templates/setup_2fa.html
Normale Datei
@ -0,0 +1,210 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}2FA Einrichten{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.setup-card {
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.setup-card:hover {
|
||||
box-shadow: 0 6px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.app-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.qr-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
display: inline-block;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.secret-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 2px;
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.code-input {
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>🔐 2FA einrichten</h1>
|
||||
<a href="{{ url_for('auth.profile') }}" class="btn btn-secondary">← Zurück zum Profil</a>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Step 1: Install App -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">1</span>
|
||||
Authenticator-App installieren
|
||||
</h5>
|
||||
<p class="ms-5">Wählen Sie eine der folgenden Apps für Ihr Smartphone:</p>
|
||||
<div class="row ms-4">
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">📱</span>
|
||||
<div>
|
||||
<strong>Google Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔷</span>
|
||||
<div>
|
||||
<strong>Microsoft Authenticator</strong><br>
|
||||
<small class="text-muted">Android / iOS</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<span style="font-size: 2rem; margin-right: 10px;">🔴</span>
|
||||
<div>
|
||||
<strong>Authy</strong><br>
|
||||
<small class="text-muted">Android / iOS / Desktop</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Scan QR Code -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">2</span>
|
||||
QR-Code scannen oder Code eingeben
|
||||
</h5>
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6 text-center mb-4">
|
||||
<p class="fw-bold">Option A: QR-Code scannen</p>
|
||||
<div class="qr-container">
|
||||
<img src="data:image/png;base64,{{ qr_code }}" alt="2FA QR Code" style="max-width: 250px;">
|
||||
</div>
|
||||
<p class="text-muted mt-2">
|
||||
<small>Öffnen Sie Ihre Authenticator-App und scannen Sie diesen Code</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<p class="fw-bold">Option B: Code manuell eingeben</p>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Account-Name:</label>
|
||||
<div class="alert alert-light py-2">
|
||||
<strong>V2 Admin Panel</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small">Geheimer Schlüssel:</label>
|
||||
<div class="secret-code">{{ totp_secret }}</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mt-2" onclick="copySecret()">
|
||||
📋 Schlüssel kopieren
|
||||
</button>
|
||||
</div>
|
||||
<div class="alert alert-warning">
|
||||
<small>
|
||||
<strong>⚠️ Wichtiger Hinweis:</strong><br>
|
||||
Speichern Sie diesen Code sicher. Er ist Ihre einzige Möglichkeit,
|
||||
2FA auf einem neuen Gerät einzurichten.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Verify -->
|
||||
<div class="card setup-card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<span class="step-number">3</span>
|
||||
Code verifizieren
|
||||
</h5>
|
||||
<p class="ms-5">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein:</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('auth.enable_2fa') }}" class="ms-5 me-5">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6 mb-3">
|
||||
<input type="text"
|
||||
class="form-control code-input"
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required>
|
||||
<div class="form-text text-center">Der Code ändert sich alle 30 Sekunden</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<button type="submit" class="btn btn-success btn-lg w-100">
|
||||
✅ 2FA aktivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copySecret() {
|
||||
const secret = '{{ totp_secret }}';
|
||||
navigator.clipboard.writeText(secret).then(function() {
|
||||
alert('Code wurde in die Zwischenablage kopiert!');
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-format the code input
|
||||
document.getElementById('token').addEventListener('input', function(e) {
|
||||
// Remove non-digits
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
131
v2_adminpanel/templates/verify_2fa.html
Normale Datei
131
v2_adminpanel/templates/verify_2fa.html
Normale Datei
@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>2FA Verifizierung - Admin Panel</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
.logo {
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.code-input {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="logo">🔐</div>
|
||||
<h2 class="text-center mb-4">Zwei-Faktor-Authentifizierung</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="token" class="form-label">Authentifizierungscode eingeben</label>
|
||||
<input type="text"
|
||||
class="form-control code-input"
|
||||
id="token"
|
||||
name="token"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
required>
|
||||
<div class="form-text text-center mt-2">
|
||||
Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Verifizieren</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<details>
|
||||
<summary class="text-muted" style="cursor: pointer;">Backup-Code verwenden</summary>
|
||||
<div class="mt-2">
|
||||
<p class="small text-muted">
|
||||
Falls Sie keinen Zugriff auf Ihre Authenticator-App haben,
|
||||
können Sie einen 8-stelligen Backup-Code eingeben.
|
||||
</p>
|
||||
<input type="text"
|
||||
class="form-control code-input mt-2"
|
||||
id="backup-token"
|
||||
placeholder="ABCD1234"
|
||||
maxlength="8"
|
||||
pattern="[A-Z0-9]{8}"
|
||||
style="letter-spacing: 0.25rem;">
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('auth.logout') }}" class="text-muted">Abbrechen und zur Anmeldung zurück</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Auto-format the code input
|
||||
document.getElementById('token').addEventListener('input', function(e) {
|
||||
// Remove non-digits
|
||||
e.target.value = e.target.value.replace(/\D/g, '');
|
||||
});
|
||||
|
||||
// Handle backup code input
|
||||
document.getElementById('backup-token').addEventListener('input', function(e) {
|
||||
// Convert to uppercase and remove non-alphanumeric
|
||||
e.target.value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
// If backup code is being used, copy to main token field
|
||||
if (e.target.value.length > 0) {
|
||||
document.getElementById('token').value = e.target.value;
|
||||
document.getElementById('token').removeAttribute('pattern');
|
||||
document.getElementById('token').setAttribute('maxlength', '8');
|
||||
}
|
||||
});
|
||||
|
||||
// Reset main field when typing in it
|
||||
document.getElementById('token').addEventListener('focus', function(e) {
|
||||
document.getElementById('backup-token').value = '';
|
||||
e.target.setAttribute('pattern', '[0-9]{6}');
|
||||
e.target.setAttribute('maxlength', '6');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren