Vorläufig fertiger server
Dieser Commit ist enthalten in:
40
JOURNAL.md
40
JOURNAL.md
@@ -1,5 +1,45 @@
|
|||||||
# v2-Docker Projekt Journal
|
# v2-Docker Projekt Journal
|
||||||
|
|
||||||
|
## Letzte Änderungen (19.06.2025 - 12:46 Uhr)
|
||||||
|
|
||||||
|
### Navigation komplett überarbeitet
|
||||||
|
- **500 Fehler auf /live-dashboard behoben**:
|
||||||
|
- Datenbankabfrage korrigiert (falsche Spaltenreferenz `c.contact_person` entfernt)
|
||||||
|
- Alle Referenzen nutzen jetzt korrekt `c.name as company_name`
|
||||||
|
|
||||||
|
- **Navigation aufgeräumt und reorganisiert**:
|
||||||
|
- "Admin Sessions" komplett entfernt (nicht benötigt)
|
||||||
|
- "Alerts & Anomalien" entfernt (redundant zu "Lizenz-Anomalien")
|
||||||
|
- "Lizenzserver Status" und "Analytics" zu einer Seite zusammengeführt
|
||||||
|
- "Live Dashboard" aus Submenu entfernt (Monitoring führt direkt dorthin)
|
||||||
|
- Neuer "Administration" Bereich erstellt (führt zu Lizenzserver Config)
|
||||||
|
|
||||||
|
- **Neue Navigationsstruktur**:
|
||||||
|
```
|
||||||
|
📊 Monitoring → (Live Dashboard)
|
||||||
|
├── System Status
|
||||||
|
├── Lizenz-Anomalien
|
||||||
|
└── Analytics (kombiniert)
|
||||||
|
|
||||||
|
🔧 Administration → (Lizenzserver Config)
|
||||||
|
├── Audit-Log
|
||||||
|
├── Backups
|
||||||
|
└── Gesperrte IPs
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Weitere Verbesserungen**:
|
||||||
|
- Grafana-Link aus System Status entfernt
|
||||||
|
- Session-Route-Fehler behoben (`admin.sessions` → `sessions.sessions`)
|
||||||
|
- Klarere Trennung zwischen operativem Monitoring und Admin-Tools
|
||||||
|
|
||||||
|
### Status:
|
||||||
|
✅ Navigation ist jetzt intuitiv und aufgeräumt
|
||||||
|
✅ Alle 500 Fehler behoben
|
||||||
|
✅ Redundante Menüpunkte eliminiert
|
||||||
|
✅ Admin-Tools klar von Monitoring getrennt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Letzte Änderungen (19.06.2025)
|
## Letzte Änderungen (19.06.2025)
|
||||||
|
|
||||||
### Monitoring vereinfacht und optimiert
|
### Monitoring vereinfacht und optimiert
|
||||||
|
|||||||
@@ -685,16 +685,44 @@ Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
|||||||
- Response Time Monitoring
|
- Response Time Monitoring
|
||||||
- Keine externen Dependencies
|
- Keine externen Dependencies
|
||||||
|
|
||||||
3. **Alerts** (/monitoring/alerts)
|
3. **Analytics** (/monitoring/analytics)
|
||||||
- Anomalie-Erkennung aus DB
|
- Kombiniert aus Lizenzserver Status + Analytics
|
||||||
- Ungelöste Probleme
|
|
||||||
- Echtzeit-Updates
|
|
||||||
|
|
||||||
4. **Analytics** (/monitoring/analytics)
|
|
||||||
- Echte Statistiken statt Demo-Daten
|
- Echte Statistiken statt Demo-Daten
|
||||||
- Auto-Refresh alle 30 Sekunden
|
- Auto-Refresh alle 30 Sekunden
|
||||||
- Basis-Metriken ohne Pricing
|
- Basis-Metriken ohne Pricing
|
||||||
|
|
||||||
|
### ✅ UPDATE: Navigation komplett überarbeitet (19.06.2025 - 12:46 Uhr)
|
||||||
|
|
||||||
|
**Verbesserungen der Admin Panel Navigation**:
|
||||||
|
|
||||||
|
1. **Fehler behoben**:
|
||||||
|
- 500 Fehler auf /live-dashboard durch korrigierte DB-Abfragen
|
||||||
|
- Session-Route-Referenzen korrigiert
|
||||||
|
|
||||||
|
2. **Navigation aufgeräumt**:
|
||||||
|
- "Admin Sessions" entfernt (nicht benötigt)
|
||||||
|
- "Alerts & Anomalien" entfernt (redundant)
|
||||||
|
- "Lizenzserver Status" + "Analytics" zusammengeführt
|
||||||
|
- Grafana-Links entfernt
|
||||||
|
|
||||||
|
3. **Neue Struktur**:
|
||||||
|
```
|
||||||
|
📊 Monitoring → (Live Dashboard)
|
||||||
|
├── System Status
|
||||||
|
├── Lizenz-Anomalien
|
||||||
|
└── Analytics (kombiniert)
|
||||||
|
|
||||||
|
🔧 Administration → (Lizenzserver Config)
|
||||||
|
├── Audit-Log
|
||||||
|
├── Backups
|
||||||
|
└── Gesperrte IPs
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Vorteile**:
|
||||||
|
- Intuitive Navigation ohne Redundanzen
|
||||||
|
- Klare Trennung: Monitoring vs. Administration
|
||||||
|
- Direkte Links zu Hauptfunktionen (kein unnötiges Klicken)
|
||||||
|
|
||||||
### 📋 Noch zu implementieren:
|
### 📋 Noch zu implementieren:
|
||||||
|
|
||||||
1. **Erweiterte Anomalie-Erkennung**
|
1. **Erweiterte Anomalie-Erkennung**
|
||||||
|
|||||||
92
v2_adminpanel/apply_license_heartbeats_migration.py
Normale Datei
92
v2_adminpanel/apply_license_heartbeats_migration.py
Normale Datei
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Apply the license_heartbeats table migration
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.environ.get('POSTGRES_HOST', 'postgres'),
|
||||||
|
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
|
||||||
|
user=os.environ.get('POSTGRES_USER', 'postgres'),
|
||||||
|
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply_migration():
|
||||||
|
"""Apply the license_heartbeats migration"""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
logger.info("Connecting to database...")
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Read migration file
|
||||||
|
migration_file = os.path.join(os.path.dirname(__file__), 'migrations', 'create_license_heartbeats_table.sql')
|
||||||
|
logger.info(f"Reading migration file: {migration_file}")
|
||||||
|
|
||||||
|
with open(migration_file, 'r') as f:
|
||||||
|
migration_sql = f.read()
|
||||||
|
|
||||||
|
# Execute migration
|
||||||
|
logger.info("Executing migration...")
|
||||||
|
cur.execute(migration_sql)
|
||||||
|
|
||||||
|
# Verify table was created
|
||||||
|
cur.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name = 'license_heartbeats'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
if cur.fetchone()[0]:
|
||||||
|
logger.info("✓ license_heartbeats table created successfully!")
|
||||||
|
|
||||||
|
# Check partitions
|
||||||
|
cur.execute("""
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename LIKE 'license_heartbeats_%'
|
||||||
|
ORDER BY tablename
|
||||||
|
""")
|
||||||
|
|
||||||
|
partitions = cur.fetchall()
|
||||||
|
logger.info(f"✓ Created {len(partitions)} partitions:")
|
||||||
|
for partition in partitions:
|
||||||
|
logger.info(f" - {partition[0]}")
|
||||||
|
else:
|
||||||
|
logger.error("✗ Failed to create license_heartbeats table")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info("✓ Migration completed successfully!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"✗ Migration failed: {str(e)}")
|
||||||
|
if conn:
|
||||||
|
conn.rollback()
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
if conn:
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logger.info("=== Applying license_heartbeats migration ===")
|
||||||
|
logger.info(f"Timestamp: {datetime.now()}")
|
||||||
|
|
||||||
|
if apply_migration():
|
||||||
|
logger.info("=== Migration successful! ===")
|
||||||
|
else:
|
||||||
|
logger.error("=== Migration failed! ===")
|
||||||
|
exit(1)
|
||||||
122
v2_adminpanel/apply_partition_migration.py
Normale Datei
122
v2_adminpanel/apply_partition_migration.py
Normale Datei
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Apply partition migration for license_heartbeats table.
|
||||||
|
This script creates missing partitions for the current and future months.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
def get_db_connection():
|
||||||
|
"""Get database connection"""
|
||||||
|
return psycopg2.connect(
|
||||||
|
host=os.environ.get('POSTGRES_HOST', 'postgres'),
|
||||||
|
database=os.environ.get('POSTGRES_DB', 'v2_adminpanel'),
|
||||||
|
user=os.environ.get('POSTGRES_USER', 'postgres'),
|
||||||
|
password=os.environ.get('POSTGRES_PASSWORD', 'postgres')
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_partition(cursor, year, month):
|
||||||
|
"""Create a partition for the given year and month"""
|
||||||
|
partition_name = f"license_heartbeats_{year}_{month:02d}"
|
||||||
|
start_date = f"{year}-{month:02d}-01"
|
||||||
|
|
||||||
|
# Calculate end date (first day of next month)
|
||||||
|
if month == 12:
|
||||||
|
end_date = f"{year + 1}-01-01"
|
||||||
|
else:
|
||||||
|
end_date = f"{year}-{month + 1:02d}-01"
|
||||||
|
|
||||||
|
# Check if partition already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename = %s
|
||||||
|
)
|
||||||
|
""", (partition_name,))
|
||||||
|
|
||||||
|
exists = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
try:
|
||||||
|
cursor.execute(f"""
|
||||||
|
CREATE TABLE {partition_name} PARTITION OF license_heartbeats
|
||||||
|
FOR VALUES FROM ('{start_date}') TO ('{end_date}')
|
||||||
|
""")
|
||||||
|
print(f"✓ Created partition {partition_name}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error creating partition {partition_name}: {e}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(f"- Partition {partition_name} already exists")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main function"""
|
||||||
|
print("Applying license_heartbeats partition migration...")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Connect to database
|
||||||
|
conn = get_db_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if license_heartbeats table exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name = 'license_heartbeats'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
if not cursor.fetchone()[0]:
|
||||||
|
print("✗ Error: license_heartbeats table does not exist!")
|
||||||
|
print(" Please run the init.sql script first.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Get current date
|
||||||
|
current_date = datetime.now()
|
||||||
|
partitions_created = 0
|
||||||
|
|
||||||
|
# Create partitions for the next 6 months (including current month)
|
||||||
|
for i in range(7):
|
||||||
|
target_date = current_date + relativedelta(months=i)
|
||||||
|
if create_partition(cursor, target_date.year, target_date.month):
|
||||||
|
partitions_created += 1
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"✓ Migration complete. Created {partitions_created} new partitions.")
|
||||||
|
|
||||||
|
# List all partitions
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename LIKE 'license_heartbeats_%'
|
||||||
|
ORDER BY tablename
|
||||||
|
""")
|
||||||
|
|
||||||
|
partitions = cursor.fetchall()
|
||||||
|
print(f"\nTotal partitions: {len(partitions)}")
|
||||||
|
for partition in partitions:
|
||||||
|
print(f" - {partition[0]}")
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Error: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
77
v2_adminpanel/docs/PARTITION_MANAGEMENT.md
Normale Datei
77
v2_adminpanel/docs/PARTITION_MANAGEMENT.md
Normale Datei
@@ -0,0 +1,77 @@
|
|||||||
|
# License Heartbeats Partition Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The `license_heartbeats` table uses PostgreSQL partitioning to efficiently store and query time-series data. Each partition contains data for one month.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The monitoring dashboard may fail with "relation does not exist" errors when:
|
||||||
|
1. The current month's partition hasn't been created
|
||||||
|
2. The license_heartbeats table hasn't been properly initialized
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Automatic Partition Creation
|
||||||
|
|
||||||
|
The monitoring routes now automatically:
|
||||||
|
1. Check if the `license_heartbeats` table exists
|
||||||
|
2. Create missing partitions for the current month
|
||||||
|
3. Handle missing table/partition gracefully by returning empty data
|
||||||
|
|
||||||
|
### Manual Partition Creation
|
||||||
|
|
||||||
|
If needed, you can manually create partitions:
|
||||||
|
|
||||||
|
1. **Using the migration script:**
|
||||||
|
```bash
|
||||||
|
docker exec -it v2_adminpanel python apply_partition_migration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Using SQL directly:**
|
||||||
|
```sql
|
||||||
|
-- Create June 2025 partition
|
||||||
|
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats
|
||||||
|
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partition Naming Convention
|
||||||
|
|
||||||
|
Partitions follow the naming pattern: `license_heartbeats_YYYY_MM`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `license_heartbeats_2025_01` - January 2025
|
||||||
|
- `license_heartbeats_2025_06` - June 2025
|
||||||
|
- `license_heartbeats_2025_12` - December 2025
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Pre-create partitions**: Create partitions for the next 6 months to avoid runtime issues
|
||||||
|
2. **Monitor partition usage**: Check which partitions exist regularly
|
||||||
|
3. **Archive old data**: Consider dropping very old partitions to save space
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
If you see "relation does not exist" errors:
|
||||||
|
|
||||||
|
1. Check if the base table exists:
|
||||||
|
```sql
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'license_heartbeats'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. List existing partitions:
|
||||||
|
```sql
|
||||||
|
SELECT tablename FROM pg_tables
|
||||||
|
WHERE tablename LIKE 'license_heartbeats_%'
|
||||||
|
ORDER BY tablename;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create missing partition for current month:
|
||||||
|
```sql
|
||||||
|
-- Replace YYYY_MM with current year and month
|
||||||
|
CREATE TABLE IF NOT EXISTS license_heartbeats_YYYY_MM PARTITION OF license_heartbeats
|
||||||
|
FOR VALUES FROM ('YYYY-MM-01') TO ('YYYY-MM-01'::date + interval '1 month');
|
||||||
|
```
|
||||||
@@ -394,6 +394,10 @@ CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heart
|
|||||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats
|
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats
|
||||||
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
|
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
|
||||||
|
|
||||||
|
-- Add June 2025 partition for current month
|
||||||
|
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_06 PARTITION OF license_heartbeats
|
||||||
|
FOR VALUES FROM ('2025-06-01') TO ('2025-07-01');
|
||||||
|
|
||||||
CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC);
|
CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC);
|
||||||
CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC);
|
CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC);
|
||||||
|
|
||||||
|
|||||||
75
v2_adminpanel/migrations/README.md
Normale Datei
75
v2_adminpanel/migrations/README.md
Normale Datei
@@ -0,0 +1,75 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
## License Heartbeats Table Migration
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
The `create_license_heartbeats_table.sql` migration creates a partitioned table for storing real-time license validation data. This table is essential for the Live Dashboard & Analytics functionality.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Monthly partitioning for efficient data management
|
||||||
|
- Automatic creation of current and next month partitions
|
||||||
|
- Optimized indexes for performance
|
||||||
|
- Foreign key relationship with licenses table
|
||||||
|
|
||||||
|
### Running the Migration
|
||||||
|
|
||||||
|
#### Option 1: Using the Python Script
|
||||||
|
```bash
|
||||||
|
cd /path/to/v2_adminpanel
|
||||||
|
python apply_license_heartbeats_migration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 2: Manual SQL Execution
|
||||||
|
```bash
|
||||||
|
psql -h postgres -U postgres -d v2_adminpanel -f migrations/create_license_heartbeats_table.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option 3: Docker Exec
|
||||||
|
```bash
|
||||||
|
docker exec -it v2_adminpanel_postgres psql -U postgres -d v2_adminpanel -f /migrations/create_license_heartbeats_table.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
After running the migration, verify the table was created:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check if table exists
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name = 'license_heartbeats'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- List all partitions
|
||||||
|
SELECT tablename
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename LIKE 'license_heartbeats_%'
|
||||||
|
ORDER BY tablename;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partition Management
|
||||||
|
The system automatically creates partitions as needed. To manually create future partitions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from utils.partition_helper import create_future_partitions
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
conn = psycopg2.connect(...)
|
||||||
|
create_future_partitions(conn, 'license_heartbeats', months_ahead=6)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Retention
|
||||||
|
Consider implementing a data retention policy to remove old partitions:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Drop partitions older than 3 months
|
||||||
|
DROP TABLE IF EXISTS license_heartbeats_2025_03;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
1. **Table already exists**: The migration is idempotent and will skip creation if the table exists.
|
||||||
|
|
||||||
|
2. **Permission denied**: Ensure the database user has CREATE privileges.
|
||||||
|
|
||||||
|
3. **Foreign key violation**: The licenses table must exist before running this migration.
|
||||||
58
v2_adminpanel/migrations/add_june_2025_partition.sql
Normale Datei
58
v2_adminpanel/migrations/add_june_2025_partition.sql
Normale Datei
@@ -0,0 +1,58 @@
|
|||||||
|
-- Migration: Add June 2025 partition for license_heartbeats table
|
||||||
|
-- This migration adds the missing partition for the current month (June 2025)
|
||||||
|
|
||||||
|
-- Check if the partition already exists before creating it
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Check if the June 2025 partition exists
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename = 'license_heartbeats_2025_06'
|
||||||
|
) THEN
|
||||||
|
-- Create the June 2025 partition
|
||||||
|
EXECUTE 'CREATE TABLE license_heartbeats_2025_06 PARTITION OF license_heartbeats
|
||||||
|
FOR VALUES FROM (''2025-06-01'') TO (''2025-07-01'')';
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created partition license_heartbeats_2025_06';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Partition license_heartbeats_2025_06 already exists';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Also create partitions for the next few months to avoid future issues
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
partition_name text;
|
||||||
|
start_date date;
|
||||||
|
end_date date;
|
||||||
|
i integer;
|
||||||
|
BEGIN
|
||||||
|
-- Create partitions for the next 6 months
|
||||||
|
FOR i IN 0..6 LOOP
|
||||||
|
start_date := date_trunc('month', CURRENT_DATE + (i || ' months')::interval);
|
||||||
|
end_date := start_date + interval '1 month';
|
||||||
|
partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM');
|
||||||
|
|
||||||
|
-- Check if partition already exists
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename = partition_name
|
||||||
|
) THEN
|
||||||
|
EXECUTE format('CREATE TABLE %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||||
|
partition_name, start_date, end_date);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created partition %', partition_name;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Verify the partitions were created
|
||||||
|
SELECT
|
||||||
|
schemaname,
|
||||||
|
tablename,
|
||||||
|
tableowner
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename LIKE 'license_heartbeats_%'
|
||||||
|
ORDER BY tablename;
|
||||||
79
v2_adminpanel/migrations/create_license_heartbeats_table.sql
Normale Datei
79
v2_adminpanel/migrations/create_license_heartbeats_table.sql
Normale Datei
@@ -0,0 +1,79 @@
|
|||||||
|
-- Migration: Create license_heartbeats partitioned table
|
||||||
|
-- Date: 2025-06-19
|
||||||
|
-- Description: Creates the license_heartbeats table with monthly partitioning
|
||||||
|
|
||||||
|
-- Create the partitioned table
|
||||||
|
CREATE TABLE IF NOT EXISTS license_heartbeats (
|
||||||
|
id BIGSERIAL,
|
||||||
|
license_id INTEGER NOT NULL,
|
||||||
|
hardware_id VARCHAR(255) NOT NULL,
|
||||||
|
ip_address INET NOT NULL,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
session_data JSONB,
|
||||||
|
PRIMARY KEY (id, timestamp),
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||||
|
) PARTITION BY RANGE (timestamp);
|
||||||
|
|
||||||
|
-- Create indexes for better query performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_license_id_timestamp
|
||||||
|
ON license_heartbeats (license_id, timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_timestamp
|
||||||
|
ON license_heartbeats (timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_hardware_id
|
||||||
|
ON license_heartbeats (hardware_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_heartbeats_ip_address
|
||||||
|
ON license_heartbeats (ip_address);
|
||||||
|
|
||||||
|
-- Create partitions for current and next month
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
current_year INTEGER;
|
||||||
|
current_month INTEGER;
|
||||||
|
next_year INTEGER;
|
||||||
|
next_month INTEGER;
|
||||||
|
partition_name TEXT;
|
||||||
|
start_date DATE;
|
||||||
|
end_date DATE;
|
||||||
|
BEGIN
|
||||||
|
-- Get current date info
|
||||||
|
current_year := EXTRACT(YEAR FROM CURRENT_DATE);
|
||||||
|
current_month := EXTRACT(MONTH FROM CURRENT_DATE);
|
||||||
|
|
||||||
|
-- Calculate next month
|
||||||
|
IF current_month = 12 THEN
|
||||||
|
next_year := current_year + 1;
|
||||||
|
next_month := 1;
|
||||||
|
ELSE
|
||||||
|
next_year := current_year;
|
||||||
|
next_month := current_month + 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Create current month partition
|
||||||
|
partition_name := 'license_heartbeats_' || current_year || '_' || LPAD(current_month::TEXT, 2, '0');
|
||||||
|
start_date := DATE_TRUNC('month', CURRENT_DATE);
|
||||||
|
end_date := DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month';
|
||||||
|
|
||||||
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||||
|
partition_name, start_date, end_date);
|
||||||
|
|
||||||
|
-- Create next month partition
|
||||||
|
partition_name := 'license_heartbeats_' || next_year || '_' || LPAD(next_month::TEXT, 2, '0');
|
||||||
|
start_date := end_date;
|
||||||
|
end_date := start_date + INTERVAL '1 month';
|
||||||
|
|
||||||
|
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||||
|
partition_name, start_date, end_date);
|
||||||
|
|
||||||
|
RAISE NOTICE 'Created partitions for current and next month';
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Add comment to the table
|
||||||
|
COMMENT ON TABLE license_heartbeats IS 'Stores heartbeat data from license validations for real-time monitoring';
|
||||||
|
COMMENT ON COLUMN license_heartbeats.license_id IS 'Foreign key to licenses table';
|
||||||
|
COMMENT ON COLUMN license_heartbeats.hardware_id IS 'Hardware identifier of the device';
|
||||||
|
COMMENT ON COLUMN license_heartbeats.ip_address IS 'IP address from which the heartbeat was sent';
|
||||||
|
COMMENT ON COLUMN license_heartbeats.timestamp IS 'Timestamp of the heartbeat';
|
||||||
|
COMMENT ON COLUMN license_heartbeats.session_data IS 'Additional session data in JSON format';
|
||||||
@@ -561,91 +561,8 @@ def clear_attempts():
|
|||||||
@admin_bp.route("/lizenzserver/monitor")
|
@admin_bp.route("/lizenzserver/monitor")
|
||||||
@login_required
|
@login_required
|
||||||
def license_monitor():
|
def license_monitor():
|
||||||
"""License server live monitoring dashboard"""
|
"""Redirect to new analytics page"""
|
||||||
try:
|
return redirect(url_for('monitoring.analytics'))
|
||||||
conn = get_connection()
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
# Get current statistics
|
|
||||||
# Active validations in last 5 minutes
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(DISTINCT license_id) as active_licenses,
|
|
||||||
COUNT(*) as total_validations,
|
|
||||||
COUNT(DISTINCT hardware_id) as unique_devices,
|
|
||||||
COUNT(DISTINCT ip_address) as unique_ips
|
|
||||||
FROM license_heartbeats
|
|
||||||
WHERE timestamp > NOW() - INTERVAL '5 minutes'
|
|
||||||
""")
|
|
||||||
live_stats = cur.fetchone()
|
|
||||||
|
|
||||||
# Get validation rate (per minute)
|
|
||||||
cur.execute("""
|
|
||||||
SELECT DATE_TRUNC('minute', timestamp) as minute,
|
|
||||||
COUNT(*) as validations
|
|
||||||
FROM license_heartbeats
|
|
||||||
WHERE timestamp > NOW() - INTERVAL '10 minutes'
|
|
||||||
GROUP BY minute
|
|
||||||
ORDER BY minute DESC
|
|
||||||
LIMIT 10
|
|
||||||
""")
|
|
||||||
validation_rates = cur.fetchall()
|
|
||||||
|
|
||||||
# Get top active licenses
|
|
||||||
cur.execute("""
|
|
||||||
SELECT l.id, l.license_key, c.name as customer_name,
|
|
||||||
COUNT(DISTINCT lh.hardware_id) as device_count,
|
|
||||||
COUNT(*) as validation_count,
|
|
||||||
MAX(lh.timestamp) as last_seen
|
|
||||||
FROM licenses l
|
|
||||||
JOIN customers c ON l.customer_id = c.id
|
|
||||||
JOIN license_heartbeats lh ON l.id = lh.license_id
|
|
||||||
WHERE lh.timestamp > NOW() - INTERVAL '15 minutes'
|
|
||||||
GROUP BY l.id, l.license_key, c.name
|
|
||||||
ORDER BY validation_count DESC
|
|
||||||
LIMIT 10
|
|
||||||
""")
|
|
||||||
top_licenses = cur.fetchall()
|
|
||||||
|
|
||||||
# Get recent anomalies
|
|
||||||
cur.execute("""
|
|
||||||
SELECT ad.*, l.license_key, c.name as customer_name
|
|
||||||
FROM anomaly_detections ad
|
|
||||||
LEFT JOIN licenses l ON ad.license_id = l.id
|
|
||||||
LEFT JOIN customers c ON l.customer_id = c.id
|
|
||||||
WHERE ad.resolved = false
|
|
||||||
ORDER BY ad.detected_at DESC
|
|
||||||
LIMIT 10
|
|
||||||
""")
|
|
||||||
recent_anomalies = cur.fetchall()
|
|
||||||
|
|
||||||
# Get geographic distribution
|
|
||||||
cur.execute("""
|
|
||||||
SELECT ip_address, COUNT(*) as count
|
|
||||||
FROM license_heartbeats
|
|
||||||
WHERE timestamp > NOW() - INTERVAL '1 hour'
|
|
||||||
AND ip_address IS NOT NULL
|
|
||||||
GROUP BY ip_address
|
|
||||||
ORDER BY count DESC
|
|
||||||
LIMIT 20
|
|
||||||
""")
|
|
||||||
geo_distribution = cur.fetchall()
|
|
||||||
|
|
||||||
return render_template('license_monitor.html',
|
|
||||||
live_stats=live_stats,
|
|
||||||
validation_rates=validation_rates,
|
|
||||||
top_licenses=top_licenses,
|
|
||||||
recent_anomalies=recent_anomalies,
|
|
||||||
geo_distribution=geo_distribution
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
flash(f'Fehler beim Laden der Monitoring-Daten: {str(e)}', 'error')
|
|
||||||
return render_template('license_monitor.html')
|
|
||||||
finally:
|
|
||||||
if 'cur' in locals():
|
|
||||||
cur.close()
|
|
||||||
if 'conn' in locals():
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/lizenzserver/analytics")
|
@admin_bp.route("/lizenzserver/analytics")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
import requests
|
import requests
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
|
from utils.partition_helper import ensure_partition_exists, check_table_exists
|
||||||
|
|
||||||
monitoring_bp = Blueprint('monitoring', __name__)
|
monitoring_bp = Blueprint('monitoring', __name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -33,18 +34,33 @@ def login_required(f):
|
|||||||
@monitoring_bp.route('/live-dashboard')
|
@monitoring_bp.route('/live-dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
def live_dashboard():
|
def live_dashboard():
|
||||||
"""Live Dashboard showing active customer sessions"""
|
"""Live Dashboard showing active customer sessions and analytics"""
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor(cursor_factory=RealDictCursor)
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# Check if license_heartbeats table exists
|
||||||
|
if not check_table_exists(conn, 'license_heartbeats'):
|
||||||
|
logger.warning("license_heartbeats table does not exist")
|
||||||
|
# Return empty data
|
||||||
|
return render_template('monitoring/live_dashboard.html',
|
||||||
|
active_sessions=[],
|
||||||
|
stats={'active_licenses': 0, 'active_devices': 0, 'total_heartbeats': 0},
|
||||||
|
validation_timeline=[],
|
||||||
|
live_stats=[0, 0, 0, 0],
|
||||||
|
validation_rates=[],
|
||||||
|
recent_anomalies=[],
|
||||||
|
top_licenses=[])
|
||||||
|
|
||||||
|
# Ensure current month partition exists
|
||||||
|
ensure_partition_exists(conn, 'license_heartbeats', datetime.now())
|
||||||
|
|
||||||
# Get active customer sessions (last 5 minutes)
|
# Get active customer sessions (last 5 minutes)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
l.id,
|
l.id,
|
||||||
l.license_key,
|
l.license_key,
|
||||||
c.company_name,
|
c.name as company_name,
|
||||||
c.contact_person,
|
|
||||||
lh.hardware_id,
|
lh.hardware_id,
|
||||||
lh.ip_address,
|
lh.ip_address,
|
||||||
lh.timestamp as last_activity,
|
lh.timestamp as last_activity,
|
||||||
@@ -71,7 +87,7 @@ def live_dashboard():
|
|||||||
""")
|
""")
|
||||||
stats = cur.fetchone()
|
stats = cur.fetchone()
|
||||||
|
|
||||||
# Get validations per minute
|
# Get validations per minute (for both charts)
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
DATE_TRUNC('minute', timestamp) as minute,
|
DATE_TRUNC('minute', timestamp) as minute,
|
||||||
@@ -84,13 +100,81 @@ def live_dashboard():
|
|||||||
""")
|
""")
|
||||||
validation_timeline = cur.fetchall()
|
validation_timeline = cur.fetchall()
|
||||||
|
|
||||||
|
# Get live statistics for analytics cards
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT license_id) as active_licenses,
|
||||||
|
COUNT(*) as total_validations,
|
||||||
|
COUNT(DISTINCT hardware_id) as unique_devices,
|
||||||
|
COUNT(DISTINCT ip_address) as unique_ips
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '5 minutes'
|
||||||
|
""")
|
||||||
|
live_stats_data = cur.fetchone()
|
||||||
|
live_stats = [
|
||||||
|
live_stats_data['active_licenses'] or 0,
|
||||||
|
live_stats_data['total_validations'] or 0,
|
||||||
|
live_stats_data['unique_devices'] or 0,
|
||||||
|
live_stats_data['unique_ips'] or 0
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get validation rates for analytics chart (last 30 minutes)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('minute', timestamp) as minute,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '30 minutes'
|
||||||
|
GROUP BY minute
|
||||||
|
ORDER BY minute DESC
|
||||||
|
LIMIT 30
|
||||||
|
""")
|
||||||
|
validation_rates = [(row['minute'].isoformat(), row['count']) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
# Get recent anomalies
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
ad.*,
|
||||||
|
l.license_key,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM anomaly_detections ad
|
||||||
|
LEFT JOIN licenses l ON l.id = ad.license_id
|
||||||
|
LEFT JOIN customers c ON c.id = l.customer_id
|
||||||
|
WHERE ad.detected_at > NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY ad.detected_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
recent_anomalies = cur.fetchall()
|
||||||
|
|
||||||
|
# Get top active licenses
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
l.license_key,
|
||||||
|
c.name as customer_name,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||||
|
COUNT(*) as validation_count,
|
||||||
|
MAX(lh.timestamp) as last_seen
|
||||||
|
FROM license_heartbeats lh
|
||||||
|
JOIN licenses l ON l.id = lh.license_id
|
||||||
|
JOIN customers c ON c.id = l.customer_id
|
||||||
|
WHERE lh.timestamp > NOW() - INTERVAL '15 minutes'
|
||||||
|
GROUP BY l.license_key, c.name
|
||||||
|
ORDER BY validation_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
top_licenses = cur.fetchall()
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
return render_template('monitoring/live_dashboard.html',
|
return render_template('monitoring/live_dashboard.html',
|
||||||
active_sessions=active_sessions,
|
active_sessions=active_sessions,
|
||||||
stats=stats,
|
stats=stats,
|
||||||
validation_timeline=validation_timeline)
|
validation_timeline=validation_timeline,
|
||||||
|
live_stats=live_stats,
|
||||||
|
validation_rates=validation_rates,
|
||||||
|
recent_anomalies=recent_anomalies,
|
||||||
|
top_licenses=top_licenses)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in live dashboard: {str(e)}")
|
logger.error(f"Error in live dashboard: {str(e)}")
|
||||||
@@ -173,7 +257,7 @@ def alerts():
|
|||||||
SELECT
|
SELECT
|
||||||
ad.*,
|
ad.*,
|
||||||
l.license_key,
|
l.license_key,
|
||||||
c.company_name
|
c.name as company_name
|
||||||
FROM anomaly_detections ad
|
FROM anomaly_detections ad
|
||||||
LEFT JOIN licenses l ON l.id = ad.license_id
|
LEFT JOIN licenses l ON l.id = ad.license_id
|
||||||
LEFT JOIN customers c ON c.id = l.customer_id
|
LEFT JOIN customers c ON c.id = l.customer_id
|
||||||
@@ -191,9 +275,91 @@ def alerts():
|
|||||||
@monitoring_bp.route('/analytics')
|
@monitoring_bp.route('/analytics')
|
||||||
@login_required
|
@login_required
|
||||||
def analytics():
|
def analytics():
|
||||||
"""Detailed analytics page"""
|
"""Combined analytics and license server status page"""
|
||||||
# This will integrate with the existing analytics service
|
try:
|
||||||
return render_template('monitoring/analytics.html')
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# Get live statistics for the top cards
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT license_id) as active_licenses,
|
||||||
|
COUNT(*) as total_validations,
|
||||||
|
COUNT(DISTINCT hardware_id) as unique_devices,
|
||||||
|
COUNT(DISTINCT ip_address) as unique_ips
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '5 minutes'
|
||||||
|
""")
|
||||||
|
live_stats = cur.fetchone()
|
||||||
|
live_stats = [
|
||||||
|
live_stats['active_licenses'] or 0,
|
||||||
|
live_stats['total_validations'] or 0,
|
||||||
|
live_stats['unique_devices'] or 0,
|
||||||
|
live_stats['unique_ips'] or 0
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get validation rates for chart
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('minute', timestamp) as minute,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '30 minutes'
|
||||||
|
GROUP BY minute
|
||||||
|
ORDER BY minute DESC
|
||||||
|
LIMIT 30
|
||||||
|
""")
|
||||||
|
validation_rates = [(row['minute'].isoformat(), row['count']) for row in cur.fetchall()]
|
||||||
|
|
||||||
|
# Get recent anomalies
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
ad.*,
|
||||||
|
l.license_key,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM anomaly_detections ad
|
||||||
|
LEFT JOIN licenses l ON l.id = ad.license_id
|
||||||
|
LEFT JOIN customers c ON c.id = l.customer_id
|
||||||
|
WHERE ad.detected_at > NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY ad.detected_at DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
recent_anomalies = cur.fetchall()
|
||||||
|
|
||||||
|
# Get top active licenses
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
l.license_key,
|
||||||
|
c.name as customer_name,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||||
|
COUNT(*) as validation_count,
|
||||||
|
MAX(lh.timestamp) as last_seen
|
||||||
|
FROM license_heartbeats lh
|
||||||
|
JOIN licenses l ON l.id = lh.license_id
|
||||||
|
JOIN customers c ON c.id = l.customer_id
|
||||||
|
WHERE lh.timestamp > NOW() - INTERVAL '15 minutes'
|
||||||
|
GROUP BY l.license_key, c.name
|
||||||
|
ORDER BY validation_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
""")
|
||||||
|
top_licenses = cur.fetchall()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template('monitoring/analytics.html',
|
||||||
|
live_stats=live_stats,
|
||||||
|
validation_rates=validation_rates,
|
||||||
|
recent_anomalies=recent_anomalies,
|
||||||
|
top_licenses=top_licenses)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in analytics: {str(e)}")
|
||||||
|
return render_template('monitoring/analytics.html',
|
||||||
|
live_stats=[0, 0, 0, 0],
|
||||||
|
validation_rates=[],
|
||||||
|
recent_anomalies=[],
|
||||||
|
top_licenses=[])
|
||||||
|
|
||||||
# API endpoints for live data
|
# API endpoints for live data
|
||||||
@monitoring_bp.route('/api/live-stats')
|
@monitoring_bp.route('/api/live-stats')
|
||||||
@@ -235,7 +401,7 @@ def api_active_sessions():
|
|||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT
|
SELECT
|
||||||
l.license_key,
|
l.license_key,
|
||||||
c.company_name,
|
c.name as company_name,
|
||||||
lh.hardware_id,
|
lh.hardware_id,
|
||||||
lh.ip_address,
|
lh.ip_address,
|
||||||
lh.timestamp as last_activity,
|
lh.timestamp as last_activity,
|
||||||
|
|||||||
@@ -409,24 +409,12 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item {% if request.endpoint in ['monitoring.live_dashboard', 'monitoring.system_status', 'monitoring.alerts', 'admin.audit_log', 'admin.license_monitor', 'admin.license_analytics', 'admin.license_anomalies'] %}has-active-child{% endif %}">
|
||||||
<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 {% if request.endpoint in ['monitoring.live_dashboard', 'monitoring.system_status', 'monitoring.alerts', 'monitoring.analytics'] %}has-active-child{% endif %}">
|
|
||||||
<a class="nav-link has-submenu" href="{{ url_for('monitoring.live_dashboard') }}">
|
<a class="nav-link has-submenu" href="{{ url_for('monitoring.live_dashboard') }}">
|
||||||
<i class="bi bi-activity"></i>
|
<i class="bi bi-activity"></i>
|
||||||
<span>Monitoring</span>
|
<span>Monitoring</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.endpoint == 'monitoring.live_dashboard' %}active{% endif %}" href="{{ url_for('monitoring.live_dashboard') }}">
|
|
||||||
<i class="bi bi-graph-up"></i>
|
|
||||||
<span>Live Dashboard</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'monitoring.system_status' %}active{% endif %}" href="{{ url_for('monitoring.system_status') }}">
|
<a class="nav-link {% if request.endpoint == 'monitoring.system_status' %}active{% endif %}" href="{{ url_for('monitoring.system_status') }}">
|
||||||
<i class="bi bi-pc-display"></i>
|
<i class="bi bi-pc-display"></i>
|
||||||
@@ -434,53 +422,35 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'monitoring.alerts' %}active{% endif %}" href="{{ url_for('monitoring.alerts') }}">
|
<a class="nav-link {% if request.endpoint == 'admin.license_anomalies' %}active{% endif %}" href="{{ url_for('admin.license_anomalies') }}">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
<i class="bi bi-bug"></i>
|
||||||
<span>Alerts</span>
|
<span>Lizenz-Anomalien</span>
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.endpoint == 'monitoring.analytics' %}active{% endif %}" href="{{ url_for('monitoring.analytics') }}">
|
|
||||||
<i class="bi bi-bar-chart-line"></i>
|
|
||||||
<span>Analytics</span>
|
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<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 {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
|
<a class="nav-link has-submenu" href="{{ url_for('admin.license_config') }}">
|
||||||
<i class="bi bi-cloud-download"></i>
|
<i class="bi bi-tools"></i>
|
||||||
<span>Backups</span>
|
<span>Administration</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-shield-lock"></i>
|
|
||||||
<span>Sicherheit</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item {% if request.endpoint in ['admin.license_monitor', 'admin.license_analytics', 'admin.license_anomalies', 'admin.license_config'] %}has-active-child{% endif %}">
|
|
||||||
<a class="nav-link has-submenu" href="{{ url_for('admin.license_monitor') }}">
|
|
||||||
<i class="bi bi-speedometer2"></i>
|
|
||||||
<span>Lizenzserver</span>
|
|
||||||
</a>
|
</a>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'admin.license_analytics' %}active{% endif %}" href="{{ url_for('admin.license_analytics') }}">
|
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
|
||||||
<i class="bi bi-bar-chart"></i>
|
<i class="bi bi-journal-text"></i>
|
||||||
<span>Analytics</span>
|
<span>Audit-Log</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'admin.license_anomalies' %}active{% endif %}" href="{{ url_for('admin.license_anomalies') }}">
|
<a class="nav-link {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
<i class="bi bi-cloud-download"></i>
|
||||||
<span>Anomalien</span>
|
<span>Backups</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'admin.license_config' %}active{% endif %}" href="{{ url_for('admin.license_config') }}">
|
<a class="nav-link {% if request.endpoint == 'admin.blocked_ips' %}active{% endif %}" href="{{ url_for('admin.blocked_ips') }}">
|
||||||
<i class="bi bi-gear"></i>
|
<i class="bi bi-slash-circle"></i>
|
||||||
<span>Konfiguration</span>
|
<span>Gesperrte IPs</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -94,15 +94,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<a href="{{ url_for('sessions.sessions') }}" class="text-decoration-none">
|
<div class="card stat-card h-100">
|
||||||
<div class="card stat-card h-100">
|
<div class="card-body text-center">
|
||||||
<div class="card-body text-center">
|
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||||
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
|
<div class="card-value text-success">{{ stats.active_sessions }}</div>
|
||||||
<div class="card-value text-success">{{ stats.active_sessions }}</div>
|
<div class="card-label text-muted">Aktive Sessions</div>
|
||||||
<div class="card-label text-muted">Aktive Sessions</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
37
v2_adminpanel/templates/error.html
Normale Datei
37
v2_adminpanel/templates/error.html
Normale Datei
@@ -0,0 +1,37 @@
|
|||||||
|
{% 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">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
{{ error_message|default('Ein Fehler ist aufgetreten') }}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if details %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<h6>Details:</h6>
|
||||||
|
<pre class="mb-0">{{ details }}</pre>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house"></i> Zurück zum Dashboard
|
||||||
|
</a>
|
||||||
|
<button onclick="window.history.back();" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Zurück
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Lizenzserver Monitor{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-speedometer2"></i> Lizenzserver Live Monitor</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>
|
|
||||||
|
|
||||||
<!-- 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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{% 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 %}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start auto-refresh
|
|
||||||
if (autoRefresh) {
|
|
||||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial fetch
|
|
||||||
fetchLiveStats();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Analytics{% endblock %}
|
{% block title %}Analytics & Lizenzserver Status{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
/* Analytics Styles */
|
||||||
.analytics-card {
|
.analytics-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -69,14 +70,72 @@
|
|||||||
padding: 30px;
|
padding: 30px;
|
||||||
color: #6c757d;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div class="col">
|
<h1><i class="bi bi-bar-chart-line"></i> Analytics & Lizenzserver Status</h1>
|
||||||
<h2><i class="bi bi-bar-chart-line"></i> Analytics</h2>
|
<div>
|
||||||
<p class="text-muted">Detaillierte Analyse und Berichte</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,40 +160,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Key Metrics Overview -->
|
<!-- Live Statistics Cards -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="stat-box">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="active-licenses">-</div>
|
<div class="stat-number" id="active-licenses">
|
||||||
|
{{ live_stats[0] if live_stats else 0 }}
|
||||||
|
</div>
|
||||||
<div class="stat-label">Aktive Lizenzen</div>
|
<div class="stat-label">Aktive Lizenzen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="stat-box">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="total-validations">-</div>
|
<div class="stat-number" id="total-validations">
|
||||||
<div class="stat-label">Validierungen</div>
|
{{ live_stats[1] if live_stats else 0 }}
|
||||||
|
</div>
|
||||||
|
<div class="stat-label">Validierungen (5 Min)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3 mb-3">
|
||||||
<div class="stat-box">
|
<div class="stat-card">
|
||||||
<div class="stat-value" id="active-devices">-</div>
|
<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 class="stat-label">Aktive Geräte</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- Analytics Notice -->
|
<div class="row">
|
||||||
<div class="analytics-card">
|
<!-- Validation Timeline -->
|
||||||
<div class="no-data">
|
<div class="col-md-8 mb-4">
|
||||||
<i class="bi bi-info-circle" style="font-size: 3rem; color: #6c757d;"></i>
|
<div class="card">
|
||||||
<h5 class="mt-3">Analytics-Daten werden gesammelt</h5>
|
<div class="card-header">
|
||||||
<p>Die detaillierten Analysen stehen zur Verfügung, sobald genügend Daten vorhanden sind.</p>
|
<h5 class="mb-0">Validierungen pro Minute</h5>
|
||||||
<p>Nutzen Sie das <a href="{{ url_for('monitoring.live_dashboard') }}">Live Dashboard</a> für Echtzeit-Statistiken.</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Options -->
|
<!-- Export Options -->
|
||||||
<div class="analytics-card mt-4">
|
<div class="analytics-card">
|
||||||
<h5>Berichte exportieren</h5>
|
<h5>Berichte exportieren</h5>
|
||||||
<div class="export-buttons">
|
<div class="export-buttons">
|
||||||
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
|
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
|
||||||
@@ -151,7 +315,45 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<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() {
|
function loadAnalyticsData() {
|
||||||
// Load basic statistics from database
|
// Load basic statistics from database
|
||||||
fetch('/monitoring/api/live-stats')
|
fetch('/monitoring/api/live-stats')
|
||||||
@@ -159,33 +361,92 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
document.getElementById('active-licenses').textContent = data.active_licenses || '0';
|
document.getElementById('active-licenses').textContent = data.active_licenses || '0';
|
||||||
document.getElementById('total-validations').textContent = data.validations_last_minute || '0';
|
document.getElementById('total-validations').textContent = data.validations_last_minute || '0';
|
||||||
document.getElementById('active-devices').textContent = data.active_devices || '0';
|
document.getElementById('unique-devices').textContent = data.active_devices || '0';
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error('Error loading analytics:', error);
|
console.error('Error loading analytics:', error);
|
||||||
document.getElementById('active-licenses').textContent = '0';
|
document.getElementById('active-licenses').textContent = '0';
|
||||||
document.getElementById('total-validations').textContent = '0';
|
document.getElementById('total-validations').textContent = '0';
|
||||||
document.getElementById('active-devices').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() {
|
function updateAnalytics() {
|
||||||
const range = document.getElementById('date-range').value;
|
const range = document.getElementById('date-range').value;
|
||||||
console.log('Updating analytics for range:', range);
|
console.log('Updating analytics for range:', range);
|
||||||
loadAnalyticsData();
|
loadAnalyticsData();
|
||||||
|
fetchLiveStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshAnalytics() {
|
function refreshAnalytics() {
|
||||||
loadAnalyticsData();
|
loadAnalyticsData();
|
||||||
|
fetchLiveStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportReport(format) {
|
function exportReport(format) {
|
||||||
alert(`Export-Funktion wird implementiert für Format: ${format.toUpperCase()}`);
|
alert(`Export-Funktion wird implementiert für Format: ${format.toUpperCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start auto-refresh
|
||||||
|
if (autoRefresh) {
|
||||||
|
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadAnalyticsData();
|
loadAnalyticsData();
|
||||||
|
fetchLiveStats();
|
||||||
// Refresh every 30 seconds
|
// Refresh every 30 seconds
|
||||||
setInterval(loadAnalyticsData, 30000);
|
setInterval(loadAnalyticsData, 30000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Live Dashboard{% endblock %}
|
{% block title %}Live Dashboard & Analytics{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
|
/* Combined styles from both dashboards */
|
||||||
.stats-card {
|
.stats-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-card:hover {
|
.stats-card:hover {
|
||||||
@@ -83,17 +85,69 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 2px;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h2><i class="bi bi-activity"></i> Live Dashboard</h2>
|
<h2><i class="bi bi-activity"></i> Live Dashboard & Analytics</h2>
|
||||||
<p class="text-muted">Echtzeit-Übersicht der aktiven Kunden-Sessions</p>
|
<p class="text-muted">Echtzeit-Übersicht und Analyse der Lizenznutzung</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<span class="text-muted">Auto-Refresh: <span id="refresh-countdown">30</span>s</span>
|
<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()">
|
<button class="btn btn-sm btn-outline-primary ms-2" onclick="refreshData()">
|
||||||
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
||||||
</button>
|
</button>
|
||||||
@@ -102,86 +156,261 @@
|
|||||||
|
|
||||||
<!-- Statistics Cards -->
|
<!-- Statistics Cards -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
|
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
|
||||||
<div class="stats-number text-primary">{{ stats.active_licenses|default(0) }}</div>
|
<div class="stats-number text-primary" id="active-licenses">{{ live_stats[0] if live_stats else 0 }}</div>
|
||||||
<div class="stats-label">Aktive Kunden</div>
|
<div class="stats-label">Aktive Lizenzen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<i class="bi bi-laptop text-success" style="font-size: 2rem;"></i>
|
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
|
||||||
<div class="stats-number text-success">{{ stats.active_devices|default(0) }}</div>
|
<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 class="stats-label">Aktive Geräte</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<div class="stats-card">
|
<div class="stats-card">
|
||||||
<i class="bi bi-speedometer2 text-info" style="font-size: 2rem;"></i>
|
<i class="bi bi-globe text-warning" style="font-size: 2rem;"></i>
|
||||||
<div class="stats-number text-info" id="validations-per-minute">0</div>
|
<div class="stats-number text-warning" id="unique-ips">{{ live_stats[3] if live_stats else 0 }}</div>
|
||||||
<div class="stats-label">Validierungen/Min</div>
|
<div class="stats-label">Unique IPs</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Activity Timeline Chart -->
|
<!-- Tabbed Interface -->
|
||||||
<div class="card mb-4">
|
<ul class="nav nav-tabs" id="dashboardTabs" role="tablist">
|
||||||
<div class="card-header">
|
<li class="nav-item" role="presentation">
|
||||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Aktivität (letzte 60 Minuten)</h5>
|
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
|
||||||
</div>
|
<i class="bi bi-speedometer2"></i> Übersicht
|
||||||
<div class="card-body">
|
</button>
|
||||||
<canvas id="activityChart" height="80"></canvas>
|
</li>
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- Active Sessions -->
|
<div class="tab-content" id="dashboardTabContent">
|
||||||
<div class="card">
|
<!-- Overview Tab -->
|
||||||
<div class="card-header">
|
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||||
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions</h5>
|
<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>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div id="active-sessions-container">
|
<!-- Active Sessions Tab -->
|
||||||
{% for session in active_sessions %}
|
<div class="tab-pane fade" id="sessions" role="tabpanel">
|
||||||
<div class="session-card">
|
<div class="card">
|
||||||
<div class="row align-items-center">
|
<div class="card-header">
|
||||||
<div class="col-md-4">
|
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions (letzte 5 Minuten)</h5>
|
||||||
<h6 class="mb-1">
|
</div>
|
||||||
<span class="activity-indicator activity-active"></span>
|
<div class="card-body">
|
||||||
{{ session.company_name }}
|
<div id="active-sessions-container">
|
||||||
</h6>
|
{% for session in active_sessions %}
|
||||||
<small class="text-muted">{{ session.contact_person }}</small>
|
<div class="session-card">
|
||||||
</div>
|
<div class="row align-items-center">
|
||||||
<div class="col-md-3">
|
<div class="col-md-4">
|
||||||
<div class="mb-1">
|
<h6 class="mb-1">
|
||||||
<i class="bi bi-key"></i> {{ session.license_key[:8] }}...
|
<span class="activity-indicator activity-active"></span>
|
||||||
</div>
|
{{ session.company_name }}
|
||||||
<div class="device-info">
|
</h6>
|
||||||
<i class="bi bi-laptop"></i> {{ session.active_devices }} Gerät(e)
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
{% else %}
|
||||||
<div class="geo-info">
|
<div class="text-center text-muted py-5">
|
||||||
<i class="bi bi-geo-alt"></i> {{ session.ip_address }}
|
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
|
||||||
<div><small>Hardware: {{ session.hardware_id[:12] }}...</small></div>
|
<p>Keine aktiven Sessions in den letzten 5 Minuten</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 text-end">
|
{% endfor %}
|
||||||
<div class="text-muted">
|
</div>
|
||||||
<i class="bi bi-clock"></i>
|
</div>
|
||||||
<span class="last-activity" data-timestamp="{{ session.last_activity }}">
|
</div>
|
||||||
vor wenigen Sekunden
|
|
||||||
</span>
|
<!-- 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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,6 +419,9 @@
|
|||||||
<script id="validation-timeline-data" type="application/json">
|
<script id="validation-timeline-data" type="application/json">
|
||||||
{{ validation_timeline|tojson }}
|
{{ validation_timeline|tojson }}
|
||||||
</script>
|
</script>
|
||||||
|
<script id="validation-rates-data" type="application/json">
|
||||||
|
{{ validation_rates|tojson }}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@@ -197,6 +429,7 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let activityChart;
|
let activityChart;
|
||||||
|
let validationChart;
|
||||||
let refreshInterval;
|
let refreshInterval;
|
||||||
let refreshCountdown = 30;
|
let refreshCountdown = 30;
|
||||||
|
|
||||||
@@ -243,6 +476,35 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Update last activity times
|
||||||
function updateActivityTimes() {
|
function updateActivityTimes() {
|
||||||
document.querySelectorAll('.last-activity').forEach(el => {
|
document.querySelectorAll('.last-activity').forEach(el => {
|
||||||
@@ -270,9 +532,9 @@
|
|||||||
const stats = await statsResponse.json();
|
const stats = await statsResponse.json();
|
||||||
|
|
||||||
// Update stats cards
|
// Update stats cards
|
||||||
document.querySelector('.stats-number.text-primary').textContent = stats.active_licenses || 0;
|
document.getElementById('active-licenses').textContent = stats.active_licenses || 0;
|
||||||
document.querySelector('.stats-number.text-success').textContent = stats.active_devices || 0;
|
document.getElementById('unique-devices').textContent = stats.active_devices || 0;
|
||||||
document.getElementById('validations-per-minute').textContent = stats.validations_last_minute || 0;
|
document.getElementById('total-validations').textContent = stats.validations_last_minute || 0;
|
||||||
|
|
||||||
// Get active sessions
|
// Get active sessions
|
||||||
const sessionsResponse = await fetch('/monitoring/api/active-sessions');
|
const sessionsResponse = await fetch('/monitoring/api/active-sessions');
|
||||||
@@ -281,6 +543,9 @@
|
|||||||
// Update sessions display
|
// Update sessions display
|
||||||
updateSessionsDisplay(sessions);
|
updateSessionsDisplay(sessions);
|
||||||
|
|
||||||
|
// Fetch live stats for validation stream
|
||||||
|
fetchLiveStats();
|
||||||
|
|
||||||
// Reset countdown
|
// Reset countdown
|
||||||
refreshCountdown = 30;
|
refreshCountdown = 30;
|
||||||
|
|
||||||
@@ -343,6 +608,40 @@
|
|||||||
container.innerHTML = sessionsHtml;
|
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
|
// Format seconds ago
|
||||||
function formatSecondsAgo(seconds) {
|
function formatSecondsAgo(seconds) {
|
||||||
if (seconds < 60) return 'wenigen Sekunden';
|
if (seconds < 60) return 'wenigen Sekunden';
|
||||||
@@ -354,6 +653,16 @@
|
|||||||
return `${hours} Stunde${hours > 1 ? 'n' : ''}`;
|
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) {
|
||||||
|
alert(`Export-Funktion wird implementiert für Format: ${format.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Countdown timer
|
// Countdown timer
|
||||||
function updateCountdown() {
|
function updateCountdown() {
|
||||||
refreshCountdown--;
|
refreshCountdown--;
|
||||||
@@ -367,6 +676,7 @@
|
|||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initActivityChart();
|
initActivityChart();
|
||||||
|
initValidationChart();
|
||||||
updateActivityTimes();
|
updateActivityTimes();
|
||||||
|
|
||||||
// Set up auto-refresh
|
// Set up auto-refresh
|
||||||
|
|||||||
@@ -182,23 +182,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Grafana Dashboard Embed (if available) -->
|
|
||||||
{% if prometheus_data %}
|
|
||||||
<div class="card mt-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0"><i class="bi bi-graph-up"></i> Performance Dashboard</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<i class="bi bi-info-circle"></i>
|
|
||||||
Für detaillierte Metriken besuchen Sie das
|
|
||||||
<a href="http://localhost:3000" target="_blank" class="alert-link">Grafana Dashboard</a>
|
|
||||||
</div>
|
|
||||||
<!-- Optionally embed Grafana dashboard here -->
|
|
||||||
<!-- <iframe src="http://localhost:3000/d/license-server-overview?orgId=1&theme=light" class="grafana-embed"></iframe> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
|
|||||||
114
v2_adminpanel/utils/partition_helper.py
Normale Datei
114
v2_adminpanel/utils/partition_helper.py
Normale Datei
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Helper functions for managing partitioned tables
|
||||||
|
"""
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def ensure_partition_exists(conn, table_name, timestamp):
|
||||||
|
"""
|
||||||
|
Ensure a partition exists for the given timestamp.
|
||||||
|
Creates the partition if it doesn't exist.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Database connection
|
||||||
|
table_name: Base table name (e.g., 'license_heartbeats')
|
||||||
|
timestamp: Timestamp to check partition for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if partition exists or was created, False on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get year and month from timestamp
|
||||||
|
if isinstance(timestamp, str):
|
||||||
|
timestamp = datetime.fromisoformat(timestamp)
|
||||||
|
|
||||||
|
year = timestamp.year
|
||||||
|
month = timestamp.month
|
||||||
|
|
||||||
|
# Partition name
|
||||||
|
partition_name = f"{table_name}_{year}_{month:02d}"
|
||||||
|
|
||||||
|
# Check if partition exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pg_tables
|
||||||
|
WHERE tablename = %s
|
||||||
|
)
|
||||||
|
""", (partition_name,))
|
||||||
|
|
||||||
|
if cursor.fetchone()[0]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Create partition
|
||||||
|
start_date = f"{year}-{month:02d}-01"
|
||||||
|
if month == 12:
|
||||||
|
end_date = f"{year + 1}-01-01"
|
||||||
|
else:
|
||||||
|
end_date = f"{year}-{month + 1:02d}-01"
|
||||||
|
|
||||||
|
cursor.execute(f"""
|
||||||
|
CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name}
|
||||||
|
FOR VALUES FROM ('{start_date}') TO ('{end_date}')
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Created partition {partition_name}")
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ensuring partition exists: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_future_partitions(conn, table_name, months_ahead=6):
|
||||||
|
"""
|
||||||
|
Create partitions for the next N months
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Database connection
|
||||||
|
table_name: Base table name
|
||||||
|
months_ahead: Number of months to create partitions for
|
||||||
|
"""
|
||||||
|
current_date = datetime.now()
|
||||||
|
|
||||||
|
for i in range(months_ahead + 1):
|
||||||
|
target_date = current_date + relativedelta(months=i)
|
||||||
|
ensure_partition_exists(conn, table_name, target_date)
|
||||||
|
|
||||||
|
def check_table_exists(conn, table_name):
|
||||||
|
"""
|
||||||
|
Check if a table exists in the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Database connection
|
||||||
|
table_name: Table name to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if table exists, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_name = %s
|
||||||
|
)
|
||||||
|
""", (table_name,))
|
||||||
|
|
||||||
|
exists = cursor.fetchone()[0]
|
||||||
|
cursor.close()
|
||||||
|
return exists
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking if table exists: {e}")
|
||||||
|
return False
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren