Vorläufig fertiger server
Dieser Commit ist enthalten in:
40
JOURNAL.md
40
JOURNAL.md
@@ -1,5 +1,45 @@
|
||||
# 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)
|
||||
|
||||
### Monitoring vereinfacht und optimiert
|
||||
|
||||
@@ -685,16 +685,44 @@ Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
||||
- Response Time Monitoring
|
||||
- Keine externen Dependencies
|
||||
|
||||
3. **Alerts** (/monitoring/alerts)
|
||||
- Anomalie-Erkennung aus DB
|
||||
- Ungelöste Probleme
|
||||
- Echtzeit-Updates
|
||||
|
||||
4. **Analytics** (/monitoring/analytics)
|
||||
3. **Analytics** (/monitoring/analytics)
|
||||
- Kombiniert aus Lizenzserver Status + Analytics
|
||||
- Echte Statistiken statt Demo-Daten
|
||||
- Auto-Refresh alle 30 Sekunden
|
||||
- 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:
|
||||
|
||||
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
|
||||
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_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")
|
||||
@login_required
|
||||
def license_monitor():
|
||||
"""License server live monitoring dashboard"""
|
||||
try:
|
||||
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()
|
||||
"""Redirect to new analytics page"""
|
||||
return redirect(url_for('monitoring.analytics'))
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/analytics")
|
||||
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from utils.partition_helper import ensure_partition_exists, check_table_exists
|
||||
|
||||
monitoring_bp = Blueprint('monitoring', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -33,18 +34,33 @@ def login_required(f):
|
||||
@monitoring_bp.route('/live-dashboard')
|
||||
@login_required
|
||||
def live_dashboard():
|
||||
"""Live Dashboard showing active customer sessions"""
|
||||
"""Live Dashboard showing active customer sessions and analytics"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
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)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.id,
|
||||
l.license_key,
|
||||
c.company_name,
|
||||
c.contact_person,
|
||||
c.name as company_name,
|
||||
lh.hardware_id,
|
||||
lh.ip_address,
|
||||
lh.timestamp as last_activity,
|
||||
@@ -71,7 +87,7 @@ def live_dashboard():
|
||||
""")
|
||||
stats = cur.fetchone()
|
||||
|
||||
# Get validations per minute
|
||||
# Get validations per minute (for both charts)
|
||||
cur.execute("""
|
||||
SELECT
|
||||
DATE_TRUNC('minute', timestamp) as minute,
|
||||
@@ -84,13 +100,81 @@ def live_dashboard():
|
||||
""")
|
||||
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()
|
||||
conn.close()
|
||||
|
||||
return render_template('monitoring/live_dashboard.html',
|
||||
active_sessions=active_sessions,
|
||||
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:
|
||||
logger.error(f"Error in live dashboard: {str(e)}")
|
||||
@@ -173,7 +257,7 @@ def alerts():
|
||||
SELECT
|
||||
ad.*,
|
||||
l.license_key,
|
||||
c.company_name
|
||||
c.name as company_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
|
||||
@@ -191,9 +275,91 @@ def alerts():
|
||||
@monitoring_bp.route('/analytics')
|
||||
@login_required
|
||||
def analytics():
|
||||
"""Detailed analytics page"""
|
||||
# This will integrate with the existing analytics service
|
||||
return render_template('monitoring/analytics.html')
|
||||
"""Combined analytics and license server status page"""
|
||||
try:
|
||||
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
|
||||
@monitoring_bp.route('/api/live-stats')
|
||||
@@ -235,7 +401,7 @@ def api_active_sessions():
|
||||
cur.execute("""
|
||||
SELECT
|
||||
l.license_key,
|
||||
c.company_name,
|
||||
c.name as company_name,
|
||||
lh.hardware_id,
|
||||
lh.ip_address,
|
||||
lh.timestamp as last_activity,
|
||||
|
||||
@@ -409,24 +409,12 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Audit-Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['monitoring.live_dashboard', 'monitoring.system_status', 'monitoring.alerts', 'monitoring.analytics'] %}has-active-child{% endif %}">
|
||||
<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 has-submenu" href="{{ url_for('monitoring.live_dashboard') }}">
|
||||
<i class="bi bi-activity"></i>
|
||||
<span>Monitoring</span>
|
||||
</a>
|
||||
<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">
|
||||
<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>
|
||||
@@ -434,19 +422,25 @@
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'monitoring.alerts' %}active{% endif %}" href="{{ url_for('monitoring.alerts') }}">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>Alerts</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 class="nav-link {% if request.endpoint == 'admin.license_anomalies' %}active{% endif %}" href="{{ url_for('admin.license_anomalies') }}">
|
||||
<i class="bi bi-bug"></i>
|
||||
<span>Lizenz-Anomalien</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['admin.audit_log', 'admin.backups', 'admin.blocked_ips', 'admin.license_config'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu" href="{{ url_for('admin.license_config') }}">
|
||||
<i class="bi bi-tools"></i>
|
||||
<span>Administration</span>
|
||||
</a>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.audit_log' %}active{% endif %}" href="{{ url_for('admin.audit_log') }}">
|
||||
<i class="bi bi-journal-text"></i>
|
||||
<span>Audit-Log</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.backups' %}active{% endif %}" href="{{ url_for('admin.backups') }}">
|
||||
<i class="bi bi-cloud-download"></i>
|
||||
@@ -455,32 +449,8 @@
|
||||
</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>
|
||||
<ul class="sidebar-submenu">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.license_analytics' %}active{% endif %}" href="{{ url_for('admin.license_analytics') }}">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<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>
|
||||
<span>Anomalien</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.license_config' %}active{% endif %}" href="{{ url_for('admin.license_config') }}">
|
||||
<i class="bi bi-gear"></i>
|
||||
<span>Konfiguration</span>
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
<span>Gesperrte IPs</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
</a>
|
||||
</div>
|
||||
<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-body text-center">
|
||||
<div class="card-icon text-success{% if stats.active_sessions > 0 %} pulse-effect{% endif %}">🟢</div>
|
||||
@@ -102,7 +101,6 @@
|
||||
<div class="card-label text-muted">Aktive Sessions</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</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" %}
|
||||
|
||||
{% block title %}Analytics{% endblock %}
|
||||
{% block title %}Analytics & Lizenzserver Status{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Analytics Styles */
|
||||
.analytics-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
@@ -69,14 +70,72 @@
|
||||
padding: 30px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* License Monitor Styles */
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.validation-timeline {
|
||||
height: 300px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-bar-chart-line"></i> Analytics</h2>
|
||||
<p class="text-muted">Detaillierte Analyse und Berichte</p>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-bar-chart-line"></i> Analytics & Lizenzserver Status</h1>
|
||||
<div>
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-3" onclick="toggleAutoRefresh()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Auto-Refresh: <span id="refresh-status">AN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -101,40 +160,145 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Key Metrics Overview -->
|
||||
<!-- Live Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value" id="active-licenses">-</div>
|
||||
<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-4">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value" id="total-validations">-</div>
|
||||
<div class="stat-label">Validierungen</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-4">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value" id="active-devices">-</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>
|
||||
|
||||
<!-- Analytics Notice -->
|
||||
<div class="analytics-card">
|
||||
<div class="no-data">
|
||||
<i class="bi bi-info-circle" style="font-size: 3rem; color: #6c757d;"></i>
|
||||
<h5 class="mt-3">Analytics-Daten werden gesammelt</h5>
|
||||
<p>Die detaillierten Analysen stehen zur Verfügung, sobald genügend Daten vorhanden sind.</p>
|
||||
<p>Nutzen Sie das <a href="{{ url_for('monitoring.live_dashboard') }}">Live Dashboard</a> für Echtzeit-Statistiken.</p>
|
||||
<div class="row">
|
||||
<!-- Validation Timeline -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge badge-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="analytics-card mt-4">
|
||||
<div class="analytics-card">
|
||||
<h5>Berichte exportieren</h5>
|
||||
<div class="export-buttons">
|
||||
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
|
||||
@@ -151,7 +315,45 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let autoRefresh = true;
|
||||
let refreshInterval;
|
||||
let validationChart;
|
||||
|
||||
// Initialize validation chart
|
||||
const ctx = document.getElementById('validationChart').getContext('2d');
|
||||
validationChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: [],
|
||||
borderColor: 'rgb(40, 167, 69)',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update chart with validation rates
|
||||
{% if validation_rates %}
|
||||
const rates = {{ validation_rates|tojson }};
|
||||
validationChart.data.labels = rates.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse();
|
||||
validationChart.data.datasets[0].data = rates.map(r => r[1]).reverse();
|
||||
validationChart.update();
|
||||
{% endif %}
|
||||
|
||||
function loadAnalyticsData() {
|
||||
// Load basic statistics from database
|
||||
fetch('/monitoring/api/live-stats')
|
||||
@@ -159,33 +361,92 @@
|
||||
.then(data => {
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses || '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 => {
|
||||
console.error('Error loading analytics:', error);
|
||||
document.getElementById('active-licenses').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() {
|
||||
const range = document.getElementById('date-range').value;
|
||||
console.log('Updating analytics for range:', range);
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function refreshAnalytics() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
}
|
||||
|
||||
function exportReport(format) {
|
||||
alert(`Export-Funktion wird implementiert für Format: ${format.toUpperCase()}`);
|
||||
}
|
||||
|
||||
// Start auto-refresh
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadAnalyticsData();
|
||||
fetchLiveStats();
|
||||
// Refresh every 30 seconds
|
||||
setInterval(loadAnalyticsData, 30000);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Live Dashboard{% endblock %}
|
||||
{% block title %}Live Dashboard & Analytics{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Combined styles from both dashboards */
|
||||
.stats-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
@@ -11,6 +12,7 @@
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
@@ -83,17 +85,69 @@
|
||||
display: inline-block;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
/* Analytics specific styles */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 300px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #495057;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: var(--bs-primary);
|
||||
background: none;
|
||||
border-bottom-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-activity"></i> Live Dashboard</h2>
|
||||
<p class="text-muted">Echtzeit-Übersicht der aktiven Kunden-Sessions</p>
|
||||
<h2><i class="bi bi-activity"></i> Live Dashboard & Analytics</h2>
|
||||
<p class="text-muted">Echtzeit-Übersicht und Analyse der Lizenznutzung</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<span class="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()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
||||
</button>
|
||||
@@ -102,31 +156,62 @@
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-people-fill text-primary" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-primary">{{ stats.active_licenses|default(0) }}</div>
|
||||
<div class="stats-label">Aktive Kunden</div>
|
||||
<div class="stats-number text-primary" id="active-licenses">{{ live_stats[0] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-laptop text-success" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-success">{{ stats.active_devices|default(0) }}</div>
|
||||
<i class="bi bi-shield-check text-success" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-success" id="total-validations">{{ live_stats[1] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-laptop text-info" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-info" id="unique-devices">{{ live_stats[2] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<i class="bi bi-speedometer2 text-info" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-info" id="validations-per-minute">0</div>
|
||||
<div class="stats-label">Validierungen/Min</div>
|
||||
<i class="bi bi-globe text-warning" style="font-size: 2rem;"></i>
|
||||
<div class="stats-number text-warning" id="unique-ips">{{ live_stats[3] if live_stats else 0 }}</div>
|
||||
<div class="stats-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabbed Interface -->
|
||||
<ul class="nav nav-tabs" id="dashboardTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
|
||||
<i class="bi bi-speedometer2"></i> Übersicht
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="sessions-tab" data-bs-toggle="tab" data-bs-target="#sessions" type="button" role="tab">
|
||||
<i class="bi bi-people"></i> Aktive Sessions
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="analytics-tab" data-bs-toggle="tab" data-bs-target="#analytics" type="button" role="tab">
|
||||
<i class="bi bi-bar-chart-line"></i> Analytics
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="dashboardTabContent">
|
||||
<!-- Overview Tab -->
|
||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||
<div class="row">
|
||||
<!-- Activity Timeline Chart -->
|
||||
<div class="card mb-4">
|
||||
<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>
|
||||
@@ -134,11 +219,90 @@
|
||||
<canvas id="activityChart" height="80"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<!-- 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"><i class="bi bi-people"></i> Aktive Kunden-Sessions</h5>
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Tab -->
|
||||
<div class="tab-pane fade" id="sessions" role="tabpanel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-people"></i> Aktive Kunden-Sessions (letzte 5 Minuten)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="active-sessions-container">
|
||||
@@ -186,10 +350,78 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<div class="tab-pane fade" id="analytics" role="tabpanel">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute (30 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Options -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Berichte exportieren</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label>Zeitraum auswählen:</label>
|
||||
<select class="form-select" id="date-range" onchange="updateAnalytics()">
|
||||
<option value="today">Heute</option>
|
||||
<option value="week" selected>Letzte 7 Tage</option>
|
||||
<option value="month">Letzte 30 Tage</option>
|
||||
<option value="quarter">Letztes Quartal</option>
|
||||
<option value="year">Letztes Jahr</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label> </label>
|
||||
<div>
|
||||
<button class="btn btn-outline-primary me-2" onclick="exportReport('pdf')">
|
||||
<i class="bi bi-file-pdf"></i> PDF Export
|
||||
</button>
|
||||
<button class="btn btn-outline-success me-2" onclick="exportReport('excel')">
|
||||
<i class="bi bi-file-excel"></i> Excel Export
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="exportReport('csv')">
|
||||
<i class="bi bi-file-text"></i> CSV Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden validation timeline data for chart -->
|
||||
<script id="validation-timeline-data" type="application/json">
|
||||
{{ validation_timeline|tojson }}
|
||||
</script>
|
||||
<script id="validation-rates-data" type="application/json">
|
||||
{{ validation_rates|tojson }}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@@ -197,6 +429,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/date-fns@2.29.3/index.min.js"></script>
|
||||
<script>
|
||||
let activityChart;
|
||||
let validationChart;
|
||||
let refreshInterval;
|
||||
let refreshCountdown = 30;
|
||||
|
||||
@@ -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
|
||||
function updateActivityTimes() {
|
||||
document.querySelectorAll('.last-activity').forEach(el => {
|
||||
@@ -270,9 +532,9 @@
|
||||
const stats = await statsResponse.json();
|
||||
|
||||
// Update stats cards
|
||||
document.querySelector('.stats-number.text-primary').textContent = stats.active_licenses || 0;
|
||||
document.querySelector('.stats-number.text-success').textContent = stats.active_devices || 0;
|
||||
document.getElementById('validations-per-minute').textContent = stats.validations_last_minute || 0;
|
||||
document.getElementById('active-licenses').textContent = stats.active_licenses || 0;
|
||||
document.getElementById('unique-devices').textContent = stats.active_devices || 0;
|
||||
document.getElementById('total-validations').textContent = stats.validations_last_minute || 0;
|
||||
|
||||
// Get active sessions
|
||||
const sessionsResponse = await fetch('/monitoring/api/active-sessions');
|
||||
@@ -281,6 +543,9 @@
|
||||
// Update sessions display
|
||||
updateSessionsDisplay(sessions);
|
||||
|
||||
// Fetch live stats for validation stream
|
||||
fetchLiveStats();
|
||||
|
||||
// Reset countdown
|
||||
refreshCountdown = 30;
|
||||
|
||||
@@ -343,6 +608,40 @@
|
||||
container.innerHTML = sessionsHtml;
|
||||
}
|
||||
|
||||
// Fetch live statistics
|
||||
function fetchLiveStats() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update validation stream
|
||||
const stream = document.getElementById('validation-stream');
|
||||
const newEntries = data.latest_validations.map(v =>
|
||||
`<div class="d-flex justify-content-between border-bottom py-2">
|
||||
<span>
|
||||
<code>${v.license_key}</code> |
|
||||
<span class="text-muted">${v.hardware_id}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-secondary">${v.ip_address}</span>
|
||||
<span class="text-muted ms-2">${v.timestamp}</span>
|
||||
</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (newEntries) {
|
||||
stream.innerHTML = newEntries + stream.innerHTML;
|
||||
// Keep only last 20 entries
|
||||
const entries = stream.querySelectorAll('div');
|
||||
if (entries.length > 20) {
|
||||
for (let i = 20; i < entries.length; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching live stats:', error));
|
||||
}
|
||||
|
||||
// Format seconds ago
|
||||
function formatSecondsAgo(seconds) {
|
||||
if (seconds < 60) return 'wenigen Sekunden';
|
||||
@@ -354,6 +653,16 @@
|
||||
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
|
||||
function updateCountdown() {
|
||||
refreshCountdown--;
|
||||
@@ -367,6 +676,7 @@
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initActivityChart();
|
||||
initValidationChart();
|
||||
updateActivityTimes();
|
||||
|
||||
// Set up auto-refresh
|
||||
|
||||
@@ -182,23 +182,6 @@
|
||||
</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 -->
|
||||
<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