Add comprehensive database integration, authentication, and admin dashboard
This commit introduces major enhancements to Talk2Me: ## Database Integration - PostgreSQL support with SQLAlchemy ORM - Redis integration for caching and real-time analytics - Automated database initialization scripts - Migration support infrastructure ## User Authentication System - JWT-based API authentication - Session-based web authentication - API key authentication for programmatic access - User roles and permissions (admin/user) - Login history and session tracking - Rate limiting per user with customizable limits ## Admin Dashboard - Real-time analytics and monitoring - User management interface (create, edit, delete users) - System health monitoring - Request/error tracking - Language pair usage statistics - Performance metrics visualization ## Key Features - Dual authentication support (token + user accounts) - Graceful fallback for missing services - Non-blocking analytics middleware - Comprehensive error handling - Session management with security features ## Bug Fixes - Fixed rate limiting bypass for admin routes - Added missing email validation method - Improved error handling for missing database tables - Fixed session-based authentication for API endpoints 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
534
admin/__init__.py
Normal file
534
admin/__init__.py
Normal file
@@ -0,0 +1,534 @@
|
||||
from flask import Blueprint, request, jsonify, render_template, redirect, url_for, session
|
||||
from functools import wraps
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
import redis
|
||||
import psycopg2
|
||||
from psycopg2.extras import RealDictCursor
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Create admin blueprint
|
||||
admin_bp = Blueprint('admin', __name__,
|
||||
template_folder='templates',
|
||||
static_folder='static',
|
||||
static_url_path='/admin/static')
|
||||
|
||||
# Initialize Redis and PostgreSQL connections
|
||||
redis_client = None
|
||||
pg_conn = None
|
||||
|
||||
def init_admin(app):
|
||||
"""Initialize admin module with app configuration"""
|
||||
global redis_client, pg_conn
|
||||
|
||||
try:
|
||||
# Initialize Redis
|
||||
redis_client = redis.from_url(
|
||||
app.config.get('REDIS_URL', 'redis://localhost:6379/0'),
|
||||
decode_responses=True
|
||||
)
|
||||
redis_client.ping()
|
||||
logger.info("Redis connection established for admin dashboard")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Redis: {e}")
|
||||
redis_client = None
|
||||
|
||||
try:
|
||||
# Initialize PostgreSQL
|
||||
pg_conn = psycopg2.connect(
|
||||
app.config.get('DATABASE_URL', 'postgresql://localhost/talk2me')
|
||||
)
|
||||
logger.info("PostgreSQL connection established for admin dashboard")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to PostgreSQL: {e}")
|
||||
pg_conn = None
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to require admin authentication"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Check if user is logged in with admin role (from unified login)
|
||||
user_role = session.get('user_role')
|
||||
if user_role == 'admin':
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# Also support the old admin token for backward compatibility
|
||||
auth_token = request.headers.get('X-Admin-Token')
|
||||
session_token = session.get('admin_token')
|
||||
expected_token = os.environ.get('ADMIN_TOKEN', 'default-admin-token')
|
||||
|
||||
if auth_token == expected_token or session_token == expected_token:
|
||||
if auth_token == expected_token:
|
||||
session['admin_token'] = expected_token
|
||||
return f(*args, **kwargs)
|
||||
|
||||
# For API endpoints, return JSON error
|
||||
if request.path.startswith('/admin/api/'):
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
# For web pages, redirect to unified login
|
||||
return redirect(url_for('login', next=request.url))
|
||||
|
||||
return decorated_function
|
||||
|
||||
@admin_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""Admin login - redirect to main login page"""
|
||||
# Redirect to the unified login page
|
||||
next_url = request.args.get('next', url_for('admin.dashboard'))
|
||||
return redirect(url_for('login', next=next_url))
|
||||
|
||||
@admin_bp.route('/logout')
|
||||
def logout():
|
||||
"""Admin logout - redirect to main logout"""
|
||||
# Clear all session data
|
||||
session.clear()
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@admin_bp.route('/')
|
||||
@admin_bp.route('/dashboard')
|
||||
@admin_required
|
||||
def dashboard():
|
||||
"""Main admin dashboard"""
|
||||
return render_template('dashboard.html')
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@admin_required
|
||||
def users():
|
||||
"""User management page"""
|
||||
# The template is in the main templates folder, not admin/templates
|
||||
return render_template('admin_users.html')
|
||||
|
||||
# Analytics API endpoints
|
||||
@admin_bp.route('/api/stats/overview')
|
||||
@admin_required
|
||||
def get_overview_stats():
|
||||
"""Get overview statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'requests': {'total': 0, 'today': 0, 'hour': 0},
|
||||
'translations': {'total': 0, 'today': 0},
|
||||
'transcriptions': {'total': 0, 'today': 0},
|
||||
'active_sessions': 0,
|
||||
'error_rate': 0,
|
||||
'cache_hit_rate': 0,
|
||||
'system_health': check_system_health()
|
||||
}
|
||||
|
||||
# Get data from Redis
|
||||
if redis_client:
|
||||
try:
|
||||
# Request counts
|
||||
stats['requests']['total'] = int(redis_client.get('stats:requests:total') or 0)
|
||||
stats['requests']['today'] = int(redis_client.get(f'stats:requests:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
stats['requests']['hour'] = int(redis_client.get(f'stats:requests:hourly:{datetime.now().strftime("%Y-%m-%d-%H")}') or 0)
|
||||
|
||||
# Operation counts
|
||||
stats['translations']['total'] = int(redis_client.get('stats:translations:total') or 0)
|
||||
stats['translations']['today'] = int(redis_client.get(f'stats:translations:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
stats['transcriptions']['total'] = int(redis_client.get('stats:transcriptions:total') or 0)
|
||||
stats['transcriptions']['today'] = int(redis_client.get(f'stats:transcriptions:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
|
||||
# Active sessions
|
||||
stats['active_sessions'] = len(redis_client.keys('session:*'))
|
||||
|
||||
# Cache stats
|
||||
cache_hits = int(redis_client.get('stats:cache:hits') or 0)
|
||||
cache_misses = int(redis_client.get('stats:cache:misses') or 0)
|
||||
if cache_hits + cache_misses > 0:
|
||||
stats['cache_hit_rate'] = round((cache_hits / (cache_hits + cache_misses)) * 100, 2)
|
||||
|
||||
# Error rate
|
||||
total_requests = stats['requests']['today']
|
||||
errors_today = int(redis_client.get(f'stats:errors:daily:{datetime.now().strftime("%Y-%m-%d")}') or 0)
|
||||
if total_requests > 0:
|
||||
stats['error_rate'] = round((errors_today / total_requests) * 100, 2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Redis stats: {e}")
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_overview_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/requests/<timeframe>')
|
||||
@admin_required
|
||||
def get_request_stats(timeframe):
|
||||
"""Get request statistics for different timeframes"""
|
||||
try:
|
||||
if timeframe not in ['minute', 'hour', 'day']:
|
||||
return jsonify({'error': 'Invalid timeframe'}), 400
|
||||
|
||||
data = []
|
||||
labels = []
|
||||
|
||||
if redis_client:
|
||||
now = datetime.now()
|
||||
|
||||
if timeframe == 'minute':
|
||||
# Last 60 minutes
|
||||
for i in range(59, -1, -1):
|
||||
time_key = (now - timedelta(minutes=i)).strftime('%Y-%m-%d-%H-%M')
|
||||
count = int(redis_client.get(f'stats:requests:minute:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(minutes=i)).strftime('%H:%M'))
|
||||
|
||||
elif timeframe == 'hour':
|
||||
# Last 24 hours
|
||||
for i in range(23, -1, -1):
|
||||
time_key = (now - timedelta(hours=i)).strftime('%Y-%m-%d-%H')
|
||||
count = int(redis_client.get(f'stats:requests:hourly:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(hours=i)).strftime('%H:00'))
|
||||
|
||||
elif timeframe == 'day':
|
||||
# Last 30 days
|
||||
for i in range(29, -1, -1):
|
||||
time_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
count = int(redis_client.get(f'stats:requests:daily:{time_key}') or 0)
|
||||
data.append(count)
|
||||
labels.append((now - timedelta(days=i)).strftime('%m/%d'))
|
||||
|
||||
return jsonify({
|
||||
'labels': labels,
|
||||
'data': data,
|
||||
'timeframe': timeframe
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_request_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/operations')
|
||||
@admin_required
|
||||
def get_operation_stats():
|
||||
"""Get translation and transcription statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'translations': {'data': [], 'labels': []},
|
||||
'transcriptions': {'data': [], 'labels': []},
|
||||
'language_pairs': {},
|
||||
'response_times': {'translation': [], 'transcription': []}
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
now = datetime.now()
|
||||
|
||||
# Get daily stats for last 7 days
|
||||
for i in range(6, -1, -1):
|
||||
date_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
date_label = (now - timedelta(days=i)).strftime('%m/%d')
|
||||
|
||||
# Translation counts
|
||||
trans_count = int(redis_client.get(f'stats:translations:daily:{date_key}') or 0)
|
||||
stats['translations']['data'].append(trans_count)
|
||||
stats['translations']['labels'].append(date_label)
|
||||
|
||||
# Transcription counts
|
||||
transcr_count = int(redis_client.get(f'stats:transcriptions:daily:{date_key}') or 0)
|
||||
stats['transcriptions']['data'].append(transcr_count)
|
||||
stats['transcriptions']['labels'].append(date_label)
|
||||
|
||||
# Get language pair statistics
|
||||
lang_pairs = redis_client.hgetall('stats:language_pairs') or {}
|
||||
stats['language_pairs'] = {k: int(v) for k, v in lang_pairs.items()}
|
||||
|
||||
# Get response times (last 100 operations)
|
||||
trans_times = redis_client.lrange('stats:response_times:translation', 0, 99)
|
||||
transcr_times = redis_client.lrange('stats:response_times:transcription', 0, 99)
|
||||
|
||||
stats['response_times']['translation'] = [float(t) for t in trans_times[:20]]
|
||||
stats['response_times']['transcription'] = [float(t) for t in transcr_times[:20]]
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_operation_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/errors')
|
||||
@admin_required
|
||||
def get_error_stats():
|
||||
"""Get error statistics"""
|
||||
try:
|
||||
stats = {
|
||||
'error_types': {},
|
||||
'error_timeline': {'data': [], 'labels': []},
|
||||
'recent_errors': []
|
||||
}
|
||||
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
# Get error types distribution
|
||||
cursor.execute("""
|
||||
SELECT error_type, COUNT(*) as count
|
||||
FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY error_type
|
||||
ORDER BY count DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
error_types = cursor.fetchall()
|
||||
stats['error_types'] = {row['error_type']: row['count'] for row in error_types}
|
||||
|
||||
# Get error timeline (hourly for last 24 hours)
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
DATE_TRUNC('hour', created_at) as hour,
|
||||
COUNT(*) as count
|
||||
FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
""")
|
||||
timeline = cursor.fetchall()
|
||||
|
||||
for row in timeline:
|
||||
stats['error_timeline']['labels'].append(row['hour'].strftime('%H:00'))
|
||||
stats['error_timeline']['data'].append(row['count'])
|
||||
|
||||
# Get recent errors
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
error_type,
|
||||
error_message,
|
||||
endpoint,
|
||||
created_at
|
||||
FROM error_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 10
|
||||
""")
|
||||
recent = cursor.fetchall()
|
||||
stats['recent_errors'] = [
|
||||
{
|
||||
'type': row['error_type'],
|
||||
'message': row['error_message'][:100],
|
||||
'endpoint': row['endpoint'],
|
||||
'time': row['created_at'].isoformat()
|
||||
}
|
||||
for row in recent
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying PostgreSQL: {e}")
|
||||
|
||||
# Fallback to Redis if PostgreSQL fails
|
||||
if not stats['error_types'] and redis_client:
|
||||
error_types = redis_client.hgetall('stats:error_types') or {}
|
||||
stats['error_types'] = {k: int(v) for k, v in error_types.items()}
|
||||
|
||||
# Get hourly error counts
|
||||
now = datetime.now()
|
||||
for i in range(23, -1, -1):
|
||||
hour_key = (now - timedelta(hours=i)).strftime('%Y-%m-%d-%H')
|
||||
count = int(redis_client.get(f'stats:errors:hourly:{hour_key}') or 0)
|
||||
stats['error_timeline']['data'].append(count)
|
||||
stats['error_timeline']['labels'].append((now - timedelta(hours=i)).strftime('%H:00'))
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_error_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/stats/performance')
|
||||
@admin_required
|
||||
def get_performance_stats():
|
||||
"""Get performance metrics"""
|
||||
try:
|
||||
stats = {
|
||||
'response_times': {
|
||||
'translation': {'avg': 0, 'p95': 0, 'p99': 0},
|
||||
'transcription': {'avg': 0, 'p95': 0, 'p99': 0},
|
||||
'tts': {'avg': 0, 'p95': 0, 'p99': 0}
|
||||
},
|
||||
'throughput': {'data': [], 'labels': []},
|
||||
'slow_requests': []
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
# Calculate response time percentiles
|
||||
for operation in ['translation', 'transcription', 'tts']:
|
||||
times = redis_client.lrange(f'stats:response_times:{operation}', 0, -1)
|
||||
if times:
|
||||
times = sorted([float(t) for t in times])
|
||||
stats['response_times'][operation]['avg'] = round(sum(times) / len(times), 2)
|
||||
stats['response_times'][operation]['p95'] = round(times[int(len(times) * 0.95)], 2)
|
||||
stats['response_times'][operation]['p99'] = round(times[int(len(times) * 0.99)], 2)
|
||||
|
||||
# Get throughput (requests per minute for last hour)
|
||||
now = datetime.now()
|
||||
for i in range(59, -1, -1):
|
||||
time_key = (now - timedelta(minutes=i)).strftime('%Y-%m-%d-%H-%M')
|
||||
count = int(redis_client.get(f'stats:requests:minute:{time_key}') or 0)
|
||||
stats['throughput']['data'].append(count)
|
||||
stats['throughput']['labels'].append((now - timedelta(minutes=i)).strftime('%H:%M'))
|
||||
|
||||
# Get slow requests
|
||||
slow_requests = redis_client.lrange('stats:slow_requests', 0, 9)
|
||||
stats['slow_requests'] = [json.loads(req) for req in slow_requests if req]
|
||||
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_performance_stats: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@admin_bp.route('/api/export/<data_type>')
|
||||
@admin_required
|
||||
def export_data(data_type):
|
||||
"""Export analytics data"""
|
||||
try:
|
||||
if data_type not in ['requests', 'errors', 'performance', 'all']:
|
||||
return jsonify({'error': 'Invalid data type'}), 400
|
||||
|
||||
export_data = {
|
||||
'export_time': datetime.now().isoformat(),
|
||||
'data_type': data_type
|
||||
}
|
||||
|
||||
if data_type in ['requests', 'all']:
|
||||
# Export request data
|
||||
request_data = []
|
||||
if redis_client:
|
||||
# Get daily stats for last 30 days
|
||||
now = datetime.now()
|
||||
for i in range(29, -1, -1):
|
||||
date_key = (now - timedelta(days=i)).strftime('%Y-%m-%d')
|
||||
request_data.append({
|
||||
'date': date_key,
|
||||
'requests': int(redis_client.get(f'stats:requests:daily:{date_key}') or 0),
|
||||
'translations': int(redis_client.get(f'stats:translations:daily:{date_key}') or 0),
|
||||
'transcriptions': int(redis_client.get(f'stats:transcriptions:daily:{date_key}') or 0),
|
||||
'errors': int(redis_client.get(f'stats:errors:daily:{date_key}') or 0)
|
||||
})
|
||||
export_data['requests'] = request_data
|
||||
|
||||
if data_type in ['errors', 'all']:
|
||||
# Export error data from PostgreSQL
|
||||
error_data = []
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute("""
|
||||
SELECT * FROM error_logs
|
||||
WHERE created_at > NOW() - INTERVAL '7 days'
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
errors = cursor.fetchall()
|
||||
error_data = [dict(row) for row in errors]
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting from PostgreSQL: {e}")
|
||||
export_data['errors'] = error_data
|
||||
|
||||
if data_type in ['performance', 'all']:
|
||||
# Export performance data
|
||||
perf_data = {
|
||||
'response_times': {},
|
||||
'slow_requests': []
|
||||
}
|
||||
if redis_client:
|
||||
for op in ['translation', 'transcription', 'tts']:
|
||||
times = redis_client.lrange(f'stats:response_times:{op}', 0, -1)
|
||||
perf_data['response_times'][op] = [float(t) for t in times]
|
||||
|
||||
slow_reqs = redis_client.lrange('stats:slow_requests', 0, -1)
|
||||
perf_data['slow_requests'] = [json.loads(req) for req in slow_reqs if req]
|
||||
|
||||
export_data['performance'] = perf_data
|
||||
|
||||
# Return as downloadable JSON
|
||||
response = jsonify(export_data)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename=talk2me_analytics_{data_type}_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in export_data: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def check_system_health():
|
||||
"""Check health of system components"""
|
||||
health = {
|
||||
'redis': 'unknown',
|
||||
'postgresql': 'unknown',
|
||||
'overall': 'healthy'
|
||||
}
|
||||
|
||||
# Check Redis
|
||||
if redis_client:
|
||||
try:
|
||||
redis_client.ping()
|
||||
health['redis'] = 'healthy'
|
||||
except:
|
||||
health['redis'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
else:
|
||||
health['redis'] = 'not_configured'
|
||||
health['overall'] = 'degraded'
|
||||
|
||||
# Check PostgreSQL
|
||||
if pg_conn:
|
||||
try:
|
||||
with pg_conn.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
health['postgresql'] = 'healthy'
|
||||
except:
|
||||
health['postgresql'] = 'unhealthy'
|
||||
health['overall'] = 'degraded'
|
||||
else:
|
||||
health['postgresql'] = 'not_configured'
|
||||
health['overall'] = 'degraded'
|
||||
|
||||
return health
|
||||
|
||||
# WebSocket support for real-time updates (using Server-Sent Events as fallback)
|
||||
@admin_bp.route('/api/stream/updates')
|
||||
@admin_required
|
||||
def stream_updates():
|
||||
"""Stream real-time updates using Server-Sent Events"""
|
||||
def generate():
|
||||
last_update = time.time()
|
||||
|
||||
while True:
|
||||
# Send update every 5 seconds
|
||||
if time.time() - last_update > 5:
|
||||
try:
|
||||
# Get current stats
|
||||
stats = {
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'requests_per_minute': 0,
|
||||
'active_sessions': 0,
|
||||
'recent_errors': 0
|
||||
}
|
||||
|
||||
if redis_client:
|
||||
# Current requests per minute
|
||||
current_minute = datetime.now().strftime('%Y-%m-%d-%H-%M')
|
||||
stats['requests_per_minute'] = int(redis_client.get(f'stats:requests:minute:{current_minute}') or 0)
|
||||
|
||||
# Active sessions
|
||||
stats['active_sessions'] = len(redis_client.keys('session:*'))
|
||||
|
||||
# Recent errors
|
||||
current_hour = datetime.now().strftime('%Y-%m-%d-%H')
|
||||
stats['recent_errors'] = int(redis_client.get(f'stats:errors:hourly:{current_hour}') or 0)
|
||||
|
||||
yield f"data: {json.dumps(stats)}\n\n"
|
||||
last_update = time.time()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in stream_updates: {e}")
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
return app.response_class(
|
||||
generate(),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
)
|
192
admin/static/css/admin.css
Normal file
192
admin/static/css/admin.css
Normal file
@@ -0,0 +1,192 @@
|
||||
/* Admin Dashboard Styles */
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 56px; /* For fixed navbar */
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e3e6f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.badge {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.badge.bg-success {
|
||||
background-color: #1cc88a !important;
|
||||
}
|
||||
|
||||
.badge.bg-warning {
|
||||
background-color: #f6c23e !important;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.badge.bg-danger {
|
||||
background-color: #e74a3b !important;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
canvas {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Login Page */
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.card-body h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: 0.25rem !important;
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinners */
|
||||
.spinner-border-sm {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* Error list */
|
||||
.error-item {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.error-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.error-type {
|
||||
font-weight: 600;
|
||||
color: #e74a3b;
|
||||
}
|
||||
|
||||
.error-time {
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
background-color: white;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.updating {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #2a2a2a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #2a2a2a;
|
||||
border-bottom-color: #3a3a3a;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background-color: #3a3a3a;
|
||||
border-color: #4a4a4a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Performance optimization */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 40vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
519
admin/static/js/admin.js
Normal file
519
admin/static/js/admin.js
Normal file
@@ -0,0 +1,519 @@
|
||||
// Admin Dashboard JavaScript
|
||||
|
||||
// Global variables
|
||||
let charts = {};
|
||||
let currentTimeframe = 'minute';
|
||||
let eventSource = null;
|
||||
|
||||
// Chart.js default configuration
|
||||
Chart.defaults.responsive = true;
|
||||
Chart.defaults.maintainAspectRatio = false;
|
||||
|
||||
// Initialize dashboard
|
||||
function initializeDashboard() {
|
||||
// Initialize all charts
|
||||
initializeCharts();
|
||||
|
||||
// Set up event handlers
|
||||
setupEventHandlers();
|
||||
}
|
||||
|
||||
// Initialize all charts
|
||||
function initializeCharts() {
|
||||
// Request Volume Chart
|
||||
const requestCtx = document.getElementById('requestChart').getContext('2d');
|
||||
charts.request = new Chart(requestCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label: 'Requests',
|
||||
data: [],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
||||
tension: 0.1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Language Pairs Chart
|
||||
const languageCtx = document.getElementById('languageChart').getContext('2d');
|
||||
charts.language = new Chart(languageCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#FF6384',
|
||||
'#36A2EB',
|
||||
'#FFCE56',
|
||||
'#4BC0C0',
|
||||
'#9966FF',
|
||||
'#FF9F40'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Operations Chart
|
||||
const operationsCtx = document.getElementById('operationsChart').getContext('2d');
|
||||
charts.operations = new Chart(operationsCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Translations',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.8)'
|
||||
},
|
||||
{
|
||||
label: 'Transcriptions',
|
||||
data: [],
|
||||
backgroundColor: 'rgba(255, 159, 64, 0.8)'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Response Time Chart
|
||||
const responseCtx = document.getElementById('responseTimeChart').getContext('2d');
|
||||
charts.responseTime = new Chart(responseCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Translation', 'Transcription', 'TTS'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Average',
|
||||
data: [],
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)'
|
||||
},
|
||||
{
|
||||
label: 'P95',
|
||||
data: [],
|
||||
borderColor: 'rgb(255, 206, 86)',
|
||||
backgroundColor: 'rgba(255, 206, 86, 0.2)'
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: [],
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.2)'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Response Time (ms)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Error Type Chart
|
||||
const errorCtx = document.getElementById('errorTypeChart').getContext('2d');
|
||||
charts.errorType = new Chart(errorCtx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
data: [],
|
||||
backgroundColor: [
|
||||
'#e74a3b',
|
||||
'#f6c23e',
|
||||
'#4e73df',
|
||||
'#1cc88a',
|
||||
'#36b9cc',
|
||||
'#858796'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load overview statistics
|
||||
function loadOverviewStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/overview',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update cards
|
||||
$('#total-requests').text(data.requests.total.toLocaleString());
|
||||
$('#today-requests').text(data.requests.today.toLocaleString());
|
||||
$('#active-sessions').text(data.active_sessions);
|
||||
$('#error-rate').text(data.error_rate + '%');
|
||||
$('#cache-hit-rate').text(data.cache_hit_rate + '%');
|
||||
|
||||
// Update system health
|
||||
updateSystemHealth(data.system_health);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load overview stats:', error);
|
||||
showToast('Failed to load overview statistics', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update system health indicators
|
||||
function updateSystemHealth(health) {
|
||||
// Redis status
|
||||
const redisStatus = $('#redis-status');
|
||||
redisStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
if (health.redis === 'healthy') {
|
||||
redisStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (health.redis === 'not_configured') {
|
||||
redisStatus.addClass('bg-warning').text('Not Configured');
|
||||
} else {
|
||||
redisStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
|
||||
// PostgreSQL status
|
||||
const pgStatus = $('#postgresql-status');
|
||||
pgStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
if (health.postgresql === 'healthy') {
|
||||
pgStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (health.postgresql === 'not_configured') {
|
||||
pgStatus.addClass('bg-warning').text('Not Configured');
|
||||
} else {
|
||||
pgStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
|
||||
// ML services status (check via main app health endpoint)
|
||||
$.ajax({
|
||||
url: '/health/detailed',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
const mlStatus = $('#ml-status');
|
||||
mlStatus.removeClass('bg-success bg-warning bg-danger');
|
||||
|
||||
if (data.components.whisper.status === 'healthy' &&
|
||||
data.components.tts.status === 'healthy') {
|
||||
mlStatus.addClass('bg-success').text('Healthy');
|
||||
} else if (data.status === 'degraded') {
|
||||
mlStatus.addClass('bg-warning').text('Degraded');
|
||||
} else {
|
||||
mlStatus.addClass('bg-danger').text('Unhealthy');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load request chart data
|
||||
function loadRequestChart(timeframe) {
|
||||
currentTimeframe = timeframe;
|
||||
|
||||
// Update button states
|
||||
$('.btn-group button').removeClass('active');
|
||||
$(`button[onclick="updateRequestChart('${timeframe}')"]`).addClass('active');
|
||||
|
||||
$.ajax({
|
||||
url: `/admin/api/stats/requests/${timeframe}`,
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
charts.request.data.labels = data.labels;
|
||||
charts.request.data.datasets[0].data = data.data;
|
||||
charts.request.update();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load request chart:', error);
|
||||
showToast('Failed to load request data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update request chart
|
||||
function updateRequestChart(timeframe) {
|
||||
loadRequestChart(timeframe);
|
||||
}
|
||||
|
||||
// Load operation statistics
|
||||
function loadOperationStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/operations',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update operations chart
|
||||
charts.operations.data.labels = data.translations.labels;
|
||||
charts.operations.data.datasets[0].data = data.translations.data;
|
||||
charts.operations.data.datasets[1].data = data.transcriptions.data;
|
||||
charts.operations.update();
|
||||
|
||||
// Update language pairs chart
|
||||
const langPairs = Object.entries(data.language_pairs)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6); // Top 6 language pairs
|
||||
|
||||
charts.language.data.labels = langPairs.map(pair => pair[0]);
|
||||
charts.language.data.datasets[0].data = langPairs.map(pair => pair[1]);
|
||||
charts.language.update();
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load operation stats:', error);
|
||||
showToast('Failed to load operation data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load error statistics
|
||||
function loadErrorStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/errors',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update error type chart
|
||||
const errorTypes = Object.entries(data.error_types)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 6);
|
||||
|
||||
charts.errorType.data.labels = errorTypes.map(type => type[0]);
|
||||
charts.errorType.data.datasets[0].data = errorTypes.map(type => type[1]);
|
||||
charts.errorType.update();
|
||||
|
||||
// Update recent errors list
|
||||
updateRecentErrors(data.recent_errors);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load error stats:', error);
|
||||
showToast('Failed to load error data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update recent errors list
|
||||
function updateRecentErrors(errors) {
|
||||
const errorsList = $('#recent-errors-list');
|
||||
|
||||
if (errors.length === 0) {
|
||||
errorsList.html('<p class="text-muted text-center">No recent errors</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
errors.forEach(error => {
|
||||
const time = new Date(error.time).toLocaleString();
|
||||
html += `
|
||||
<div class="error-item">
|
||||
<div class="error-type">${error.type}</div>
|
||||
<div class="text-muted small">${error.endpoint}</div>
|
||||
<div>${error.message}</div>
|
||||
<div class="error-time">${time}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
errorsList.html(html);
|
||||
}
|
||||
|
||||
// Load performance statistics
|
||||
function loadPerformanceStats() {
|
||||
$.ajax({
|
||||
url: '/admin/api/stats/performance',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
// Update response time chart
|
||||
const operations = ['translation', 'transcription', 'tts'];
|
||||
const avgData = operations.map(op => data.response_times[op].avg);
|
||||
const p95Data = operations.map(op => data.response_times[op].p95);
|
||||
const p99Data = operations.map(op => data.response_times[op].p99);
|
||||
|
||||
charts.responseTime.data.datasets[0].data = avgData;
|
||||
charts.responseTime.data.datasets[1].data = p95Data;
|
||||
charts.responseTime.data.datasets[2].data = p99Data;
|
||||
charts.responseTime.update();
|
||||
|
||||
// Update performance table
|
||||
updatePerformanceTable(data.response_times);
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
console.error('Failed to load performance stats:', error);
|
||||
showToast('Failed to load performance data', 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update performance table
|
||||
function updatePerformanceTable(responseData) {
|
||||
const tbody = $('#performance-table');
|
||||
let html = '';
|
||||
|
||||
const operations = {
|
||||
'translation': 'Translation',
|
||||
'transcription': 'Transcription',
|
||||
'tts': 'Text-to-Speech'
|
||||
};
|
||||
|
||||
for (const [key, label] of Object.entries(operations)) {
|
||||
const data = responseData[key];
|
||||
html += `
|
||||
<tr>
|
||||
<td>${label}</td>
|
||||
<td>${data.avg || '-'}</td>
|
||||
<td>${data.p95 || '-'}</td>
|
||||
<td>${data.p99 || '-'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
tbody.html(html);
|
||||
}
|
||||
|
||||
// Start real-time updates
|
||||
function startRealtimeUpdates() {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
eventSource = new EventSource('/admin/api/stream/updates');
|
||||
|
||||
eventSource.onmessage = function(event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Update real-time metrics
|
||||
if (data.requests_per_minute !== undefined) {
|
||||
$('#requests-per-minute').text(data.requests_per_minute);
|
||||
}
|
||||
|
||||
if (data.active_sessions !== undefined) {
|
||||
$('#active-sessions').text(data.active_sessions);
|
||||
}
|
||||
|
||||
// Update last update time
|
||||
$('#last-update').text('Just now');
|
||||
|
||||
// Show update indicator
|
||||
$('#update-status').text('Connected').removeClass('text-danger').addClass('text-success');
|
||||
};
|
||||
|
||||
eventSource.onerror = function(error) {
|
||||
console.error('EventSource error:', error);
|
||||
$('#update-status').text('Disconnected').removeClass('text-success').addClass('text-danger');
|
||||
|
||||
// Reconnect after 5 seconds
|
||||
setTimeout(startRealtimeUpdates, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
// Export data function
|
||||
function exportData(dataType) {
|
||||
window.location.href = `/admin/api/export/${dataType}`;
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = $('#update-toast');
|
||||
const toastBody = toast.find('.toast-body');
|
||||
|
||||
toastBody.removeClass('text-success text-danger text-warning');
|
||||
|
||||
if (type === 'success') {
|
||||
toastBody.addClass('text-success');
|
||||
} else if (type === 'error') {
|
||||
toastBody.addClass('text-danger');
|
||||
} else if (type === 'warning') {
|
||||
toastBody.addClass('text-warning');
|
||||
}
|
||||
|
||||
toastBody.text(message);
|
||||
|
||||
const bsToast = new bootstrap.Toast(toast[0]);
|
||||
bsToast.show();
|
||||
}
|
||||
|
||||
// Setup event handlers
|
||||
function setupEventHandlers() {
|
||||
// Auto-refresh toggle
|
||||
$('#auto-refresh').on('change', function() {
|
||||
if ($(this).prop('checked')) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
// Export buttons
|
||||
$('.export-btn').on('click', function() {
|
||||
const dataType = $(this).data('type');
|
||||
exportData(dataType);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-refresh functionality
|
||||
let refreshIntervals = {};
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshIntervals.overview = setInterval(loadOverviewStats, 10000);
|
||||
refreshIntervals.operations = setInterval(loadOperationStats, 30000);
|
||||
refreshIntervals.errors = setInterval(loadErrorStats, 60000);
|
||||
refreshIntervals.performance = setInterval(loadPerformanceStats, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
Object.values(refreshIntervals).forEach(interval => clearInterval(interval));
|
||||
refreshIntervals = {};
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(ms) {
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
if (ms < 60000) return (ms / 1000).toFixed(1) + 's';
|
||||
return (ms / 60000).toFixed(1) + 'm';
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
$(document).ready(function() {
|
||||
if ($('#requestChart').length > 0) {
|
||||
initializeDashboard();
|
||||
}
|
||||
});
|
75
admin/templates/base.html
Normal file
75
admin/templates/base.html
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Talk2Me Admin Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('admin.static', filename='css/admin.css') }}">
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-language"></i> Talk2Me Admin
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.users') }}">
|
||||
<i class="fas fa-users"></i> Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="exportData('all')">
|
||||
<i class="fas fa-download"></i> Export Data
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.logout') }}">
|
||||
<i class="fas fa-sign-out-alt"></i> Logout
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container-fluid mt-5 pt-3">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- jQuery (for AJAX) -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script src="{{ url_for('admin.static', filename='js/admin.js') }}"></script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
277
admin/templates/dashboard.html
Normal file
277
admin/templates/dashboard.html
Normal file
@@ -0,0 +1,277 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Talk2Me Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Quick Actions</h5>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-primary">
|
||||
<i class="fas fa-users"></i> Manage Users
|
||||
</a>
|
||||
<button onclick="exportData('all')" class="btn btn-secondary">
|
||||
<i class="fas fa-download"></i> Export Data
|
||||
</button>
|
||||
<button onclick="clearCache()" class="btn btn-warning">
|
||||
<i class="fas fa-trash"></i> Clear Cache
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-primary">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Total Requests</h5>
|
||||
<h2 class="card-text" id="total-requests">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Today: <span id="today-requests">-</span></small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-success">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Active Sessions</h5>
|
||||
<h2 class="card-text" id="active-sessions">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Live users</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-warning">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Error Rate</h5>
|
||||
<h2 class="card-text" id="error-rate">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Last 24 hours</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 mb-3">
|
||||
<div class="card text-white bg-info">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Cache Hit Rate</h5>
|
||||
<h2 class="card-text" id="cache-hit-rate">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
</h2>
|
||||
<p class="card-text"><small>Performance metric</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health Status -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-heartbeat"></i> System Health</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-database fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">Redis</h6>
|
||||
<span class="badge" id="redis-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-server fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">PostgreSQL</h6>
|
||||
<span class="badge" id="postgresql-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-microphone fa-2x me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0">Whisper/TTS</h6>
|
||||
<span class="badge" id="ml-status">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 1 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Request Volume</h5>
|
||||
<div class="btn-group btn-group-sm float-end" role="group">
|
||||
<button type="button" class="btn btn-outline-primary active" onclick="updateRequestChart('minute')">Minute</button>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="updateRequestChart('hour')">Hour</button>
|
||||
<button type="button" class="btn btn-outline-primary" onclick="updateRequestChart('day')">Day</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="requestChart" height="100"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Language Pairs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="languageChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row 2 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Operations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="operationsChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Response Times (ms)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="responseTimeChart" height="120"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Analysis -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Error Types</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="errorTypeChart" height="150"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Recent Errors</h5>
|
||||
</div>
|
||||
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
||||
<div id="recent-errors-list">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Performance Metrics</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Operation</th>
|
||||
<th>Average (ms)</th>
|
||||
<th>95th Percentile (ms)</th>
|
||||
<th>99th Percentile (ms)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="performance-table">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Real-time Updates Status -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1000">
|
||||
<div class="toast" id="update-toast" role="alert">
|
||||
<div class="toast-header">
|
||||
<i class="fas fa-sync-alt me-2"></i>
|
||||
<strong class="me-auto">Real-time Updates</strong>
|
||||
<small id="last-update">Just now</small>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
<span id="update-status">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Initialize dashboard
|
||||
$(document).ready(function() {
|
||||
initializeDashboard();
|
||||
|
||||
// Start real-time updates
|
||||
startRealtimeUpdates();
|
||||
|
||||
// Load initial data
|
||||
loadOverviewStats();
|
||||
loadRequestChart('minute');
|
||||
loadOperationStats();
|
||||
loadErrorStats();
|
||||
loadPerformanceStats();
|
||||
|
||||
// Refresh data periodically
|
||||
setInterval(loadOverviewStats, 10000); // Every 10 seconds
|
||||
setInterval(function() {
|
||||
loadRequestChart(currentTimeframe);
|
||||
}, 30000); // Every 30 seconds
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
120
admin/templates/dashboard_simple.html
Normal file
120
admin/templates/dashboard_simple.html
Normal file
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Talk2Me Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Simple Mode Notice -->
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<h4 class="alert-heading">Simple Mode Active</h4>
|
||||
<p>The admin dashboard is running in simple mode because Redis and PostgreSQL services are not available.</p>
|
||||
<hr>
|
||||
<p class="mb-0">To enable full analytics and monitoring features, please ensure Redis and PostgreSQL are running.</p>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">System Status</h5>
|
||||
<p class="card-text">
|
||||
<span class="badge badge-success">Online</span>
|
||||
</p>
|
||||
<small class="text-muted">Talk2Me API is running</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin Access</h5>
|
||||
<p class="card-text">
|
||||
<span class="badge badge-primary">Authenticated</span>
|
||||
</p>
|
||||
<small class="text-muted">You are logged in as admin</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Services</h5>
|
||||
<p class="card-text">
|
||||
Redis: <span class="badge badge-secondary">Not configured</span><br>
|
||||
PostgreSQL: <span class="badge badge-secondary">Not configured</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Available Actions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>In simple mode, you can:</p>
|
||||
<ul>
|
||||
<li>Access the Talk2Me API with admin privileges</li>
|
||||
<li>View system health status</li>
|
||||
<li>Logout from the admin session</li>
|
||||
</ul>
|
||||
|
||||
<p class="mt-3">To enable full features, set up the following services:</p>
|
||||
<ol>
|
||||
<li><strong>Redis</strong>: For caching, rate limiting, and session management</li>
|
||||
<li><strong>PostgreSQL</strong>: For persistent storage of analytics and user data</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/admin/logout" class="btn btn-secondary">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup Instructions -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Quick Setup Guide</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>1. Install Redis:</h6>
|
||||
<pre class="bg-light p-2"><code># Ubuntu/Debian
|
||||
sudo apt-get install redis-server
|
||||
sudo systemctl start redis
|
||||
|
||||
# macOS
|
||||
brew install redis
|
||||
brew services start redis</code></pre>
|
||||
|
||||
<h6>2. Install PostgreSQL:</h6>
|
||||
<pre class="bg-light p-2"><code># Ubuntu/Debian
|
||||
sudo apt-get install postgresql
|
||||
sudo systemctl start postgresql
|
||||
|
||||
# macOS
|
||||
brew install postgresql
|
||||
brew services start postgresql</code></pre>
|
||||
|
||||
<h6>3. Configure Environment:</h6>
|
||||
<pre class="bg-light p-2"><code># Add to .env file
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
DATABASE_URL=postgresql://user:pass@localhost/talk2me</code></pre>
|
||||
|
||||
<h6>4. Initialize Database:</h6>
|
||||
<pre class="bg-light p-2"><code>python init_auth_db.py</code></pre>
|
||||
|
||||
<p class="mt-3">After completing these steps, restart the Talk2Me server to enable full admin features.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Simple mode - no need for real-time updates or API calls
|
||||
console.log('Admin dashboard loaded in simple mode');
|
||||
</script>
|
||||
{% endblock %}
|
35
admin/templates/login.html
Normal file
35
admin/templates/login.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Admin Login - Talk2Me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center mt-5">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-center mb-4">
|
||||
<i class="fas fa-lock"></i> Admin Login
|
||||
</h3>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="token" class="form-label">Admin Token</label>
|
||||
<input type="password" class="form-control" id="token" name="token" required autofocus>
|
||||
<div class="form-text">Enter your admin access token</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="fas fa-sign-in-alt"></i> Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
Reference in New Issue
Block a user