Lizenzserver - Integration Admin Panel
Dieser Commit ist enthalten in:
@@ -351,3 +351,217 @@ BEGIN
|
||||
UPDATE sessions SET active = is_active;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ===================== LICENSE SERVER TABLES =====================
|
||||
-- Following best practices: snake_case for DB fields, clear naming conventions
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- License tokens for offline validation
|
||||
CREATE TABLE IF NOT EXISTS license_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
token VARCHAR(512) NOT NULL UNIQUE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
valid_until TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_validated TIMESTAMP,
|
||||
validation_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_token ON license_tokens(token);
|
||||
CREATE INDEX idx_hardware ON license_tokens(hardware_id);
|
||||
CREATE INDEX idx_valid_until ON license_tokens(valid_until);
|
||||
|
||||
-- Heartbeat tracking with partitioning support
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats (
|
||||
id BIGSERIAL,
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent VARCHAR(500),
|
||||
app_version VARCHAR(50),
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
session_data JSONB,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
) PARTITION BY RANGE (timestamp);
|
||||
|
||||
-- Create partitions for the current and next month
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_01 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS license_heartbeats_2025_02 PARTITION OF license_heartbeats
|
||||
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
|
||||
|
||||
CREATE INDEX idx_heartbeat_license_time ON license_heartbeats(license_id, timestamp DESC);
|
||||
CREATE INDEX idx_heartbeat_hardware_time ON license_heartbeats(hardware_id, timestamp DESC);
|
||||
|
||||
-- Activation events tracking
|
||||
CREATE TABLE IF NOT EXISTS activation_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
event_type VARCHAR(50) NOT NULL CHECK (event_type IN ('activation', 'deactivation', 'reactivation', 'transfer')),
|
||||
hardware_id VARCHAR(255),
|
||||
previous_hardware_id VARCHAR(255),
|
||||
ip_address INET,
|
||||
user_agent VARCHAR(500),
|
||||
success BOOLEAN DEFAULT true,
|
||||
error_message TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_license_events ON activation_events(license_id, created_at DESC);
|
||||
CREATE INDEX idx_event_type ON activation_events(event_type, created_at DESC);
|
||||
|
||||
-- API rate limiting
|
||||
CREATE TABLE IF NOT EXISTS api_rate_limits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
api_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
requests_per_minute INTEGER DEFAULT 60,
|
||||
requests_per_hour INTEGER DEFAULT 1000,
|
||||
requests_per_day INTEGER DEFAULT 10000,
|
||||
burst_size INTEGER DEFAULT 100,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Anomaly detection
|
||||
CREATE TABLE IF NOT EXISTS anomaly_detections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id),
|
||||
anomaly_type VARCHAR(100) NOT NULL CHECK (anomaly_type IN ('multiple_ips', 'rapid_hardware_change', 'suspicious_pattern', 'concurrent_use', 'geo_anomaly')),
|
||||
severity VARCHAR(20) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||
details JSONB NOT NULL,
|
||||
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
resolved BOOLEAN DEFAULT false,
|
||||
resolved_at TIMESTAMP,
|
||||
resolved_by VARCHAR(255),
|
||||
action_taken TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_unresolved ON anomaly_detections(resolved, severity, detected_at DESC);
|
||||
CREATE INDEX idx_license_anomalies ON anomaly_detections(license_id, detected_at DESC);
|
||||
|
||||
-- API clients for authentication
|
||||
CREATE TABLE IF NOT EXISTS api_clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_name VARCHAR(255) NOT NULL,
|
||||
api_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
secret_key VARCHAR(255) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
allowed_endpoints TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Feature flags for gradual rollout
|
||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||
id SERIAL PRIMARY KEY,
|
||||
feature_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
is_enabled BOOLEAN DEFAULT false,
|
||||
rollout_percentage INTEGER DEFAULT 0 CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100),
|
||||
whitelist_license_ids INTEGER[],
|
||||
blacklist_license_ids INTEGER[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Insert default feature flags
|
||||
INSERT INTO feature_flags (feature_name, is_enabled, rollout_percentage) VALUES
|
||||
('anomaly_detection', true, 100),
|
||||
('offline_tokens', true, 100),
|
||||
('advanced_analytics', false, 0),
|
||||
('geo_restriction', false, 0)
|
||||
ON CONFLICT (feature_name) DO NOTHING;
|
||||
|
||||
-- Session management for concurrent use tracking
|
||||
CREATE TABLE IF NOT EXISTS active_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
|
||||
hardware_id VARCHAR(255) NOT NULL,
|
||||
session_token VARCHAR(512) NOT NULL UNIQUE,
|
||||
ip_address INET,
|
||||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_session_license ON active_sessions(license_id);
|
||||
CREATE INDEX idx_session_expires ON active_sessions(expires_at);
|
||||
|
||||
-- Update trigger for updated_at columns
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_api_rate_limits_updated_at BEFORE UPDATE ON api_rate_limits
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_api_clients_updated_at BEFORE UPDATE ON api_clients
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_feature_flags_updated_at BEFORE UPDATE ON feature_flags
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- Function to automatically create monthly partitions for heartbeats
|
||||
CREATE OR REPLACE FUNCTION create_monthly_partition()
|
||||
RETURNS void AS $$
|
||||
DECLARE
|
||||
start_date date;
|
||||
end_date date;
|
||||
partition_name text;
|
||||
BEGIN
|
||||
start_date := date_trunc('month', CURRENT_DATE + interval '1 month');
|
||||
end_date := start_date + interval '1 month';
|
||||
partition_name := 'license_heartbeats_' || to_char(start_date, 'YYYY_MM');
|
||||
|
||||
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF license_heartbeats FOR VALUES FROM (%L) TO (%L)',
|
||||
partition_name, start_date, end_date);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Migration: Add max_devices column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'max_devices') THEN
|
||||
ALTER TABLE licenses ADD COLUMN max_devices INTEGER DEFAULT 3 CHECK (max_devices >= 1);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add expires_at column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'expires_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN expires_at TIMESTAMP;
|
||||
-- Set expires_at based on valid_until for existing licenses
|
||||
UPDATE licenses SET expires_at = valid_until::timestamp WHERE expires_at IS NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add features column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'features') THEN
|
||||
ALTER TABLE licenses ADD COLUMN features TEXT[] DEFAULT '{}';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Migration: Add updated_at column to licenses if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'licenses' AND column_name = 'updated_at') THEN
|
||||
ALTER TABLE licenses ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
CREATE TRIGGER update_licenses_updated_at BEFORE UPDATE ON licenses
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
@@ -553,4 +553,422 @@ def clear_attempts():
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin.blocked_ips'))
|
||||
return redirect(url_for('admin.blocked_ips'))
|
||||
|
||||
|
||||
# ===================== LICENSE SERVER MONITORING ROUTES =====================
|
||||
|
||||
@admin_bp.route("/lizenzserver/monitor")
|
||||
@login_required
|
||||
def license_monitor():
|
||||
"""License server live monitoring dashboard"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get current statistics
|
||||
# Active validations in last 5 minutes
|
||||
cur.execute("""
|
||||
SELECT COUNT(DISTINCT license_id) as active_licenses,
|
||||
COUNT(*) as total_validations,
|
||||
COUNT(DISTINCT hardware_id) as unique_devices,
|
||||
COUNT(DISTINCT ip_address) as unique_ips
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '5 minutes'
|
||||
""")
|
||||
live_stats = cur.fetchone()
|
||||
|
||||
# Get validation rate (per minute)
|
||||
cur.execute("""
|
||||
SELECT DATE_TRUNC('minute', timestamp) as minute,
|
||||
COUNT(*) as validations
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '10 minutes'
|
||||
GROUP BY minute
|
||||
ORDER BY minute DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
validation_rates = cur.fetchall()
|
||||
|
||||
# Get top active licenses
|
||||
cur.execute("""
|
||||
SELECT l.id, l.license_key, c.name as customer_name,
|
||||
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||
COUNT(*) as validation_count,
|
||||
MAX(lh.timestamp) as last_seen
|
||||
FROM licenses l
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||
WHERE lh.timestamp > NOW() - INTERVAL '15 minutes'
|
||||
GROUP BY l.id, l.license_key, c.name
|
||||
ORDER BY validation_count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
top_licenses = cur.fetchall()
|
||||
|
||||
# Get recent anomalies
|
||||
cur.execute("""
|
||||
SELECT ad.*, l.license_key, c.name as customer_name
|
||||
FROM anomaly_detections ad
|
||||
LEFT JOIN licenses l ON ad.license_id = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE ad.resolved = false
|
||||
ORDER BY ad.detected_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
recent_anomalies = cur.fetchall()
|
||||
|
||||
# Get geographic distribution
|
||||
cur.execute("""
|
||||
SELECT ip_address, COUNT(*) as count
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '1 hour'
|
||||
AND ip_address IS NOT NULL
|
||||
GROUP BY ip_address
|
||||
ORDER BY count DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
geo_distribution = cur.fetchall()
|
||||
|
||||
return render_template('license_monitor.html',
|
||||
live_stats=live_stats,
|
||||
validation_rates=validation_rates,
|
||||
top_licenses=top_licenses,
|
||||
recent_anomalies=recent_anomalies,
|
||||
geo_distribution=geo_distribution
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Monitoring-Daten: {str(e)}', 'error')
|
||||
return render_template('license_monitor.html')
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/analytics")
|
||||
@login_required
|
||||
def license_analytics():
|
||||
"""License usage analytics"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Time range from query params
|
||||
days = int(request.args.get('days', 30))
|
||||
|
||||
# Usage trends over time
|
||||
cur.execute("""
|
||||
SELECT DATE(timestamp) as date,
|
||||
COUNT(DISTINCT license_id) as unique_licenses,
|
||||
COUNT(DISTINCT hardware_id) as unique_devices,
|
||||
COUNT(*) as total_validations
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '%s days'
|
||||
GROUP BY date
|
||||
ORDER BY date
|
||||
""", (days,))
|
||||
usage_trends = cur.fetchall()
|
||||
|
||||
# License performance metrics
|
||||
cur.execute("""
|
||||
SELECT l.id, l.license_key, c.name as customer_name,
|
||||
COUNT(DISTINCT lh.hardware_id) as device_count,
|
||||
l.max_devices,
|
||||
COUNT(*) as total_validations,
|
||||
COUNT(DISTINCT DATE(lh.timestamp)) as active_days,
|
||||
MIN(lh.timestamp) as first_seen,
|
||||
MAX(lh.timestamp) as last_seen
|
||||
FROM licenses l
|
||||
JOIN customers c ON l.customer_id = c.id
|
||||
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||
WHERE lh.timestamp > NOW() - INTERVAL '%s days'
|
||||
GROUP BY l.id, l.license_key, c.name, l.max_devices
|
||||
ORDER BY total_validations DESC
|
||||
""", (days,))
|
||||
license_metrics = cur.fetchall()
|
||||
|
||||
# Device distribution
|
||||
cur.execute("""
|
||||
SELECT l.max_devices as limit,
|
||||
COUNT(*) as license_count,
|
||||
AVG(device_count) as avg_usage
|
||||
FROM licenses l
|
||||
LEFT JOIN (
|
||||
SELECT license_id, COUNT(DISTINCT hardware_id) as device_count
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '30 days'
|
||||
GROUP BY license_id
|
||||
) usage ON l.id = usage.license_id
|
||||
WHERE l.is_active = true
|
||||
GROUP BY l.max_devices
|
||||
ORDER BY l.max_devices
|
||||
""")
|
||||
device_distribution = cur.fetchall()
|
||||
|
||||
# Revenue analysis
|
||||
cur.execute("""
|
||||
SELECT l.license_type,
|
||||
COUNT(DISTINCT l.id) as license_count,
|
||||
COUNT(DISTINCT CASE WHEN lh.license_id IS NOT NULL THEN l.id END) as active_licenses,
|
||||
COUNT(DISTINCT lh.hardware_id) as total_devices
|
||||
FROM licenses l
|
||||
LEFT JOIN license_heartbeats lh ON l.id = lh.license_id
|
||||
AND lh.timestamp > NOW() - INTERVAL '%s days'
|
||||
GROUP BY l.license_type
|
||||
""", (days,))
|
||||
revenue_analysis = cur.fetchall()
|
||||
|
||||
return render_template('license_analytics.html',
|
||||
days=days,
|
||||
usage_trends=usage_trends,
|
||||
license_metrics=license_metrics,
|
||||
device_distribution=device_distribution,
|
||||
revenue_analysis=revenue_analysis
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Analytics-Daten: {str(e)}', 'error')
|
||||
return render_template('license_analytics.html', days=30)
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/anomalies")
|
||||
@login_required
|
||||
def license_anomalies():
|
||||
"""Anomaly detection and management"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Filter parameters
|
||||
severity = request.args.get('severity', 'all')
|
||||
resolved = request.args.get('resolved', 'false')
|
||||
|
||||
# Build query
|
||||
query = """
|
||||
SELECT ad.*, l.license_key, c.name as customer_name, c.email
|
||||
FROM anomaly_detections ad
|
||||
LEFT JOIN licenses l ON ad.license_id = l.id
|
||||
LEFT JOIN customers c ON l.customer_id = c.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if severity != 'all':
|
||||
query += " AND ad.severity = %s"
|
||||
params.append(severity)
|
||||
|
||||
if resolved == 'false':
|
||||
query += " AND ad.resolved = false"
|
||||
elif resolved == 'true':
|
||||
query += " AND ad.resolved = true"
|
||||
|
||||
query += " ORDER BY ad.detected_at DESC LIMIT 100"
|
||||
|
||||
cur.execute(query, params)
|
||||
anomalies = cur.fetchall()
|
||||
|
||||
# Get anomaly statistics
|
||||
cur.execute("""
|
||||
SELECT anomaly_type, severity, COUNT(*) as count
|
||||
FROM anomaly_detections
|
||||
WHERE resolved = false
|
||||
GROUP BY anomaly_type, severity
|
||||
ORDER BY count DESC
|
||||
""")
|
||||
anomaly_stats = cur.fetchall()
|
||||
|
||||
return render_template('license_anomalies.html',
|
||||
anomalies=anomalies,
|
||||
anomaly_stats=anomaly_stats,
|
||||
severity=severity,
|
||||
resolved=resolved
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Anomalie-Daten: {str(e)}', 'error')
|
||||
return render_template('license_anomalies.html')
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/anomaly/<anomaly_id>/resolve", methods=["POST"])
|
||||
@login_required
|
||||
def resolve_anomaly(anomaly_id):
|
||||
"""Resolve an anomaly"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
action_taken = request.form.get('action_taken', '')
|
||||
|
||||
cur.execute("""
|
||||
UPDATE anomaly_detections
|
||||
SET resolved = true,
|
||||
resolved_at = NOW(),
|
||||
resolved_by = %s,
|
||||
action_taken = %s
|
||||
WHERE id = %s
|
||||
""", (session.get('username'), action_taken, str(anomaly_id)))
|
||||
|
||||
conn.commit()
|
||||
|
||||
flash('Anomalie wurde als behoben markiert', 'success')
|
||||
log_audit('RESOLVE_ANOMALY', 'license_server', entity_id=str(anomaly_id),
|
||||
additional_info=f"Action: {action_taken}")
|
||||
|
||||
except Exception as e:
|
||||
if 'conn' in locals():
|
||||
conn.rollback()
|
||||
flash(f'Fehler beim Beheben der Anomalie: {str(e)}', 'error')
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin.license_anomalies'))
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/config")
|
||||
@login_required
|
||||
def license_config():
|
||||
"""License server configuration"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get feature flags
|
||||
cur.execute("""
|
||||
SELECT * FROM feature_flags
|
||||
ORDER BY feature_name
|
||||
""")
|
||||
feature_flags = cur.fetchall()
|
||||
|
||||
# Get API clients
|
||||
cur.execute("""
|
||||
SELECT id, client_name, api_key, is_active, created_at
|
||||
FROM api_clients
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
api_clients = cur.fetchall()
|
||||
|
||||
# Get rate limits
|
||||
cur.execute("""
|
||||
SELECT * FROM api_rate_limits
|
||||
ORDER BY api_key
|
||||
""")
|
||||
rate_limits = cur.fetchall()
|
||||
|
||||
return render_template('license_config.html',
|
||||
feature_flags=feature_flags,
|
||||
api_clients=api_clients,
|
||||
rate_limits=rate_limits
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
flash(f'Fehler beim Laden der Konfiguration: {str(e)}', 'error')
|
||||
return render_template('license_config.html')
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_bp.route("/lizenzserver/config/feature-flag/<int:flag_id>", methods=["POST"])
|
||||
@login_required
|
||||
def update_feature_flag(flag_id):
|
||||
"""Update feature flag settings"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
is_enabled = request.form.get('is_enabled') == 'on'
|
||||
rollout_percentage = int(request.form.get('rollout_percentage', 0))
|
||||
|
||||
cur.execute("""
|
||||
UPDATE feature_flags
|
||||
SET is_enabled = %s,
|
||||
rollout_percentage = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""", (is_enabled, rollout_percentage, flag_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
flash('Feature Flag wurde aktualisiert', 'success')
|
||||
log_audit('UPDATE_FEATURE_FLAG', 'license_server', entity_id=flag_id)
|
||||
|
||||
except Exception as e:
|
||||
if 'conn' in locals():
|
||||
conn.rollback()
|
||||
flash(f'Fehler beim Aktualisieren: {str(e)}', 'error')
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
return redirect(url_for('admin.license_config'))
|
||||
|
||||
|
||||
@admin_bp.route("/api/admin/lizenzserver/live-stats")
|
||||
@login_required
|
||||
def license_live_stats():
|
||||
"""API endpoint for live statistics (for AJAX updates)"""
|
||||
try:
|
||||
conn = get_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get real-time stats
|
||||
cur.execute("""
|
||||
SELECT COUNT(DISTINCT license_id) as active_licenses,
|
||||
COUNT(*) as validations_per_minute,
|
||||
COUNT(DISTINCT hardware_id) as active_devices
|
||||
FROM license_heartbeats
|
||||
WHERE timestamp > NOW() - INTERVAL '1 minute'
|
||||
""")
|
||||
stats = cur.fetchone()
|
||||
|
||||
# Get latest validations
|
||||
cur.execute("""
|
||||
SELECT l.license_key, lh.hardware_id, lh.ip_address, lh.timestamp
|
||||
FROM license_heartbeats lh
|
||||
JOIN licenses l ON lh.license_id = l.id
|
||||
ORDER BY lh.timestamp DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
latest_validations = cur.fetchall()
|
||||
|
||||
return jsonify({
|
||||
'active_licenses': stats[0] or 0,
|
||||
'validations_per_minute': stats[1] or 0,
|
||||
'active_devices': stats[2] or 0,
|
||||
'latest_validations': [
|
||||
{
|
||||
'license_key': v[0][:8] + '...',
|
||||
'hardware_id': v[1][:8] + '...',
|
||||
'ip_address': v[2] or 'Unknown',
|
||||
'timestamp': v[3].strftime('%H:%M:%S')
|
||||
} for v in latest_validations
|
||||
]
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
finally:
|
||||
if 'cur' in locals():
|
||||
cur.close()
|
||||
if 'conn' in locals():
|
||||
conn.close()
|
||||
|
||||
@@ -338,6 +338,11 @@ def api_customer_licenses(customer_id):
|
||||
WHEN l.is_active = false THEN 'inaktiv'
|
||||
ELSE 'aktiv'
|
||||
END as status,
|
||||
-- License Server Status
|
||||
(SELECT COUNT(*) FROM license_heartbeats lh WHERE lh.license_id = l.id AND lh.timestamp > NOW() - INTERVAL '5 minutes') as recent_heartbeats,
|
||||
(SELECT MAX(timestamp) FROM license_heartbeats lh WHERE lh.license_id = l.id) as last_heartbeat,
|
||||
(SELECT COUNT(DISTINCT hardware_id) FROM license_heartbeats lh WHERE lh.license_id = l.id AND lh.timestamp > NOW() - INTERVAL '15 minutes') as active_server_devices,
|
||||
(SELECT COUNT(*) FROM anomaly_detections ad WHERE ad.license_id = l.id AND ad.resolved = false) as unresolved_anomalies,
|
||||
l.domain_count,
|
||||
l.ipv4_count,
|
||||
l.phone_count,
|
||||
@@ -408,14 +413,19 @@ def api_customer_licenses(customer_id):
|
||||
'active_sessions': row[9],
|
||||
'registered_devices': row[10],
|
||||
'status': row[11],
|
||||
'domain_count': row[12],
|
||||
'ipv4_count': row[13],
|
||||
'phone_count': row[14],
|
||||
'active_devices': row[15],
|
||||
'actual_domain_count': row[16],
|
||||
'actual_ipv4_count': row[17],
|
||||
'actual_phone_count': row[18],
|
||||
'resources': resources
|
||||
'domain_count': row[16],
|
||||
'ipv4_count': row[17],
|
||||
'phone_count': row[18],
|
||||
'active_devices': row[19],
|
||||
'actual_domain_count': row[20],
|
||||
'actual_ipv4_count': row[21],
|
||||
'actual_phone_count': row[22],
|
||||
'resources': resources,
|
||||
# License Server Data
|
||||
'recent_heartbeats': row[12],
|
||||
'last_heartbeat': row[13].strftime('%Y-%m-%d %H:%M:%S') if row[13] else None,
|
||||
'active_server_devices': row[14],
|
||||
'unresolved_anomalies': row[15]
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
|
||||
@@ -433,6 +433,38 @@
|
||||
<span>Sicherheit</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item {% if request.endpoint in ['admin.license_monitor', 'admin.license_analytics', 'admin.license_anomalies', 'admin.license_config'] %}has-active-child{% endif %}">
|
||||
<a class="nav-link has-submenu" href="#">
|
||||
<i class="bi bi-graph-up"></i>
|
||||
<span>Lizenzserver</span>
|
||||
</a>
|
||||
<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">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.license_analytics' %}active{% endif %}" href="{{ url_for('admin.license_analytics') }}">
|
||||
<i class="bi bi-bar-chart"></i>
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.license_anomalies' %}active{% endif %}" href="{{ url_for('admin.license_anomalies') }}">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<span>Anomalien</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'admin.license_config' %}active{% endif %}" href="{{ url_for('admin.license_config') }}">
|
||||
<i class="bi bi-gear"></i>
|
||||
<span>Konfiguration</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ function updateLicenseView(customerId, licenses) {
|
||||
<th>Gültig von</th>
|
||||
<th>Gültig bis</th>
|
||||
<th>Status</th>
|
||||
<th>Server Status</th>
|
||||
<th>Ressourcen</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
@@ -378,6 +379,26 @@ function updateLicenseView(customerId, licenses) {
|
||||
|
||||
const typeClass = license.license_type === 'full' ? 'bg-primary' : 'bg-secondary';
|
||||
|
||||
// License Server Status
|
||||
let serverStatusHtml = '';
|
||||
if (license.recent_heartbeats > 0) {
|
||||
serverStatusHtml = `<span class="badge bg-success" title="Aktiv - ${license.active_server_devices} Geräte">💚 Online</span>`;
|
||||
if (license.unresolved_anomalies > 0) {
|
||||
serverStatusHtml += `<br><span class="badge bg-danger" title="${license.unresolved_anomalies} ungelöste Anomalien">⚠️ ${license.unresolved_anomalies}</span>`;
|
||||
}
|
||||
} else if (license.last_heartbeat) {
|
||||
const lastSeen = new Date(license.last_heartbeat);
|
||||
const minutesAgo = Math.floor((new Date() - lastSeen) / 60000);
|
||||
if (minutesAgo < 60) {
|
||||
serverStatusHtml = `<span class="badge bg-warning" title="Zuletzt vor ${minutesAgo} Min">⏱️ ${minutesAgo} Min</span>`;
|
||||
} else {
|
||||
const hoursAgo = Math.floor(minutesAgo / 60);
|
||||
serverStatusHtml = `<span class="badge bg-secondary" title="Zuletzt vor ${hoursAgo}h">💤 Offline</span>`;
|
||||
}
|
||||
} else {
|
||||
serverStatusHtml = `<span class="badge bg-secondary">-</span>`;
|
||||
}
|
||||
|
||||
// Erstelle Ressourcen-HTML mit Details
|
||||
let resourcesHtml = '';
|
||||
const actualDomainCount = license.actual_domain_count || 0;
|
||||
@@ -425,6 +446,7 @@ function updateLicenseView(customerId, licenses) {
|
||||
<td>${license.valid_from || '-'}</td>
|
||||
<td>${license.valid_until || '-'}</td>
|
||||
<td><span class="badge ${statusClass}">${license.status}</span></td>
|
||||
<td>${serverStatusHtml}</td>
|
||||
<td class="resources-cell">
|
||||
${resourcesHtml || '<span class="text-muted">-</span>'}
|
||||
</td>
|
||||
|
||||
319
v2_adminpanel/templates/license_monitor.html
Normale Datei
319
v2_adminpanel/templates/license_monitor.html
Normale Datei
@@ -0,0 +1,319 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Lizenzserver Monitor{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--status-active);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--status-active);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.validation-timeline {
|
||||
height: 300px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.anomaly-alert {
|
||||
padding: 1rem;
|
||||
border-left: 4px solid var(--status-danger);
|
||||
background: #fff5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.device-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1><i class="bi bi-speedometer2"></i> Lizenzserver Live Monitor</h1>
|
||||
<div>
|
||||
<span class="live-indicator"></span>
|
||||
<span class="text-muted">Live-Daten</span>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-3" onclick="toggleAutoRefresh()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Auto-Refresh: <span id="refresh-status">AN</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="active-licenses">
|
||||
{{ live_stats[0] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Lizenzen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="total-validations">
|
||||
{{ live_stats[1] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Validierungen (5 Min)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-devices">
|
||||
{{ live_stats[2] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Aktive Geräte</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number" id="unique-ips">
|
||||
{{ live_stats[3] if live_stats else 0 }}
|
||||
</div>
|
||||
<div class="stat-label">Unique IPs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Validation Timeline -->
|
||||
<div class="col-md-8 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Validierungen pro Minute</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="validationChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Anomalies -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Aktuelle Anomalien</h5>
|
||||
<a href="{{ url_for('admin.license_anomalies') }}" class="btn btn-sm btn-outline-primary">
|
||||
Alle anzeigen
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
||||
{% if recent_anomalies %}
|
||||
{% for anomaly in recent_anomalies %}
|
||||
<div class="anomaly-alert">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge badge-{{ 'danger' if anomaly['severity'] == 'critical' else anomaly['severity'] }}">
|
||||
{{ anomaly['severity'].upper() }}
|
||||
</span>
|
||||
<small class="text-muted">{{ anomaly['detected_at'].strftime('%H:%M') }}</small>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<strong>{{ anomaly['anomaly_type'].replace('_', ' ').title() }}</strong><br>
|
||||
<small>Lizenz: {{ anomaly['license_key'][:8] }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted text-center">Keine aktiven Anomalien</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Active Licenses -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Top Aktive Lizenzen (letzte 15 Min)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lizenzschlüssel</th>
|
||||
<th>Kunde</th>
|
||||
<th>Geräte</th>
|
||||
<th>Validierungen</th>
|
||||
<th>Zuletzt gesehen</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="top-licenses-tbody">
|
||||
{% for license in top_licenses %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ license['license_key'][:12] }}...</code>
|
||||
</td>
|
||||
<td>{{ license['customer_name'] }}</td>
|
||||
<td>
|
||||
<span class="device-badge">
|
||||
<i class="bi bi-laptop"></i> {{ license['device_count'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ license['validation_count'] }}</td>
|
||||
<td>{{ license['last_seen'].strftime('%H:%M:%S') }}</td>
|
||||
<td>
|
||||
<span class="badge bg-success">Aktiv</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Validations Stream -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Letzte Validierungen (Live-Stream)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="validation-stream" style="max-height: 300px; overflow-y: auto;">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let autoRefresh = true;
|
||||
let refreshInterval;
|
||||
let validationChart;
|
||||
|
||||
// Initialize validation chart
|
||||
const ctx = document.getElementById('validationChart').getContext('2d');
|
||||
validationChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Validierungen',
|
||||
data: [],
|
||||
borderColor: 'rgb(40, 167, 69)',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update chart with validation rates
|
||||
{% if validation_rates %}
|
||||
const rates = {{ validation_rates|tojson }};
|
||||
validationChart.data.labels = rates.map(r => new Date(r[0]).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})).reverse();
|
||||
validationChart.data.datasets[0].data = rates.map(r => r[1]).reverse();
|
||||
validationChart.update();
|
||||
{% endif %}
|
||||
|
||||
// Fetch live statistics
|
||||
function fetchLiveStats() {
|
||||
fetch('{{ url_for("admin.license_live_stats") }}')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update statistics
|
||||
document.getElementById('active-licenses').textContent = data.active_licenses;
|
||||
document.getElementById('total-validations').textContent = data.validations_per_minute;
|
||||
document.getElementById('unique-devices').textContent = data.active_devices;
|
||||
|
||||
// Update validation stream
|
||||
const stream = document.getElementById('validation-stream');
|
||||
const newEntries = data.latest_validations.map(v =>
|
||||
`<div class="d-flex justify-content-between border-bottom py-2">
|
||||
<span>
|
||||
<code>${v.license_key}</code> |
|
||||
<span class="text-muted">${v.hardware_id}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-secondary">${v.ip_address}</span>
|
||||
<span class="text-muted ms-2">${v.timestamp}</span>
|
||||
</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (newEntries) {
|
||||
stream.innerHTML = newEntries + stream.innerHTML;
|
||||
// Keep only last 20 entries
|
||||
const entries = stream.querySelectorAll('div');
|
||||
if (entries.length > 20) {
|
||||
for (let i = 20; i < entries.length; i++) {
|
||||
entries[i].remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error fetching live stats:', error));
|
||||
}
|
||||
|
||||
// Toggle auto-refresh
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh = !autoRefresh;
|
||||
document.getElementById('refresh-status').textContent = autoRefresh ? 'AN' : 'AUS';
|
||||
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
} else {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
// Start auto-refresh
|
||||
if (autoRefresh) {
|
||||
refreshInterval = setInterval(fetchLiveStats, 5000);
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchLiveStats();
|
||||
</script>
|
||||
{% endblock %}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren