Lizenzserver im Adminpanel
Dieser Commit ist enthalten in:
162
LIZENZSERVER.md
162
LIZENZSERVER.md
@@ -299,6 +299,94 @@ CREATE TABLE anomaly_detections (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Neue Service-Endpunkte (18.06.2025)
|
||||||
|
|
||||||
|
### Analytics Service API (Port 5003)
|
||||||
|
```yaml
|
||||||
|
# Usage Statistics
|
||||||
|
GET /api/v1/analytics/usage
|
||||||
|
Query: days, customer_id
|
||||||
|
Response: Daily usage trends with active licenses/devices
|
||||||
|
|
||||||
|
# Performance Metrics
|
||||||
|
GET /api/v1/analytics/performance
|
||||||
|
Query: days
|
||||||
|
Response: Hourly performance metrics
|
||||||
|
|
||||||
|
# Anomaly Statistics
|
||||||
|
GET /api/v1/analytics/anomalies
|
||||||
|
Query: days
|
||||||
|
Response: Anomaly breakdown by type and severity
|
||||||
|
|
||||||
|
# License Distribution
|
||||||
|
GET /api/v1/analytics/distribution
|
||||||
|
Response: License distribution by type and status
|
||||||
|
|
||||||
|
# Revenue Impact
|
||||||
|
GET /api/v1/analytics/revenue
|
||||||
|
Query: days
|
||||||
|
Response: Revenue analysis by license type
|
||||||
|
|
||||||
|
# Geographic Distribution
|
||||||
|
GET /api/v1/analytics/geographic
|
||||||
|
Response: Top 100 locations by usage
|
||||||
|
|
||||||
|
# Usage Patterns
|
||||||
|
GET /api/v1/analytics/patterns
|
||||||
|
Query: license_id (optional)
|
||||||
|
Response: Hourly/daily usage patterns
|
||||||
|
|
||||||
|
# Churn Risk Analysis
|
||||||
|
GET /api/v1/analytics/churn-risk
|
||||||
|
Response: Customers sorted by churn risk
|
||||||
|
|
||||||
|
# Customer Summary
|
||||||
|
GET /api/v1/analytics/summary/{customer_id}
|
||||||
|
Response: Comprehensive customer analytics
|
||||||
|
|
||||||
|
# Real-time Stats
|
||||||
|
GET /api/v1/analytics/realtime
|
||||||
|
Response: Current live statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin API Service (Port 5004)
|
||||||
|
```yaml
|
||||||
|
# License Management
|
||||||
|
GET /api/v1/admin/licenses
|
||||||
|
Query: page, per_page, customer_id, is_active, license_type
|
||||||
|
POST /api/v1/admin/licenses
|
||||||
|
GET /api/v1/admin/licenses/{id}
|
||||||
|
PUT /api/v1/admin/licenses/{id}
|
||||||
|
DELETE /api/v1/admin/licenses/{id}
|
||||||
|
|
||||||
|
# Batch Operations
|
||||||
|
POST /api/v1/admin/licenses/batch
|
||||||
|
POST /api/v1/admin/licenses/batch/activate
|
||||||
|
|
||||||
|
# Device Management
|
||||||
|
GET /api/v1/admin/licenses/{id}/devices
|
||||||
|
DELETE /api/v1/admin/licenses/{id}/devices/{hardware_id}
|
||||||
|
|
||||||
|
# Customer Management
|
||||||
|
GET /api/v1/admin/customers
|
||||||
|
Query: page, per_page, search
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
GET /api/v1/admin/config/feature-flags
|
||||||
|
PUT /api/v1/admin/config/feature-flags/{id}
|
||||||
|
|
||||||
|
# API Key Management
|
||||||
|
GET /api/v1/admin/api-keys
|
||||||
|
POST /api/v1/admin/api-keys
|
||||||
|
|
||||||
|
# Audit Log
|
||||||
|
GET /api/v1/admin/audit-log
|
||||||
|
Query: page, per_page, action, username, start_date, end_date
|
||||||
|
|
||||||
|
# System Statistics
|
||||||
|
GET /api/v1/admin/stats/overview
|
||||||
|
```
|
||||||
|
|
||||||
## API-Endpunkte
|
## API-Endpunkte
|
||||||
|
|
||||||
### Public API (Client-Software)
|
### Public API (Client-Software)
|
||||||
@@ -483,15 +571,17 @@ Cache-Keys:
|
|||||||
- [ ] Load Testing
|
- [ ] Load Testing
|
||||||
- [ ] Documentation
|
- [ ] Documentation
|
||||||
|
|
||||||
## Aktueller Implementierungsstand (Stand: 18.06.2025)
|
## Aktueller Implementierungsstand (Stand: 18.06.2025 - UPDATED)
|
||||||
|
|
||||||
### ✅ Fertiggestellte Komponenten:
|
### ✅ Fertiggestellte Komponenten:
|
||||||
|
|
||||||
#### 1. **Microservices-Architektur**
|
#### 1. **Microservices-Architektur**
|
||||||
- **Auth Service** (Port 5001): JWT-Token-Generierung und -Validierung
|
- **Auth Service** (Port 5001): JWT-Token-Generierung und -Validierung ✅
|
||||||
- **License API Service** (Port 5002): Lizenzvalidierung, Aktivierung, Heartbeat
|
- **License API Service** (Port 5002): Lizenzvalidierung, Aktivierung, Heartbeat ✅
|
||||||
- **Docker Compose**: Vollständiges Setup mit Redis, RabbitMQ, PostgreSQL
|
- **Analytics Service** (Port 5003): Umfassende Analyse-Funktionen ✅ NEU!
|
||||||
- **Netzwerk**: Gemeinsames `v2_network` für Service-Kommunikation
|
- **Admin API Service** (Port 5004): CRUD-Operationen und Batch-Processing ✅ NEU!
|
||||||
|
- **Docker Compose**: Vollständiges Setup mit Redis, RabbitMQ, PostgreSQL ✅
|
||||||
|
- **Netzwerk**: Gemeinsames `internal_net` für Service-Kommunikation ✅
|
||||||
|
|
||||||
#### 2. **Datenbank-Erweiterungen**
|
#### 2. **Datenbank-Erweiterungen**
|
||||||
Alle neuen Tabellen wurden erfolgreich implementiert:
|
Alle neuen Tabellen wurden erfolgreich implementiert:
|
||||||
@@ -525,16 +615,20 @@ Alle neuen Tabellen wurden erfolgreich implementiert:
|
|||||||
|
|
||||||
#### 5. **Admin Panel Integration**
|
#### 5. **Admin Panel Integration**
|
||||||
Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
||||||
- **Live Monitor** (`/lizenzserver/monitor`):
|
- **Live Monitor** (`/lizenzserver/monitor`): ✅
|
||||||
- Echtzeit-Statistiken (aktive Lizenzen, Validierungen/Min)
|
- Echtzeit-Statistiken (aktive Lizenzen, Validierungen/Min)
|
||||||
- Top 10 aktive Lizenzen
|
- Top 10 aktive Lizenzen
|
||||||
- Aktuelle Anomalien
|
- Aktuelle Anomalien
|
||||||
- Validierungs-Timeline mit Chart.js
|
- Validierungs-Timeline mit Chart.js
|
||||||
- **Analytics** (`/lizenzserver/analytics`): Placeholder für detaillierte Analysen
|
- **Analytics** (`/lizenzserver/analytics`): ✅ FERTIG!
|
||||||
- **Anomalien** (`/lizenzserver/anomalies`):
|
- Integration mit Analytics Service API
|
||||||
|
- Nutzungstrends und Performance-Metriken
|
||||||
|
- Revenue Analysis und Churn Risk
|
||||||
|
- Interaktive Charts mit Chart.js
|
||||||
|
- **Anomalien** (`/lizenzserver/anomalies`): ✅
|
||||||
- Anomalie-Liste mit Filterung
|
- Anomalie-Liste mit Filterung
|
||||||
- Anomalie-Resolution mit Audit-Log
|
- Anomalie-Resolution mit Audit-Log
|
||||||
- **Konfiguration** (`/lizenzserver/config`):
|
- **Konfiguration** (`/lizenzserver/config`): ✅
|
||||||
- Feature Flag Management
|
- Feature Flag Management
|
||||||
- API Client Verwaltung
|
- API Client Verwaltung
|
||||||
- Rate Limit Konfiguration
|
- Rate Limit Konfiguration
|
||||||
@@ -546,15 +640,32 @@ Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
|||||||
- 💤 Offline (länger als 1h inaktiv)
|
- 💤 Offline (länger als 1h inaktiv)
|
||||||
- ⚠️ Anzahl ungelöster Anomalien
|
- ⚠️ Anzahl ungelöster Anomalien
|
||||||
|
|
||||||
### 🚧 In Entwicklung:
|
### ✅ NEU: Fertiggestellte Services (18.06.2025):
|
||||||
|
|
||||||
1. **Analytics Service** (Port 5003)
|
1. **Analytics Service** (Port 5003) ✅ FERTIG!
|
||||||
- Grundstruktur vorhanden
|
- Vollständige Implementierung mit allen geplanten Features:
|
||||||
- Detaillierte Implementierung ausstehend
|
- Usage Statistics API
|
||||||
|
- Performance Metrics
|
||||||
|
- Anomaly Statistics
|
||||||
|
- Revenue Impact Analysis
|
||||||
|
- Geographic Distribution
|
||||||
|
- Usage Patterns & Churn Risk Calculation
|
||||||
|
- Integration in Admin Panel Analytics-Seite
|
||||||
|
- JWT-basierte Authentifizierung
|
||||||
|
- Redis-Caching für Performance
|
||||||
|
|
||||||
2. **Admin API Service** (Port 5004)
|
2. **Admin API Service** (Port 5004) ✅ FERTIG!
|
||||||
- Struktur vorbereitet
|
- Vollständige CRUD-API für License Management:
|
||||||
- Implementation pending
|
- License CRUD mit Pagination und Filterung
|
||||||
|
- Batch-Operationen (Bulk Create/Update)
|
||||||
|
- Customer Management API
|
||||||
|
- Device Management
|
||||||
|
- Feature Flag Management
|
||||||
|
- API Key Management
|
||||||
|
- Audit Log API
|
||||||
|
- System Overview Statistics
|
||||||
|
- JWT-basierte Admin-Authentifizierung
|
||||||
|
- Comprehensive Audit Logging
|
||||||
|
|
||||||
### 📋 Noch zu implementieren:
|
### 📋 Noch zu implementieren:
|
||||||
|
|
||||||
@@ -582,20 +693,25 @@ Neuer Menüpunkt "Lizenzserver" mit folgenden Unterseiten:
|
|||||||
### 🔧 Technische Details:
|
### 🔧 Technische Details:
|
||||||
|
|
||||||
- **Python Version**: 3.11
|
- **Python Version**: 3.11
|
||||||
- **Flask Version**: 3.0.0
|
- **Flask Version**: 3.0.0 (Analytics & Admin Services)
|
||||||
|
- **FastAPI**: License Server
|
||||||
- **PostgreSQL**: 15 mit UUID-Extension
|
- **PostgreSQL**: 15 mit UUID-Extension
|
||||||
- **Redis**: 7-alpine für Caching
|
- **Redis**: 7-alpine für Caching (3 separate Datenbanken)
|
||||||
- **RabbitMQ**: 3-management für Event Bus
|
- **RabbitMQ**: 3-management für Event Bus
|
||||||
- **JWT**: PyJWT 2.8.0
|
- **JWT**: PyJWT 2.8.0
|
||||||
- **Psycopg2**: 2.9.9 für PostgreSQL
|
- **Psycopg2**: 2.9.9 für PostgreSQL
|
||||||
|
- **Nginx**: Reverse Proxy mit SSL/TLS
|
||||||
|
|
||||||
### 📝 Nächste Schritte:
|
### 📝 Nächste Schritte:
|
||||||
|
|
||||||
1. Analytics Service vollständig implementieren
|
1. ~~Analytics Service vollständig implementieren~~ ✅ ERLEDIGT!
|
||||||
2. Prometheus Monitoring aufsetzen
|
2. ~~Admin API Service implementieren~~ ✅ ERLEDIGT!
|
||||||
3. Load Testing durchführen
|
3. Prometheus Monitoring aufsetzen
|
||||||
4. API-Dokumentation mit Swagger erstellen
|
4. Grafana Dashboards erstellen
|
||||||
5. Kubernetes Deployment vorbereiten
|
5. Load Testing durchführen
|
||||||
|
6. API-Dokumentation mit Swagger erstellen
|
||||||
|
7. Kubernetes Deployment vorbereiten
|
||||||
|
8. WebSocket für Real-time Updates implementieren
|
||||||
|
|
||||||
## Testing-Strategie
|
## Testing-Strategie
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,40 @@ services:
|
|||||||
cpus: '2'
|
cpus: '2'
|
||||||
memory: 4g
|
memory: 4g
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis-cache
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
networks:
|
||||||
|
- internal_net
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512m
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
image: rabbitmq:3-management
|
||||||
|
container_name: rabbitmq
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin}
|
||||||
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASS:-admin}
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
networks:
|
||||||
|
- internal_net
|
||||||
|
volumes:
|
||||||
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 1g
|
||||||
|
|
||||||
license-server:
|
license-server:
|
||||||
build:
|
build:
|
||||||
context: ../v2_lizenzserver
|
context: ../v2_lizenzserver
|
||||||
@@ -34,8 +68,11 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
|
REDIS_URL: redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
networks:
|
networks:
|
||||||
- internal_net
|
- internal_net
|
||||||
deploy:
|
deploy:
|
||||||
@@ -44,6 +81,56 @@ services:
|
|||||||
cpus: '2'
|
cpus: '2'
|
||||||
memory: 4g
|
memory: 4g
|
||||||
|
|
||||||
|
analytics-service:
|
||||||
|
build:
|
||||||
|
context: ../v2_lizenzserver/services/analytics
|
||||||
|
container_name: analytics-service
|
||||||
|
restart: always
|
||||||
|
# Port 5003 - nur intern erreichbar
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/v2_adminpanel
|
||||||
|
REDIS_URL: redis://redis:6379/2
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
FLASK_ENV: production
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
networks:
|
||||||
|
- internal_net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 2g
|
||||||
|
|
||||||
|
admin-api-service:
|
||||||
|
build:
|
||||||
|
context: ../v2_lizenzserver/services/admin
|
||||||
|
container_name: admin-api-service
|
||||||
|
restart: always
|
||||||
|
# Port 5004 - nur intern erreichbar
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/v2_adminpanel
|
||||||
|
REDIS_URL: redis://redis:6379/3
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
FLASK_ENV: production
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
- rabbitmq
|
||||||
|
networks:
|
||||||
|
- internal_net
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '1'
|
||||||
|
memory: 2g
|
||||||
|
|
||||||
admin-panel:
|
admin-panel:
|
||||||
build:
|
build:
|
||||||
context: ../v2_adminpanel
|
context: ../v2_adminpanel
|
||||||
@@ -79,6 +166,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- admin-panel
|
- admin-panel
|
||||||
- license-server
|
- license-server
|
||||||
|
- analytics-service
|
||||||
|
- admin-api-service
|
||||||
networks:
|
networks:
|
||||||
- internal_net
|
- internal_net
|
||||||
|
|
||||||
@@ -88,3 +177,5 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
|
rabbitmq_data:
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ python-dateutil
|
|||||||
bcrypt
|
bcrypt
|
||||||
pyotp
|
pyotp
|
||||||
qrcode[pil]
|
qrcode[pil]
|
||||||
|
PyJWT
|
||||||
|
|||||||
@@ -794,7 +794,12 @@ def license_anomalies():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f'Fehler beim Laden der Anomalie-Daten: {str(e)}', 'error')
|
flash(f'Fehler beim Laden der Anomalie-Daten: {str(e)}', 'error')
|
||||||
return render_template('license_anomalies.html')
|
return render_template('license_anomalies.html',
|
||||||
|
anomalies=[],
|
||||||
|
anomaly_stats=[],
|
||||||
|
severity='all',
|
||||||
|
resolved='false'
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
if 'cur' in locals():
|
if 'cur' in locals():
|
||||||
cur.close()
|
cur.close()
|
||||||
@@ -972,3 +977,25 @@ def license_live_stats():
|
|||||||
cur.close()
|
cur.close()
|
||||||
if 'conn' in locals():
|
if 'conn' in locals():
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/admin/license/auth-token")
|
||||||
|
@login_required
|
||||||
|
def get_analytics_token():
|
||||||
|
"""Get JWT token for accessing Analytics Service"""
|
||||||
|
import jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Generate a short-lived token for the analytics service
|
||||||
|
payload = {
|
||||||
|
'sub': session.get('user_id', 'admin'),
|
||||||
|
'type': 'analytics_access',
|
||||||
|
'exp': datetime.utcnow() + timedelta(hours=1),
|
||||||
|
'iat': datetime.utcnow()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use the same secret as configured in the analytics service
|
||||||
|
jwt_secret = os.environ.get('JWT_SECRET', 'your-secret-key')
|
||||||
|
token = jwt.encode(payload, jwt_secret, algorithm='HS256')
|
||||||
|
|
||||||
|
return jsonify({'token': token})
|
||||||
|
|||||||
@@ -434,17 +434,11 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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 %}">
|
<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="#">
|
<a class="nav-link has-submenu" href="{{ url_for('admin.license_monitor') }}">
|
||||||
<i class="bi bi-graph-up"></i>
|
<i class="bi bi-speedometer2"></i>
|
||||||
<span>Lizenzserver</span>
|
<span>Lizenzserver</span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="sidebar-submenu">
|
<ul class="sidebar-submenu">
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link {% if request.endpoint == 'admin.license_monitor' %}active{% endif %}" href="{{ url_for('admin.license_monitor') }}">
|
|
||||||
<i class="bi bi-speedometer2"></i>
|
|
||||||
<span>Live Monitor</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<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.license_analytics' %}active{% endif %}" href="{{ url_for('admin.license_analytics') }}">
|
||||||
<i class="bi bi-bar-chart"></i>
|
<i class="bi bi-bar-chart"></i>
|
||||||
|
|||||||
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
445
v2_adminpanel/templates/license_analytics.html
Normale Datei
@@ -0,0 +1,445 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}License Analytics{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3">License Analytics</h1>
|
||||||
|
|
||||||
|
<!-- Time Range Selector -->
|
||||||
|
<div class="btn-group mb-3" role="group">
|
||||||
|
<a href="{{ url_for('admin.license_analytics', days=7) }}"
|
||||||
|
class="btn btn-sm {% if days == 7 %}btn-primary{% else %}btn-outline-primary{% endif %}">7 Tage</a>
|
||||||
|
<a href="{{ url_for('admin.license_analytics', days=30) }}"
|
||||||
|
class="btn btn-sm {% if days == 30 %}btn-primary{% else %}btn-outline-primary{% endif %}">30 Tage</a>
|
||||||
|
<a href="{{ url_for('admin.license_analytics', days=90) }}"
|
||||||
|
class="btn btn-sm {% if days == 90 %}btn-primary{% else %}btn-outline-primary{% endif %}">90 Tage</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Aktive Lizenzen</h5>
|
||||||
|
<h2 class="text-primary" id="active-licenses">-</h2>
|
||||||
|
<small class="text-muted">In den letzten {{ days }} Tagen</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Aktive Geräte</h5>
|
||||||
|
<h2 class="text-info" id="active-devices">-</h2>
|
||||||
|
<small class="text-muted">Unique Hardware IDs</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Validierungen</h5>
|
||||||
|
<h2 class="text-success" id="total-validations">-</h2>
|
||||||
|
<small class="text-muted">Gesamt in {{ days }} Tagen</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Churn Risk</h5>
|
||||||
|
<h2 class="text-warning" id="churn-risk">-</h2>
|
||||||
|
<small class="text-muted">Kunden mit hohem Risiko</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Trends Chart -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Nutzungstrends</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="usageTrendsChart" height="100"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance Metrics -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Performance Metriken</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="performanceChart" height="150"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Lizenzverteilung</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="distributionChart" height="150"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue Analysis -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Revenue Analysis</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="revenue-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lizenztyp</th>
|
||||||
|
<th>Anzahl Lizenzen</th>
|
||||||
|
<th>Aktive Lizenzen</th>
|
||||||
|
<th>Gesamtumsatz</th>
|
||||||
|
<th>Aktiver Umsatz</th>
|
||||||
|
<th>Inaktiver Umsatz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Wird von JavaScript gefüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Performers -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Top 10 Aktive Lizenzen</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm" id="top-licenses">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Lizenz</th>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Geräte</th>
|
||||||
|
<th>Validierungen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Wird von JavaScript gefüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Churn Risk Kunden</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm" id="churn-risk-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kunde</th>
|
||||||
|
<th>Lizenzen</th>
|
||||||
|
<th>Letzte Aktivität</th>
|
||||||
|
<th>Risk Level</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Wird von JavaScript gefüllt -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Usage Patterns Heatmap -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Nutzungsmuster (Heatmap)</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="usagePatternsChart" height="60"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Configuration
|
||||||
|
const API_BASE_URL = '/api/v1/analytics';
|
||||||
|
const DAYS = {{ days }};
|
||||||
|
|
||||||
|
// Fetch data from Analytics Service
|
||||||
|
async function fetchAnalyticsData() {
|
||||||
|
try {
|
||||||
|
// Get JWT token first (from license server auth)
|
||||||
|
const tokenResponse = await fetch('/api/admin/license/auth-token');
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
const token = tokenData.token;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch all analytics data
|
||||||
|
const [usage, performance, distribution, revenue, patterns, churnRisk] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/usage?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE_URL}/performance?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE_URL}/distribution`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE_URL}/revenue?days=${DAYS}`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE_URL}/patterns`, { headers }).then(r => r.json()),
|
||||||
|
fetch(`${API_BASE_URL}/churn-risk`, { headers }).then(r => r.json())
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update UI with fetched data
|
||||||
|
updateSummaryCards(usage.data, distribution.data, churnRisk.data);
|
||||||
|
createUsageTrendsChart(usage.data);
|
||||||
|
createPerformanceChart(performance.data);
|
||||||
|
createDistributionChart(distribution.data);
|
||||||
|
updateRevenueTable(revenue.data);
|
||||||
|
updateChurnRiskTable(churnRisk.data);
|
||||||
|
createUsagePatternsHeatmap(patterns.data);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching analytics data:', error);
|
||||||
|
// Fallback to database data if API is not available
|
||||||
|
useFallbackData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary cards
|
||||||
|
function updateSummaryCards(usageData, distributionData, churnData) {
|
||||||
|
if (usageData && usageData.length > 0) {
|
||||||
|
const totalValidations = usageData.reduce((sum, day) => sum + day.total_heartbeats, 0);
|
||||||
|
const uniqueLicenses = new Set(usageData.flatMap(day => day.active_licenses)).size;
|
||||||
|
const uniqueDevices = new Set(usageData.flatMap(day => day.active_devices)).size;
|
||||||
|
|
||||||
|
document.getElementById('active-licenses').textContent = uniqueLicenses.toLocaleString();
|
||||||
|
document.getElementById('active-devices').textContent = uniqueDevices.toLocaleString();
|
||||||
|
document.getElementById('total-validations').textContent = totalValidations.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (churnData && churnData.length > 0) {
|
||||||
|
const highRiskCount = churnData.filter(c => c.churn_risk === 'high').length;
|
||||||
|
document.getElementById('churn-risk').textContent = highRiskCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create usage trends chart
|
||||||
|
function createUsageTrendsChart(data) {
|
||||||
|
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(d => new Date(d.date).toLocaleDateString('de-DE')),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Aktive Lizenzen',
|
||||||
|
data: data.map(d => d.active_licenses),
|
||||||
|
borderColor: 'rgb(54, 162, 235)',
|
||||||
|
backgroundColor: 'rgba(54, 162, 235, 0.1)',
|
||||||
|
tension: 0.1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Aktive Geräte',
|
||||||
|
data: data.map(d => d.active_devices),
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
tension: 0.1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create performance chart
|
||||||
|
function createPerformanceChart(data) {
|
||||||
|
const ctx = document.getElementById('performanceChart').getContext('2d');
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(d => new Date(d.hour).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
hour: '2-digit'
|
||||||
|
})),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Validierungen pro Stunde',
|
||||||
|
data: data.map(d => d.validation_count),
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||||
|
borderColor: 'rgba(75, 192, 192, 1)',
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create distribution chart
|
||||||
|
function createDistributionChart(data) {
|
||||||
|
const ctx = document.getElementById('distributionChart').getContext('2d');
|
||||||
|
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(d => `${d.license_type} (${d.is_test ? 'Test' : 'Prod'})`),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Lizenzverteilung',
|
||||||
|
data: data.map(d => d.active_count),
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(255, 99, 132, 0.6)',
|
||||||
|
'rgba(54, 162, 235, 0.6)',
|
||||||
|
'rgba(255, 206, 86, 0.6)',
|
||||||
|
'rgba(75, 192, 192, 0.6)',
|
||||||
|
'rgba(153, 102, 255, 0.6)'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: chartData,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update revenue table
|
||||||
|
function updateRevenueTable(data) {
|
||||||
|
const tbody = document.querySelector('#revenue-table tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.forEach(row => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${row.license_type}</td>
|
||||||
|
<td>${row.total_licenses}</td>
|
||||||
|
<td>${row.total_licenses > 0 ? Math.round((row.active_revenue / row.total_revenue) * 100) : 0}%</td>
|
||||||
|
<td>€${(row.total_revenue || 0).toFixed(2)}</td>
|
||||||
|
<td>€${(row.active_revenue || 0).toFixed(2)}</td>
|
||||||
|
<td>€${(row.inactive_revenue || 0).toFixed(2)}</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update churn risk table
|
||||||
|
function updateChurnRiskTable(data) {
|
||||||
|
const tbody = document.querySelector('#churn-risk-table tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.filter(d => d.churn_risk !== 'low').slice(0, 10).forEach(row => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const riskClass = row.churn_risk === 'high' ? 'danger' : 'warning';
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${row.customer_id}</td>
|
||||||
|
<td>${row.total_licenses}</td>
|
||||||
|
<td>${row.avg_days_since_activity ? Math.round(row.avg_days_since_activity) + ' Tage' : '-'}</td>
|
||||||
|
<td><span class="badge bg-${riskClass}">${row.churn_risk}</span></td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create usage patterns heatmap
|
||||||
|
function createUsagePatternsHeatmap(data) {
|
||||||
|
// Implementation for heatmap would go here
|
||||||
|
// For now, just log the data
|
||||||
|
console.log('Usage patterns data:', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to use existing database data
|
||||||
|
function useFallbackData() {
|
||||||
|
// Use the data passed from the server template
|
||||||
|
const usageTrends = {{ usage_trends | tojson | safe }};
|
||||||
|
const licenseMetrics = {{ license_metrics | tojson | safe }};
|
||||||
|
const deviceDistribution = {{ device_distribution | tojson | safe }};
|
||||||
|
const revenueAnalysis = {{ revenue_analysis | tojson | safe }};
|
||||||
|
|
||||||
|
// Create charts with fallback data
|
||||||
|
if (usageTrends) {
|
||||||
|
const ctx = document.getElementById('usageTrendsChart').getContext('2d');
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: usageTrends.map(d => d[0]),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Aktive Lizenzen',
|
||||||
|
data: usageTrends.map(d => d[1]),
|
||||||
|
borderColor: 'rgb(54, 162, 235)',
|
||||||
|
tension: 0.1
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
fetchAnalyticsData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
241
v2_adminpanel/templates/license_anomalies.html
Normale Datei
@@ -0,0 +1,241 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}License Anomalien{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3">Anomalie-Erkennung</h1>
|
||||||
|
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Schweregrad</label>
|
||||||
|
<select name="severity" class="form-select">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="low" {% if request.args.get('severity') == 'low' %}selected{% endif %}>Niedrig</option>
|
||||||
|
<option value="medium" {% if request.args.get('severity') == 'medium' %}selected{% endif %}>Mittel</option>
|
||||||
|
<option value="high" {% if request.args.get('severity') == 'high' %}selected{% endif %}>Hoch</option>
|
||||||
|
<option value="critical" {% if request.args.get('severity') == 'critical' %}selected{% endif %}>Kritisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select name="resolved" class="form-select">
|
||||||
|
<option value="false" {% if request.args.get('resolved', 'false') == 'false' %}selected{% endif %}>Ungelöst</option>
|
||||||
|
<option value="true" {% if request.args.get('resolved') == 'true' %}selected{% endif %}>Gelöst</option>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Anomalie-Typ</label>
|
||||||
|
<select name="anomaly_type" class="form-select">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="multiple_ips" {% if request.args.get('anomaly_type') == 'multiple_ips' %}selected{% endif %}>Multiple IPs</option>
|
||||||
|
<option value="rapid_hardware_change" {% if request.args.get('anomaly_type') == 'rapid_hardware_change' %}selected{% endif %}>Schneller Hardware-Wechsel</option>
|
||||||
|
<option value="suspicious_pattern" {% if request.args.get('anomaly_type') == 'suspicious_pattern' %}selected{% endif %}>Verdächtiges Muster</option>
|
||||||
|
<option value="concurrent_use" {% if request.args.get('anomaly_type') == 'concurrent_use' %}selected{% endif %}>Gleichzeitige Nutzung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-primary">Filter anwenden</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title text-danger">Kritisch</h5>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% set critical_count = namespace(value=0) %}
|
||||||
|
{% for stat in anomaly_stats if stat[1] == 'critical' %}
|
||||||
|
{% set critical_count.value = critical_count.value + stat[2] %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ critical_count.value }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-warning">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title text-warning">Hoch</h5>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% set high_count = namespace(value=0) %}
|
||||||
|
{% for stat in anomaly_stats if stat[1] == 'high' %}
|
||||||
|
{% set high_count.value = high_count.value + stat[2] %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ high_count.value }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card border-info">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title text-info">Mittel</h5>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% set medium_count = namespace(value=0) %}
|
||||||
|
{% for stat in anomaly_stats if stat[1] == 'medium' %}
|
||||||
|
{% set medium_count.value = medium_count.value + stat[2] %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ medium_count.value }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<h5 class="card-title text-muted">Niedrig</h5>
|
||||||
|
<h2 class="mb-0">
|
||||||
|
{% set low_count = namespace(value=0) %}
|
||||||
|
{% for stat in anomaly_stats if stat[1] == 'low' %}
|
||||||
|
{% set low_count.value = low_count.value + stat[2] %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ low_count.value }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Anomaly List -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Anomalien</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Zeitpunkt</th>
|
||||||
|
<th>Lizenz</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Schweregrad</th>
|
||||||
|
<th>Details</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for anomaly in anomalies %}
|
||||||
|
<tr class="{% if anomaly[6] == 'critical' %}table-danger{% elif anomaly[6] == 'high' %}table-warning{% endif %}">
|
||||||
|
<td>{{ anomaly[3].strftime('%d.%m.%Y %H:%M') if anomaly[3] else '-' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if anomaly[8] %}
|
||||||
|
<small>{{ anomaly[8][:8] }}...</small><br>
|
||||||
|
<span class="text-muted">{{ anomaly[9] }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Unbekannt</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{ anomaly[5] }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{% if anomaly[6] == 'critical' %}danger{% elif anomaly[6] == 'high' %}warning{% elif anomaly[6] == 'medium' %}info{% else %}secondary{% endif %}">
|
||||||
|
{{ anomaly[6] }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-link" onclick="showDetails('{{ anomaly[7] }}')">
|
||||||
|
Details anzeigen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if anomaly[2] %}
|
||||||
|
<span class="badge bg-success">Gelöst</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Ungelöst</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if not anomaly[2] %}
|
||||||
|
<button class="btn btn-sm btn-success" onclick="resolveAnomaly('{{ anomaly[0] }}')">
|
||||||
|
Lösen
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<small class="text-muted">{{ anomaly[4].strftime('%d.%m %H:%M') if anomaly[4] else '' }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="7" class="text-center text-muted">Keine Anomalien gefunden</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Details Modal -->
|
||||||
|
<div class="modal fade" id="detailsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Anomalie Details</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pre id="anomalyDetails"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Resolve Modal -->
|
||||||
|
<div class="modal fade" id="resolveModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="resolveForm" method="post">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Anomalie lösen</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Ergriffene Maßnahme</label>
|
||||||
|
<textarea name="action_taken" class="form-control" rows="3" required></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-success">Als gelöst markieren</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function showDetails(detailsJson) {
|
||||||
|
try {
|
||||||
|
const details = JSON.parse(detailsJson);
|
||||||
|
document.getElementById('anomalyDetails').textContent = JSON.stringify(details, null, 2);
|
||||||
|
new bootstrap.Modal(document.getElementById('detailsModal')).show();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Fehler beim Anzeigen der Details');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnomaly(anomalyId) {
|
||||||
|
const form = document.getElementById('resolveForm');
|
||||||
|
form.action = `/lizenzserver/anomaly/${anomalyId}/resolve`;
|
||||||
|
new bootstrap.Modal(document.getElementById('resolveModal')).show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
239
v2_adminpanel/templates/license_config.html
Normale Datei
239
v2_adminpanel/templates/license_config.html
Normale Datei
@@ -0,0 +1,239 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lizenzserver Konfiguration{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-12">
|
||||||
|
<h1 class="h3">Lizenzserver Konfiguration</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature Flags -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Feature Flags</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feature</th>
|
||||||
|
<th>Beschreibung</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for flag in feature_flags %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ flag[1] }}</strong></td>
|
||||||
|
<td><small>{{ flag[2] }}</small></td>
|
||||||
|
<td>
|
||||||
|
{% if flag[3] %}
|
||||||
|
<span class="badge bg-success">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Inaktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="{{ url_for('admin.toggle_feature_flag', flag_id=flag[0]) }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-sm {% if flag[3] %}btn-danger{% else %}btn-success{% endif %}">
|
||||||
|
{% if flag[3] %}Deaktivieren{% else %}Aktivieren{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">Keine Feature Flags konfiguriert</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Clients -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">API Clients</h5>
|
||||||
|
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#newApiClientModal">
|
||||||
|
<i class="bi bi-plus"></i> Neuer Client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Erstellt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for client in api_clients %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ client[1] }}</td>
|
||||||
|
<td>
|
||||||
|
<code>{{ client[2][:12] }}...</code>
|
||||||
|
<button class="btn btn-sm btn-link" onclick="copyToClipboard('{{ client[2] }}')">
|
||||||
|
<i class="bi bi-clipboard"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if client[3] %}
|
||||||
|
<span class="badge bg-success">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Inaktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ client[4].strftime('%d.%m.%Y') if client[4] else '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted">Keine API Clients vorhanden</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rate Limits -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Rate Limits</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>API Key</th>
|
||||||
|
<th>Requests/Minute</th>
|
||||||
|
<th>Requests/Stunde</th>
|
||||||
|
<th>Requests/Tag</th>
|
||||||
|
<th>Burst Size</th>
|
||||||
|
<th>Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for limit in rate_limits %}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{ limit[1][:12] }}...</code></td>
|
||||||
|
<td>{{ limit[2] }}</td>
|
||||||
|
<td>{{ limit[3] }}</td>
|
||||||
|
<td>{{ limit[4] }}</td>
|
||||||
|
<td>{{ limit[5] }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-warning" onclick="editRateLimit('{{ limit[0] }}', {{ limit[2] }}, {{ limit[3] }}, {{ limit[4] }})">
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted">Keine Rate Limits konfiguriert</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New API Client Modal -->
|
||||||
|
<div class="modal fade" id="newApiClientModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" action="/lizenzserver/config/api-client">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Neuer API Client</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input type="text" name="name" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Beschreibung</label>
|
||||||
|
<textarea name="description" class="form-control" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Erstellen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Rate Limit Modal -->
|
||||||
|
<div class="modal fade" id="editRateLimitModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form id="editRateLimitForm" method="post">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Rate Limit bearbeiten</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Requests pro Minute</label>
|
||||||
|
<input type="number" name="requests_per_minute" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Requests pro Stunde</label>
|
||||||
|
<input type="number" name="requests_per_hour" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Requests pro Tag</label>
|
||||||
|
<input type="number" name="requests_per_day" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
alert('API Key wurde in die Zwischenablage kopiert!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRateLimit(id, rpm, rph, rpd) {
|
||||||
|
const form = document.getElementById('editRateLimitForm');
|
||||||
|
form.action = `/lizenzserver/config/rate-limit/${id}`;
|
||||||
|
form.requests_per_minute.value = rpm;
|
||||||
|
form.requests_per_hour.value = rph;
|
||||||
|
form.requests_per_day.value = rpd;
|
||||||
|
new bootstrap.Modal(document.getElementById('editRateLimitModal')).show();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
29
v2_lizenzserver/services/admin/Dockerfile
Normale Datei
29
v2_lizenzserver/services/admin/Dockerfile
Normale Datei
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 admin && chown -R admin:admin /app
|
||||||
|
USER admin
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5004
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:5004/health').raise_for_status()"
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "app.py"]
|
||||||
1
v2_lizenzserver/services/admin/__init__.py
Normale Datei
1
v2_lizenzserver/services/admin/__init__.py
Normale Datei
@@ -0,0 +1 @@
|
|||||||
|
# Admin API Service Package
|
||||||
739
v2_lizenzserver/services/admin/app.py
Normale Datei
739
v2_lizenzserver/services/admin/app.py
Normale Datei
@@ -0,0 +1,739 @@
|
|||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from psycopg2.pool import SimpleConnectionPool
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
import jwt
|
||||||
|
import uuid
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://postgres:postgres@postgres:5432/v2_adminpanel')
|
||||||
|
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379/3')
|
||||||
|
JWT_SECRET = os.environ.get('JWT_SECRET', 'your-secret-key')
|
||||||
|
SERVICE_PORT = 5004
|
||||||
|
|
||||||
|
# Database connection pool
|
||||||
|
db_pool = SimpleConnectionPool(1, 20, DATABASE_URL)
|
||||||
|
|
||||||
|
# Redis client
|
||||||
|
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
# JWT validation decorator with admin check
|
||||||
|
def require_admin_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if not auth_header or not auth_header.startswith('Bearer '):
|
||||||
|
return jsonify({'error': 'Missing or invalid authorization header'}), 401
|
||||||
|
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||||
|
# Check if user has admin privileges
|
||||||
|
if payload.get('type') not in ['admin_access', 'analytics_access']:
|
||||||
|
return jsonify({'error': 'Insufficient privileges'}), 403
|
||||||
|
request.jwt_payload = payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'Token expired'}), 401
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
# Database query helpers
|
||||||
|
def execute_query(query, params=None, fetchall=True):
|
||||||
|
conn = db_pool.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
if query.strip().upper().startswith(('INSERT', 'UPDATE', 'DELETE')):
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
if fetchall:
|
||||||
|
return cur.fetchall()
|
||||||
|
return cur.fetchone()
|
||||||
|
finally:
|
||||||
|
db_pool.putconn(conn)
|
||||||
|
|
||||||
|
def execute_batch(query, data):
|
||||||
|
conn = db_pool.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.executemany(query, data)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
finally:
|
||||||
|
db_pool.putconn(conn)
|
||||||
|
|
||||||
|
# Audit logging
|
||||||
|
def log_admin_action(action: str, entity_type: str, entity_id: str, details: Dict, user_id: str = None):
|
||||||
|
"""Log admin actions to audit trail"""
|
||||||
|
query = """
|
||||||
|
INSERT INTO audit_log (username, action, timestamp, ip_address, additional_info)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
username = user_id or request.jwt_payload.get('sub', 'system')
|
||||||
|
ip_address = request.headers.get('X-Real-IP', request.remote_addr)
|
||||||
|
additional_info = json.dumps({
|
||||||
|
'entity_type': entity_type,
|
||||||
|
'entity_id': entity_id,
|
||||||
|
'details': details
|
||||||
|
})
|
||||||
|
|
||||||
|
execute_query(query, [username, action, datetime.utcnow(), ip_address, additional_info])
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'service': 'admin-api-service',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# License Management
|
||||||
|
@app.route('/api/v1/admin/licenses', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def list_licenses():
|
||||||
|
"""List all licenses with filtering and pagination"""
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 50))
|
||||||
|
customer_id = request.args.get('customer_id')
|
||||||
|
is_active = request.args.get('is_active')
|
||||||
|
license_type = request.args.get('license_type')
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
# Build query with filters
|
||||||
|
query = """
|
||||||
|
SELECT l.*, c.name as customer_name, c.email as customer_email,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as active_devices,
|
||||||
|
MAX(lh.timestamp) as last_activity
|
||||||
|
FROM licenses l
|
||||||
|
LEFT JOIN customers c ON l.customer_id = c.id
|
||||||
|
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||||
|
AND lh.timestamp > NOW() - INTERVAL '24 hours'
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if customer_id:
|
||||||
|
query += " AND l.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
if is_active is not None:
|
||||||
|
query += " AND l.is_active = %s"
|
||||||
|
params.append(is_active == 'true')
|
||||||
|
if license_type:
|
||||||
|
query += " AND l.license_type = %s"
|
||||||
|
params.append(license_type)
|
||||||
|
|
||||||
|
query += """
|
||||||
|
GROUP BY l.id, c.name, c.email
|
||||||
|
ORDER BY l.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
licenses = execute_query(query, params)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = "SELECT COUNT(*) as total FROM licenses WHERE 1=1"
|
||||||
|
count_params = []
|
||||||
|
if customer_id:
|
||||||
|
count_query += " AND customer_id = %s"
|
||||||
|
count_params.append(customer_id)
|
||||||
|
if is_active is not None:
|
||||||
|
count_query += " AND is_active = %s"
|
||||||
|
count_params.append(is_active == 'true')
|
||||||
|
if license_type:
|
||||||
|
count_query += " AND license_type = %s"
|
||||||
|
count_params.append(license_type)
|
||||||
|
|
||||||
|
total = execute_query(count_query, count_params, fetchall=False)['total']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': licenses,
|
||||||
|
'pagination': {
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'total': total,
|
||||||
|
'pages': (total + per_page - 1) // per_page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses/<license_id>', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def get_license(license_id):
|
||||||
|
"""Get detailed license information"""
|
||||||
|
query = """
|
||||||
|
SELECT l.*, c.name as customer_name, c.email as customer_email,
|
||||||
|
array_agg(DISTINCT lh.hardware_id) as hardware_ids,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||||
|
MIN(lh.timestamp) as first_activation,
|
||||||
|
MAX(lh.timestamp) as last_activity
|
||||||
|
FROM licenses l
|
||||||
|
LEFT JOIN customers c ON l.customer_id = c.id
|
||||||
|
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||||
|
WHERE l.id = %s
|
||||||
|
GROUP BY l.id, c.name, c.email
|
||||||
|
"""
|
||||||
|
license_data = execute_query(query, [license_id], fetchall=False)
|
||||||
|
|
||||||
|
if not license_data:
|
||||||
|
return jsonify({'error': 'License not found'}), 404
|
||||||
|
|
||||||
|
# Get recent activity
|
||||||
|
activity_query = """
|
||||||
|
SELECT hardware_id, ip_address, timestamp, user_agent
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE license_id = %s
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
recent_activity = execute_query(activity_query, [license_id])
|
||||||
|
|
||||||
|
license_data['recent_activity'] = recent_activity
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': license_data
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses', methods=['POST'])
|
||||||
|
@require_admin_auth
|
||||||
|
def create_license():
|
||||||
|
"""Create a new license"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
required_fields = ['customer_id', 'license_type', 'device_limit']
|
||||||
|
if not all(field in data for field in required_fields):
|
||||||
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
|
license_id = str(uuid.uuid4())
|
||||||
|
license_key = f"{data['license_type'].upper()}-{uuid.uuid4().hex[:8].upper()}-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO licenses (id, customer_id, license_key, license_type,
|
||||||
|
device_limit, is_active, expires_at, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if data.get('expires_at'):
|
||||||
|
expires_at = datetime.fromisoformat(data['expires_at'])
|
||||||
|
|
||||||
|
params = [
|
||||||
|
license_id,
|
||||||
|
data['customer_id'],
|
||||||
|
license_key,
|
||||||
|
data['license_type'],
|
||||||
|
data['device_limit'],
|
||||||
|
data.get('is_active', True),
|
||||||
|
expires_at,
|
||||||
|
datetime.utcnow()
|
||||||
|
]
|
||||||
|
|
||||||
|
new_license = execute_query(query, params, fetchall=False)
|
||||||
|
|
||||||
|
log_admin_action('create_license', 'license', license_id, {
|
||||||
|
'license_key': license_key,
|
||||||
|
'customer_id': data['customer_id'],
|
||||||
|
'license_type': data['license_type']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': new_license
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses/<license_id>', methods=['PUT'])
|
||||||
|
@require_admin_auth
|
||||||
|
def update_license(license_id):
|
||||||
|
"""Update license information"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
# Build dynamic update query
|
||||||
|
update_fields = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
allowed_fields = ['is_active', 'device_limit', 'expires_at', 'notes']
|
||||||
|
for field in allowed_fields:
|
||||||
|
if field in data:
|
||||||
|
update_fields.append(f"{field} = %s")
|
||||||
|
params.append(data[field])
|
||||||
|
|
||||||
|
if not update_fields:
|
||||||
|
return jsonify({'error': 'No fields to update'}), 400
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
UPDATE licenses
|
||||||
|
SET {', '.join(update_fields)}, updated_at = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
params.extend([datetime.utcnow(), license_id])
|
||||||
|
|
||||||
|
updated_license = execute_query(query, params, fetchall=False)
|
||||||
|
|
||||||
|
if not updated_license:
|
||||||
|
return jsonify({'error': 'License not found'}), 404
|
||||||
|
|
||||||
|
log_admin_action('update_license', 'license', license_id, data)
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
redis_client.delete(f"license:{license_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': updated_license
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses/<license_id>', methods=['DELETE'])
|
||||||
|
@require_admin_auth
|
||||||
|
def delete_license(license_id):
|
||||||
|
"""Delete a license (soft delete by deactivating)"""
|
||||||
|
query = """
|
||||||
|
UPDATE licenses
|
||||||
|
SET is_active = false, updated_at = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted_license = execute_query(query, [datetime.utcnow(), license_id], fetchall=False)
|
||||||
|
|
||||||
|
if not deleted_license:
|
||||||
|
return jsonify({'error': 'License not found'}), 404
|
||||||
|
|
||||||
|
log_admin_action('delete_license', 'license', license_id, {
|
||||||
|
'license_key': deleted_license['license_key']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear cache
|
||||||
|
redis_client.delete(f"license:{license_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'License deactivated successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Batch Operations
|
||||||
|
@app.route('/api/v1/admin/licenses/batch', methods=['POST'])
|
||||||
|
@require_admin_auth
|
||||||
|
def batch_create_licenses():
|
||||||
|
"""Create multiple licenses at once"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if 'licenses' not in data or not isinstance(data['licenses'], list):
|
||||||
|
return jsonify({'error': 'Invalid request format'}), 400
|
||||||
|
|
||||||
|
created_licenses = []
|
||||||
|
|
||||||
|
for license_data in data['licenses']:
|
||||||
|
license_id = str(uuid.uuid4())
|
||||||
|
license_key = f"{license_data['license_type'].upper()}-{uuid.uuid4().hex[:8].upper()}-{uuid.uuid4().hex[:8].upper()}"
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO licenses (id, customer_id, license_key, license_type,
|
||||||
|
device_limit, is_active, expires_at, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = [
|
||||||
|
license_id,
|
||||||
|
license_data['customer_id'],
|
||||||
|
license_key,
|
||||||
|
license_data['license_type'],
|
||||||
|
license_data.get('device_limit', 1),
|
||||||
|
license_data.get('is_active', True),
|
||||||
|
license_data.get('expires_at'),
|
||||||
|
datetime.utcnow()
|
||||||
|
]
|
||||||
|
|
||||||
|
new_license = execute_query(query, params, fetchall=False)
|
||||||
|
created_licenses.append(new_license)
|
||||||
|
|
||||||
|
log_admin_action('batch_create_licenses', 'license', None, {
|
||||||
|
'count': len(created_licenses),
|
||||||
|
'customer_ids': list(set(l['customer_id'] for l in created_licenses))
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': created_licenses,
|
||||||
|
'count': len(created_licenses)
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses/batch/activate', methods=['POST'])
|
||||||
|
@require_admin_auth
|
||||||
|
def batch_activate_licenses():
|
||||||
|
"""Batch activate/deactivate licenses"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if 'license_ids' not in data or 'is_active' not in data:
|
||||||
|
return jsonify({'error': 'Missing required fields'}), 400
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE licenses
|
||||||
|
SET is_active = %s, updated_at = %s
|
||||||
|
WHERE id = ANY(%s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
affected = execute_query(
|
||||||
|
query,
|
||||||
|
[data['is_active'], datetime.utcnow(), data['license_ids']]
|
||||||
|
)
|
||||||
|
|
||||||
|
log_admin_action('batch_update_licenses', 'license', None, {
|
||||||
|
'action': 'activate' if data['is_active'] else 'deactivate',
|
||||||
|
'count': affected,
|
||||||
|
'license_ids': data['license_ids']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear cache for all affected licenses
|
||||||
|
for license_id in data['license_ids']:
|
||||||
|
redis_client.delete(f"license:{license_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'affected': affected
|
||||||
|
})
|
||||||
|
|
||||||
|
# Customer Management
|
||||||
|
@app.route('/api/v1/admin/customers', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def list_customers():
|
||||||
|
"""List all customers with stats"""
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 50))
|
||||||
|
search = request.args.get('search')
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT c.*,
|
||||||
|
COUNT(DISTINCT l.id) as license_count,
|
||||||
|
COUNT(DISTINCT CASE WHEN l.is_active THEN l.id END) as active_licenses,
|
||||||
|
MAX(lh.timestamp) as last_activity
|
||||||
|
FROM customers c
|
||||||
|
LEFT JOIN licenses l ON c.id = l.customer_id
|
||||||
|
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||||
|
AND lh.timestamp > NOW() - INTERVAL '30 days'
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if search:
|
||||||
|
query += " WHERE c.name ILIKE %s OR c.email ILIKE %s"
|
||||||
|
params.extend([f'%{search}%', f'%{search}%'])
|
||||||
|
|
||||||
|
query += """
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
customers = execute_query(query, params)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = "SELECT COUNT(*) as total FROM customers"
|
||||||
|
if search:
|
||||||
|
count_query += " WHERE name ILIKE %s OR email ILIKE %s"
|
||||||
|
total = execute_query(count_query, [f'%{search}%', f'%{search}%'], fetchall=False)['total']
|
||||||
|
else:
|
||||||
|
total = execute_query(count_query, fetchall=False)['total']
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': customers,
|
||||||
|
'pagination': {
|
||||||
|
'page': page,
|
||||||
|
'per_page': per_page,
|
||||||
|
'total': total,
|
||||||
|
'pages': (total + per_page - 1) // per_page
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# System Configuration
|
||||||
|
@app.route('/api/v1/admin/config/feature-flags', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def list_feature_flags():
|
||||||
|
"""List all feature flags"""
|
||||||
|
query = "SELECT * FROM feature_flags ORDER BY name"
|
||||||
|
flags = execute_query(query)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': flags
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/config/feature-flags/<flag_id>', methods=['PUT'])
|
||||||
|
@require_admin_auth
|
||||||
|
def update_feature_flag(flag_id):
|
||||||
|
"""Update feature flag status"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if 'enabled' not in data:
|
||||||
|
return jsonify({'error': 'Missing enabled field'}), 400
|
||||||
|
|
||||||
|
query = """
|
||||||
|
UPDATE feature_flags
|
||||||
|
SET enabled = %s, updated_at = %s
|
||||||
|
WHERE id = %s
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
updated_flag = execute_query(
|
||||||
|
query,
|
||||||
|
[data['enabled'], datetime.utcnow(), flag_id],
|
||||||
|
fetchall=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not updated_flag:
|
||||||
|
return jsonify({'error': 'Feature flag not found'}), 404
|
||||||
|
|
||||||
|
log_admin_action('update_feature_flag', 'feature_flag', flag_id, {
|
||||||
|
'name': updated_flag['name'],
|
||||||
|
'enabled': data['enabled']
|
||||||
|
})
|
||||||
|
|
||||||
|
# Clear feature flag cache
|
||||||
|
redis_client.delete('feature_flags:all')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': updated_flag
|
||||||
|
})
|
||||||
|
|
||||||
|
# API Key Management
|
||||||
|
@app.route('/api/v1/admin/api-keys', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def list_api_keys():
|
||||||
|
"""List all API keys"""
|
||||||
|
query = """
|
||||||
|
SELECT ak.*, arl.requests_per_minute, arl.requests_per_hour
|
||||||
|
FROM api_clients ak
|
||||||
|
LEFT JOIN api_rate_limits arl ON ak.api_key = arl.api_key
|
||||||
|
ORDER BY ak.created_at DESC
|
||||||
|
"""
|
||||||
|
api_keys = execute_query(query)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': api_keys
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/api-keys', methods=['POST'])
|
||||||
|
@require_admin_auth
|
||||||
|
def create_api_key():
|
||||||
|
"""Create new API key"""
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if 'name' not in data:
|
||||||
|
return jsonify({'error': 'Missing name field'}), 400
|
||||||
|
|
||||||
|
api_key = f"sk_{uuid.uuid4().hex}"
|
||||||
|
|
||||||
|
# Create API client
|
||||||
|
client_query = """
|
||||||
|
INSERT INTO api_clients (api_key, name, is_active, created_at)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
RETURNING *
|
||||||
|
"""
|
||||||
|
|
||||||
|
new_client = execute_query(
|
||||||
|
client_query,
|
||||||
|
[api_key, data['name'], True, datetime.utcnow()],
|
||||||
|
fetchall=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create rate limits
|
||||||
|
rate_query = """
|
||||||
|
INSERT INTO api_rate_limits (api_key, requests_per_minute, requests_per_hour, requests_per_day)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
rate_query,
|
||||||
|
[
|
||||||
|
api_key,
|
||||||
|
data.get('requests_per_minute', 60),
|
||||||
|
data.get('requests_per_hour', 1000),
|
||||||
|
data.get('requests_per_day', 10000)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
log_admin_action('create_api_key', 'api_key', api_key, {
|
||||||
|
'name': data['name']
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': new_client
|
||||||
|
}), 201
|
||||||
|
|
||||||
|
# Audit Log
|
||||||
|
@app.route('/api/v1/admin/audit-log', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def get_audit_log():
|
||||||
|
"""Get audit log entries"""
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', 100))
|
||||||
|
action = request.args.get('action')
|
||||||
|
username = request.args.get('username')
|
||||||
|
start_date = request.args.get('start_date')
|
||||||
|
end_date = request.args.get('end_date')
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
query = "SELECT * FROM audit_log WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if action:
|
||||||
|
query += " AND action = %s"
|
||||||
|
params.append(action)
|
||||||
|
if username:
|
||||||
|
query += " AND username = %s"
|
||||||
|
params.append(username)
|
||||||
|
if start_date:
|
||||||
|
query += " AND timestamp >= %s"
|
||||||
|
params.append(datetime.fromisoformat(start_date))
|
||||||
|
if end_date:
|
||||||
|
query += " AND timestamp <= %s"
|
||||||
|
params.append(datetime.fromisoformat(end_date))
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s"
|
||||||
|
params.extend([per_page, offset])
|
||||||
|
|
||||||
|
entries = execute_query(query, params)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': entries
|
||||||
|
})
|
||||||
|
|
||||||
|
# Device Management
|
||||||
|
@app.route('/api/v1/admin/licenses/<license_id>/devices', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def list_license_devices(license_id):
|
||||||
|
"""List all devices for a license"""
|
||||||
|
query = """
|
||||||
|
SELECT DISTINCT hardware_id,
|
||||||
|
MIN(timestamp) as first_seen,
|
||||||
|
MAX(timestamp) as last_seen,
|
||||||
|
COUNT(*) as total_heartbeats,
|
||||||
|
array_agg(DISTINCT ip_address) as ip_addresses
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE license_id = %s
|
||||||
|
GROUP BY hardware_id
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
devices = execute_query(query, [license_id])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': devices
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/admin/licenses/<license_id>/devices/<hardware_id>', methods=['DELETE'])
|
||||||
|
@require_admin_auth
|
||||||
|
def remove_device(license_id, hardware_id):
|
||||||
|
"""Remove a device from a license"""
|
||||||
|
# Mark device as inactive in activation events
|
||||||
|
query = """
|
||||||
|
INSERT INTO activation_events
|
||||||
|
(id, license_id, event_type, hardware_id, success, created_at)
|
||||||
|
VALUES (%s, %s, 'deactivation', %s, true, %s)
|
||||||
|
"""
|
||||||
|
|
||||||
|
execute_query(
|
||||||
|
query,
|
||||||
|
[str(uuid.uuid4()), license_id, hardware_id, datetime.utcnow()]
|
||||||
|
)
|
||||||
|
|
||||||
|
log_admin_action('remove_device', 'license', license_id, {
|
||||||
|
'hardware_id': hardware_id
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Device removed successfully'
|
||||||
|
})
|
||||||
|
|
||||||
|
# System Stats
|
||||||
|
@app.route('/api/v1/admin/stats/overview', methods=['GET'])
|
||||||
|
@require_admin_auth
|
||||||
|
def get_system_overview():
|
||||||
|
"""Get system overview statistics"""
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# License stats
|
||||||
|
license_stats = execute_query("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_licenses,
|
||||||
|
COUNT(CASE WHEN is_active THEN 1 END) as active_licenses,
|
||||||
|
COUNT(CASE WHEN expires_at < NOW() THEN 1 END) as expired_licenses,
|
||||||
|
COUNT(CASE WHEN is_test THEN 1 END) as test_licenses
|
||||||
|
FROM licenses
|
||||||
|
""", fetchall=False)
|
||||||
|
stats['licenses'] = license_stats
|
||||||
|
|
||||||
|
# Customer stats
|
||||||
|
customer_stats = execute_query("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_customers,
|
||||||
|
COUNT(CASE WHEN created_at > NOW() - INTERVAL '30 days' THEN 1 END) as new_customers
|
||||||
|
FROM customers
|
||||||
|
""", fetchall=False)
|
||||||
|
stats['customers'] = customer_stats
|
||||||
|
|
||||||
|
# Activity stats
|
||||||
|
activity_stats = execute_query("""
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT license_id) as active_licenses_24h,
|
||||||
|
COUNT(DISTINCT hardware_id) as active_devices_24h,
|
||||||
|
COUNT(*) as total_validations_24h
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '24 hours'
|
||||||
|
""", fetchall=False)
|
||||||
|
stats['activity'] = activity_stats
|
||||||
|
|
||||||
|
# Anomaly stats
|
||||||
|
anomaly_stats = execute_query("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_anomalies,
|
||||||
|
COUNT(CASE WHEN resolved = false THEN 1 END) as unresolved_anomalies,
|
||||||
|
COUNT(CASE WHEN severity = 'critical' AND resolved = false THEN 1 END) as critical_anomalies
|
||||||
|
FROM anomaly_detections
|
||||||
|
WHERE detected_at > NOW() - INTERVAL '7 days'
|
||||||
|
""", fetchall=False)
|
||||||
|
stats['anomalies'] = anomaly_stats
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': stats,
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info(f"Starting Admin API Service on port {SERVICE_PORT}")
|
||||||
|
app.run(host='0.0.0.0', port=SERVICE_PORT, debug=os.environ.get('FLASK_ENV') == 'development')
|
||||||
9
v2_lizenzserver/services/admin/requirements.txt
Normale Datei
9
v2_lizenzserver/services/admin/requirements.txt
Normale Datei
@@ -0,0 +1,9 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
flask-cors==4.0.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
redis==5.0.1
|
||||||
|
PyJWT==2.8.0
|
||||||
|
bcrypt==4.1.2
|
||||||
|
requests==2.31.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
29
v2_lizenzserver/services/analytics/Dockerfile
Normale Datei
29
v2_lizenzserver/services/analytics/Dockerfile
Normale Datei
@@ -0,0 +1,29 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN useradd -m -u 1000 analytics && chown -R analytics:analytics /app
|
||||||
|
USER analytics
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 5003
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:5003/health').raise_for_status()"
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "app.py"]
|
||||||
1
v2_lizenzserver/services/analytics/__init__.py
Normale Datei
1
v2_lizenzserver/services/analytics/__init__.py
Normale Datei
@@ -0,0 +1 @@
|
|||||||
|
# Analytics Service Package
|
||||||
460
v2_lizenzserver/services/analytics/app.py
Normale Datei
460
v2_lizenzserver/services/analytics/app.py
Normale Datei
@@ -0,0 +1,460 @@
|
|||||||
|
from flask import Flask, jsonify, request
|
||||||
|
from flask_cors import CORS
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import os
|
||||||
|
import psycopg2
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from psycopg2.pool import SimpleConnectionPool
|
||||||
|
import redis
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
import jwt
|
||||||
|
from collections import defaultdict
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://postgres:postgres@postgres:5432/v2_adminpanel')
|
||||||
|
REDIS_URL = os.environ.get('REDIS_URL', 'redis://redis:6379/2')
|
||||||
|
JWT_SECRET = os.environ.get('JWT_SECRET', 'your-secret-key')
|
||||||
|
SERVICE_PORT = 5003
|
||||||
|
|
||||||
|
# Database connection pool
|
||||||
|
db_pool = SimpleConnectionPool(1, 20, DATABASE_URL)
|
||||||
|
|
||||||
|
# Redis client
|
||||||
|
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
# Cache decorator
|
||||||
|
def cache_result(ttl=300):
|
||||||
|
def decorator(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
cache_key = f"analytics:{f.__name__}:{str(args)}:{str(kwargs)}"
|
||||||
|
cached = redis_client.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
result = f(*args, **kwargs)
|
||||||
|
redis_client.setex(cache_key, ttl, json.dumps(result, default=str))
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
# JWT validation decorator
|
||||||
|
def require_auth(f):
|
||||||
|
@wraps(f)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
auth_header = request.headers.get('Authorization')
|
||||||
|
if not auth_header or not auth_header.startswith('Bearer '):
|
||||||
|
return jsonify({'error': 'Missing or invalid authorization header'}), 401
|
||||||
|
|
||||||
|
token = auth_header.split(' ')[1]
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||||
|
request.jwt_payload = payload
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'Token expired'}), 401
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token'}), 401
|
||||||
|
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
# Database query helper
|
||||||
|
def execute_query(query, params=None, fetchall=True):
|
||||||
|
conn = db_pool.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(query, params)
|
||||||
|
if fetchall:
|
||||||
|
return cur.fetchall()
|
||||||
|
return cur.fetchone()
|
||||||
|
finally:
|
||||||
|
db_pool.putconn(conn)
|
||||||
|
|
||||||
|
# Analytics calculations
|
||||||
|
class AnalyticsService:
|
||||||
|
@staticmethod
|
||||||
|
@cache_result(ttl=60)
|
||||||
|
def get_usage_statistics(customer_id=None, days=30):
|
||||||
|
"""Get usage statistics for licenses"""
|
||||||
|
base_query = """
|
||||||
|
SELECT
|
||||||
|
DATE(lh.timestamp) as date,
|
||||||
|
COUNT(DISTINCT lh.license_id) as active_licenses,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as active_devices,
|
||||||
|
COUNT(*) as total_heartbeats,
|
||||||
|
COUNT(DISTINCT lh.session_data->>'app_version') as app_versions
|
||||||
|
FROM license_heartbeats lh
|
||||||
|
JOIN licenses l ON l.id = lh.license_id
|
||||||
|
WHERE lh.timestamp >= NOW() - INTERVAL '%s days'
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = [days]
|
||||||
|
if customer_id:
|
||||||
|
base_query += " AND l.customer_id = %s"
|
||||||
|
params.append(customer_id)
|
||||||
|
|
||||||
|
base_query += " GROUP BY DATE(lh.timestamp) ORDER BY date DESC"
|
||||||
|
|
||||||
|
return execute_query(base_query, params)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@cache_result(ttl=300)
|
||||||
|
def get_performance_metrics(days=7):
|
||||||
|
"""Get system performance metrics"""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('hour', timestamp) as hour,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (timestamp - LAG(timestamp) OVER (PARTITION BY license_id ORDER BY timestamp)))) as avg_heartbeat_interval,
|
||||||
|
COUNT(*) as validation_count,
|
||||||
|
COUNT(DISTINCT license_id) as unique_licenses,
|
||||||
|
COUNT(DISTINCT hardware_id) as unique_devices
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '%s days'
|
||||||
|
GROUP BY DATE_TRUNC('hour', timestamp)
|
||||||
|
ORDER BY hour DESC
|
||||||
|
"""
|
||||||
|
return execute_query(query, [days])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@cache_result(ttl=120)
|
||||||
|
def get_anomaly_statistics(days=30):
|
||||||
|
"""Get anomaly detection statistics"""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
anomaly_type,
|
||||||
|
severity,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COUNT(CASE WHEN resolved = false THEN 1 END) as unresolved_count,
|
||||||
|
AVG(CASE WHEN resolved = true THEN EXTRACT(EPOCH FROM (resolved_at - detected_at))/3600 END) as avg_resolution_hours
|
||||||
|
FROM anomaly_detections
|
||||||
|
WHERE detected_at >= NOW() - INTERVAL '%s days'
|
||||||
|
GROUP BY anomaly_type, severity
|
||||||
|
ORDER BY count DESC
|
||||||
|
"""
|
||||||
|
return execute_query(query, [days])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@cache_result(ttl=300)
|
||||||
|
def get_license_distribution():
|
||||||
|
"""Get license distribution statistics"""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
l.license_type,
|
||||||
|
l.is_test,
|
||||||
|
COUNT(*) as total_count,
|
||||||
|
COUNT(CASE WHEN l.is_active = true THEN 1 END) as active_count,
|
||||||
|
COUNT(CASE WHEN lh.timestamp >= NOW() - INTERVAL '1 hour' THEN 1 END) as recently_active,
|
||||||
|
AVG(l.device_limit) as avg_device_limit
|
||||||
|
FROM licenses l
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT timestamp
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE license_id = l.id
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
) lh ON true
|
||||||
|
GROUP BY l.license_type, l.is_test
|
||||||
|
"""
|
||||||
|
return execute_query(query)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_revenue_impact(days=30):
|
||||||
|
"""Calculate revenue impact from license usage"""
|
||||||
|
query = """
|
||||||
|
WITH license_activity AS (
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.customer_id,
|
||||||
|
l.license_type,
|
||||||
|
l.price,
|
||||||
|
COUNT(DISTINCT DATE(lh.timestamp)) as active_days,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as devices_used
|
||||||
|
FROM licenses l
|
||||||
|
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||||
|
AND lh.timestamp >= NOW() - INTERVAL '%s days'
|
||||||
|
WHERE l.is_test = false
|
||||||
|
GROUP BY l.id, l.customer_id, l.license_type, l.price
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
license_type,
|
||||||
|
COUNT(*) as total_licenses,
|
||||||
|
SUM(price) as total_revenue,
|
||||||
|
AVG(active_days) as avg_active_days,
|
||||||
|
AVG(devices_used) as avg_devices_used,
|
||||||
|
SUM(CASE WHEN active_days > 0 THEN price ELSE 0 END) as active_revenue,
|
||||||
|
SUM(CASE WHEN active_days = 0 THEN price ELSE 0 END) as inactive_revenue
|
||||||
|
FROM license_activity
|
||||||
|
GROUP BY license_type
|
||||||
|
"""
|
||||||
|
return execute_query(query, [days])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@cache_result(ttl=600)
|
||||||
|
def get_geographic_distribution():
|
||||||
|
"""Get geographic distribution of license usage"""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
lh.ip_address::text,
|
||||||
|
COUNT(DISTINCT lh.license_id) as license_count,
|
||||||
|
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||||
|
COUNT(*) as total_validations,
|
||||||
|
MAX(lh.timestamp) as last_seen
|
||||||
|
FROM license_heartbeats lh
|
||||||
|
WHERE lh.timestamp >= NOW() - INTERVAL '24 hours'
|
||||||
|
AND lh.ip_address IS NOT NULL
|
||||||
|
GROUP BY lh.ip_address
|
||||||
|
ORDER BY total_validations DESC
|
||||||
|
LIMIT 100
|
||||||
|
"""
|
||||||
|
return execute_query(query)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_usage_patterns(license_id=None):
|
||||||
|
"""Analyze usage patterns for predictive analytics"""
|
||||||
|
base_query = """
|
||||||
|
WITH hourly_usage AS (
|
||||||
|
SELECT
|
||||||
|
EXTRACT(HOUR FROM timestamp) as hour_of_day,
|
||||||
|
EXTRACT(DOW FROM timestamp) as day_of_week,
|
||||||
|
COUNT(*) as usage_count
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '30 days'
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if license_id:
|
||||||
|
base_query += " AND license_id = %s"
|
||||||
|
params.append(license_id)
|
||||||
|
|
||||||
|
base_query += """
|
||||||
|
GROUP BY hour_of_day, day_of_week
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
hour_of_day,
|
||||||
|
day_of_week,
|
||||||
|
usage_count,
|
||||||
|
AVG(usage_count) OVER (PARTITION BY hour_of_day) as avg_hourly_usage,
|
||||||
|
AVG(usage_count) OVER (PARTITION BY day_of_week) as avg_daily_usage
|
||||||
|
FROM hourly_usage
|
||||||
|
ORDER BY day_of_week, hour_of_day
|
||||||
|
"""
|
||||||
|
|
||||||
|
return execute_query(base_query, params)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_churn_risk():
|
||||||
|
"""Calculate churn risk based on usage patterns"""
|
||||||
|
query = """
|
||||||
|
WITH recent_activity AS (
|
||||||
|
SELECT
|
||||||
|
l.id,
|
||||||
|
l.customer_id,
|
||||||
|
l.expires_at,
|
||||||
|
MAX(lh.timestamp) as last_activity,
|
||||||
|
COUNT(DISTINCT DATE(lh.timestamp)) as active_days_30d,
|
||||||
|
COUNT(DISTINCT DATE(lh.timestamp)) FILTER (WHERE lh.timestamp >= NOW() - INTERVAL '7 days') as active_days_7d
|
||||||
|
FROM licenses l
|
||||||
|
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||||
|
AND lh.timestamp >= NOW() - INTERVAL '30 days'
|
||||||
|
WHERE l.is_test = false
|
||||||
|
GROUP BY l.id, l.customer_id, l.expires_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
customer_id,
|
||||||
|
COUNT(*) as total_licenses,
|
||||||
|
AVG(EXTRACT(EPOCH FROM (NOW() - last_activity))/86400) as avg_days_since_activity,
|
||||||
|
AVG(active_days_30d) as avg_active_days_30d,
|
||||||
|
AVG(active_days_7d) as avg_active_days_7d,
|
||||||
|
MIN(expires_at) as next_expiry,
|
||||||
|
CASE
|
||||||
|
WHEN AVG(active_days_7d) = 0 AND AVG(active_days_30d) > 0 THEN 'high'
|
||||||
|
WHEN AVG(active_days_30d) < 5 THEN 'medium'
|
||||||
|
ELSE 'low'
|
||||||
|
END as churn_risk
|
||||||
|
FROM recent_activity
|
||||||
|
GROUP BY customer_id
|
||||||
|
HAVING COUNT(*) > 0
|
||||||
|
ORDER BY churn_risk DESC, avg_days_since_activity DESC
|
||||||
|
"""
|
||||||
|
return execute_query(query)
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
@app.route('/health', methods=['GET'])
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'service': 'analytics-service',
|
||||||
|
'timestamp': datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/usage', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_usage_stats():
|
||||||
|
"""Get usage statistics"""
|
||||||
|
customer_id = request.args.get('customer_id')
|
||||||
|
days = int(request.args.get('days', 30))
|
||||||
|
|
||||||
|
stats = AnalyticsService.get_usage_statistics(customer_id, days)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': stats,
|
||||||
|
'period_days': days,
|
||||||
|
'customer_id': customer_id
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/performance', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_performance():
|
||||||
|
"""Get performance metrics"""
|
||||||
|
days = int(request.args.get('days', 7))
|
||||||
|
metrics = AnalyticsService.get_performance_metrics(days)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': metrics,
|
||||||
|
'period_days': days
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/anomalies', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_anomalies():
|
||||||
|
"""Get anomaly statistics"""
|
||||||
|
days = int(request.args.get('days', 30))
|
||||||
|
anomalies = AnalyticsService.get_anomaly_statistics(days)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': anomalies,
|
||||||
|
'period_days': days
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/distribution', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_distribution():
|
||||||
|
"""Get license distribution"""
|
||||||
|
distribution = AnalyticsService.get_license_distribution()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': distribution
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/revenue', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_revenue():
|
||||||
|
"""Get revenue impact analysis"""
|
||||||
|
days = int(request.args.get('days', 30))
|
||||||
|
revenue = AnalyticsService.get_revenue_impact(days)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': revenue,
|
||||||
|
'period_days': days
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/geographic', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_geographic():
|
||||||
|
"""Get geographic distribution"""
|
||||||
|
geo_data = AnalyticsService.get_geographic_distribution()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': geo_data
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/patterns', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_patterns():
|
||||||
|
"""Get usage patterns"""
|
||||||
|
license_id = request.args.get('license_id')
|
||||||
|
patterns = AnalyticsService.get_usage_patterns(license_id)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': patterns,
|
||||||
|
'license_id': license_id
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/churn-risk', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_churn_risk():
|
||||||
|
"""Get churn risk analysis"""
|
||||||
|
churn_data = AnalyticsService.calculate_churn_risk()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'data': churn_data
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route('/api/v1/analytics/summary/<customer_id>', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_customer_summary(customer_id):
|
||||||
|
"""Get comprehensive analytics summary for a customer"""
|
||||||
|
usage = AnalyticsService.get_usage_statistics(customer_id, 30)
|
||||||
|
|
||||||
|
# Calculate summary metrics
|
||||||
|
total_heartbeats = sum(day['total_heartbeats'] for day in usage)
|
||||||
|
active_days = len([day for day in usage if day['active_licenses'] > 0])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'customer_id': customer_id,
|
||||||
|
'summary': {
|
||||||
|
'total_heartbeats_30d': total_heartbeats,
|
||||||
|
'active_days_30d': active_days,
|
||||||
|
'average_daily_devices': np.mean([day['active_devices'] for day in usage]) if usage else 0,
|
||||||
|
'usage_trend': usage[:7] if len(usage) >= 7 else usage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Real-time analytics endpoint (for websocket in future)
|
||||||
|
@app.route('/api/v1/analytics/realtime', methods=['GET'])
|
||||||
|
@require_auth
|
||||||
|
def get_realtime_stats():
|
||||||
|
"""Get real-time statistics for dashboard"""
|
||||||
|
# Get stats from last 5 minutes
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT license_id) as active_licenses,
|
||||||
|
COUNT(DISTINCT hardware_id) as active_devices,
|
||||||
|
COUNT(*) as validations_5min,
|
||||||
|
COUNT(*) / 5.0 as validations_per_minute
|
||||||
|
FROM license_heartbeats
|
||||||
|
WHERE timestamp >= NOW() - INTERVAL '5 minutes'
|
||||||
|
"""
|
||||||
|
realtime = execute_query(query, fetchall=False)
|
||||||
|
|
||||||
|
# Get current anomalies
|
||||||
|
anomaly_query = """
|
||||||
|
SELECT COUNT(*) as unresolved_anomalies
|
||||||
|
FROM anomaly_detections
|
||||||
|
WHERE resolved = false
|
||||||
|
"""
|
||||||
|
anomalies = execute_query(anomaly_query, fetchall=False)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
'data': {
|
||||||
|
'active_licenses': realtime['active_licenses'] or 0,
|
||||||
|
'active_devices': realtime['active_devices'] or 0,
|
||||||
|
'validations_5min': realtime['validations_5min'] or 0,
|
||||||
|
'validations_per_minute': float(realtime['validations_per_minute'] or 0),
|
||||||
|
'unresolved_anomalies': anomalies['unresolved_anomalies'] or 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info(f"Starting Analytics Service on port {SERVICE_PORT}")
|
||||||
|
app.run(host='0.0.0.0', port=SERVICE_PORT, debug=os.environ.get('FLASK_ENV') == 'development')
|
||||||
9
v2_lizenzserver/services/analytics/requirements.txt
Normale Datei
9
v2_lizenzserver/services/analytics/requirements.txt
Normale Datei
@@ -0,0 +1,9 @@
|
|||||||
|
Flask==3.0.0
|
||||||
|
flask-cors==4.0.0
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
redis==5.0.1
|
||||||
|
PyJWT==2.8.0
|
||||||
|
numpy==1.26.2
|
||||||
|
requests==2.31.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
gunicorn==21.2.0
|
||||||
@@ -59,6 +59,26 @@ http {
|
|||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Analytics Service API (internal only)
|
||||||
|
location /api/v1/analytics/ {
|
||||||
|
proxy_pass http://analytics-service:5003/api/v1/analytics/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin API Service (internal only)
|
||||||
|
location /api/v1/admin/ {
|
||||||
|
proxy_pass http://admin-api-service:5004/api/v1/admin/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# API Server (für später)
|
# API Server (für später)
|
||||||
|
|||||||
In neuem Issue referenzieren
Einen Benutzer sperren