From c9af0a5bb1f7a8c412d2d56097055264e60751ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD?= Date: Fri, 9 Jan 2026 01:05:50 +0300 Subject: [PATCH] init commit --- .gitignore | 1 + APP/__pycache__/db.cpython-314.pyc | Bin 0 -> 5208 bytes .../openvpn_api_v3.cpython-314.pyc | Bin 0 -> 27432 bytes .../openvpn_gatherer_v3.cpython-314.pyc | Bin 0 -> 23365 bytes APP/config.ini | 32 + APP/db.py | 91 +++ APP/openvpn_api_v3.py | 574 ++++++++++++++++ APP/openvpn_gatherer_v3.py | 510 ++++++++++++++ APP/requirements.txt | 2 + DEV/task.md | 30 + DEV/task.md.resolved | 30 + DEV/task.md.resolved.10 | 30 + DEV/walkthrough.md | 68 ++ DEV/walkthrough.md.resolved | 68 ++ DEV/walkthrough.md.resolved.1 | 68 ++ DOCS/api_v3_endpoints.md | 202 ++++++ Deployment/APP/openrc/INSTALL.md | 49 ++ Deployment/APP/openrc/ovpmon-api | 16 + Deployment/APP/openrc/ovpmon-gatherer | 16 + UI/certificates.php | 142 ++++ UI/config.php | 30 + UI/css/style.css | 637 ++++++++++++++++++ UI/dashboard.php | 184 +++++ UI/index.php | 198 ++++++ UI/js/pages/certificates.js | 228 +++++++ UI/js/pages/dashboard.js | 269 ++++++++ UI/js/pages/index.js | 365 ++++++++++ UI/js/utils.js | 94 +++ 28 files changed, 3934 insertions(+) create mode 100644 .gitignore create mode 100644 APP/__pycache__/db.cpython-314.pyc create mode 100644 APP/__pycache__/openvpn_api_v3.cpython-314.pyc create mode 100644 APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc create mode 100644 APP/config.ini create mode 100644 APP/db.py create mode 100644 APP/openvpn_api_v3.py create mode 100644 APP/openvpn_gatherer_v3.py create mode 100644 APP/requirements.txt create mode 100644 DEV/task.md create mode 100644 DEV/task.md.resolved create mode 100644 DEV/task.md.resolved.10 create mode 100644 DEV/walkthrough.md create mode 100644 DEV/walkthrough.md.resolved create mode 100644 DEV/walkthrough.md.resolved.1 create mode 100644 DOCS/api_v3_endpoints.md create mode 100644 Deployment/APP/openrc/INSTALL.md create mode 100644 Deployment/APP/openrc/ovpmon-api create mode 100644 Deployment/APP/openrc/ovpmon-gatherer create mode 100644 UI/certificates.php create mode 100644 UI/config.php create mode 100644 UI/css/style.css create mode 100644 UI/dashboard.php create mode 100644 UI/index.php create mode 100644 UI/js/pages/certificates.js create mode 100644 UI/js/pages/dashboard.js create mode 100644 UI/js/pages/index.js create mode 100644 UI/js/utils.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/APP/__pycache__/db.cpython-314.pyc b/APP/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57ab2601d3ccf5d57bcfbea23d9193de7bae959b GIT binary patch literal 5208 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 literal 0 HcmV?d00001 diff --git a/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc b/APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..255eb9b5223b4b374253f36a46e09c1b51c26335 GIT binary patch literal 23365 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 literal 0 HcmV?d00001 diff --git a/APP/config.ini b/APP/config.ini new file mode 100644 index 0000000..c6ae15a --- /dev/null +++ b/APP/config.ini @@ -0,0 +1,32 @@ +[api] +host = 0.0.0.0 +port = 5000 +debug = false + +[openvpn_monitor] +log_path = /etc/openvpn/openvpn-status.log +db_path = /opt/ovpmon/openvpn_monitor.db +check_interval = 10 +data_retention_days = 90 +cleanup_interval_hours = 24 + +[logging] +level = INFO +log_file = /opt/ovpmon/openvpn_monitor.log + +[visualization] +refresh_interval = 5 +max_display_rows = 50 + +[certificates] +certificates_path = /opt/ovpn/pki/issued +certificate_extensions = crt + +[retention] +raw_retention_days = 7 +agg_5m_retention_days = 14 +agg_15m_retention_days = 28 +agg_1h_retention_days = 90 +agg_6h_retention_days = 180 +agg_1d_retention_days = 365 + diff --git a/APP/db.py b/APP/db.py new file mode 100644 index 0000000..9dee442 --- /dev/null +++ b/APP/db.py @@ -0,0 +1,91 @@ +import sqlite3 +import configparser +import os +import logging + +class DatabaseManager: + def __init__(self, config_file='config.ini'): + self.config_file = config_file + self.config = configparser.ConfigParser() + self.logger = logging.getLogger(__name__) + self.load_config() + + def load_config(self): + if os.path.exists(self.config_file): + self.config.read(self.config_file) + self.db_path = self.config.get('openvpn_monitor', 'db_path', fallback='openvpn_monitor.db') + + def get_connection(self): + """Get a database connection""" + return sqlite3.connect(self.db_path) + + def init_database(self): + """Initialize the database schema""" + # Create directory if needed + db_dir = os.path.dirname(self.db_path) + if db_dir and not os.path.exists(db_dir): + try: + os.makedirs(db_dir) + except OSError: + pass + + self.logger.info(f"Using database: {self.db_path}") + + conn = self.get_connection() + cursor = conn.cursor() + + try: + # 1. Clients Table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS clients ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + common_name TEXT UNIQUE NOT NULL, + real_address TEXT, + status TEXT DEFAULT 'Active', + total_bytes_received INTEGER DEFAULT 0, + total_bytes_sent INTEGER DEFAULT 0, + last_bytes_received INTEGER DEFAULT 0, + last_bytes_sent INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # 2. Raw Usage History + cursor.execute(''' + CREATE TABLE IF NOT EXISTS usage_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id INTEGER, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + bytes_received INTEGER, + bytes_sent INTEGER, + bytes_received_rate_mbps REAL, + bytes_sent_rate_mbps REAL, + FOREIGN KEY (client_id) REFERENCES clients (id) + ) + ''') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_usage_ts ON usage_history(timestamp)') + + # 3. Aggregated Stats Tables + tables = ['stats_5min', 'stats_15min', 'stats_hourly', 'stats_6h', 'stats_daily'] + + for table in tables: + cursor.execute(f''' + CREATE TABLE IF NOT EXISTS {table} ( + timestamp TEXT NOT NULL, + client_id INTEGER NOT NULL, + bytes_received INTEGER DEFAULT 0, + bytes_sent INTEGER DEFAULT 0, + PRIMARY KEY (timestamp, client_id), + FOREIGN KEY (client_id) REFERENCES clients (id) + ) + ''') + cursor.execute(f'CREATE INDEX IF NOT EXISTS idx_{table}_ts ON {table}(timestamp)') + + conn.commit() + self.logger.info("Database initialized with full schema") + except Exception as e: + self.logger.error(f"Database initialization error: {e}") + finally: + conn.close() diff --git a/APP/openvpn_api_v3.py b/APP/openvpn_api_v3.py new file mode 100644 index 0000000..1923b7f --- /dev/null +++ b/APP/openvpn_api_v3.py @@ -0,0 +1,574 @@ +import sqlite3 +import configparser +from datetime import datetime, timedelta, timezone +from flask import Flask, jsonify, request +from flask_cors import CORS +import logging +import subprocess +import os +from pathlib import Path +import re +from db import DatabaseManager + +# Set up logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +CORS(app) # Enable CORS for all routes + +class OpenVPNAPI: + def __init__(self, config_file='config.ini'): + self.db_manager = DatabaseManager(config_file) + self.config = configparser.ConfigParser() + self.config.read(config_file) + self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs') + self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',') + + def get_db_connection(self): + """Get a database connection""" + return self.db_manager.get_connection() + + # --- БЛОК РАБОТЫ С СЕРТИФИКАТАМИ (Оставлен без изменений) --- + def parse_openssl_date(self, date_str): + try: + parts = date_str.split() + if len(parts[1]) == 1: + parts[1] = f' {parts[1]}' + normalized_date = ' '.join(parts) + return datetime.strptime(normalized_date, '%b %d %H:%M:%S %Y GMT') + except ValueError: + try: + return datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z') + except ValueError: + logger.warning(f"Could not parse date: {date_str}") + return datetime.min + + def calculate_days_remaining(self, not_after_str): + if not_after_str == 'N/A': return 'N/A' + try: + expiration_date = self.parse_openssl_date(not_after_str) + if expiration_date == datetime.min: return 'N/A' + days_remaining = (expiration_date - datetime.now()).days + if days_remaining < 0: return f"Expired ({abs(days_remaining)} days ago)" + else: return f"{days_remaining} days" + except Exception: return 'N/A' + + def extract_cert_info(self, cert_file): + # Существующая логика парсинга через openssl + try: + result = subprocess.run(['openssl', 'x509', '-in', cert_file, '-noout', '-text'], + capture_output=True, text=True, check=True) + output = result.stdout + data = {'file': os.path.basename(cert_file), 'file_path': cert_file, 'subject': 'N/A', + 'issuer': 'N/A', 'not_after': 'N/A'} + + for line in output.split('\n'): + line = line.strip() + if line.startswith('Subject:'): + data['subject'] = line.split('Subject:', 1)[1].strip() + cn_match = re.search(r'CN=([^,]+)', data['subject']) + if cn_match: data['common_name'] = cn_match.group(1) + elif 'Not After' in line: + data['not_after'] = line.split(':', 1)[1].strip() + + if data['not_after'] != 'N/A': + data['sort_date'] = self.parse_openssl_date(data['not_after']).isoformat() + else: + data['sort_date'] = datetime.min.isoformat() + + data['days_remaining'] = self.calculate_days_remaining(data['not_after']) + data['is_expired'] = 'Expired' in data['days_remaining'] + return data + except Exception as e: + logger.error(f"Error processing {cert_file}: {e}") + return None + + def get_certificates_info(self): + cert_path = Path(self.certificates_path) + if not cert_path.exists(): return [] + cert_files = [] + for ext in self.cert_extensions: + cert_files.extend(cert_path.rglob(f'*.{ext.strip()}')) + cert_data = [] + for cert_file in cert_files: + data = self.extract_cert_info(str(cert_file)) + if data: cert_data.append(data) + return cert_data + # ----------------------------------------------------------- + + def get_current_stats(self): + """Get current statistics for all clients""" + conn = self.get_db_connection() + cursor = conn.cursor() + + try: + # ИЗМЕНЕНИЕ: + # Вместо "ORDER BY timestamp DESC LIMIT 1" (мгновенное значение), + # мы берем "MAX(rate)" за последние 2 минуты. + # Это фильтрует "нули", возникающие из-за рассинхрона записи логов, + # и показывает реальную пропускную способность канала. + + cursor.execute(''' + SELECT + c.common_name, + c.real_address, + c.status, + CASE + WHEN c.status = 'Active' THEN 'N/A' + ELSE strftime('%Y-%m-%d %H:%M:%S', c.last_activity) + END as last_activity, + c.total_bytes_received, + c.total_bytes_sent, + -- Пиковая скорость Download за последние 2 минуты + (SELECT MAX(uh.bytes_received_rate_mbps) + FROM usage_history uh + WHERE uh.client_id = c.id + AND uh.timestamp >= datetime('now', '-30 seconds')) as current_recv_rate, + -- Пиковая скорость Upload за последние 2 минуты + (SELECT MAX(uh.bytes_sent_rate_mbps) + FROM usage_history uh + WHERE uh.client_id = c.id + AND uh.timestamp >= datetime('now', '-30 seconds')) as current_sent_rate, + strftime('%Y-%m-%d %H:%M:%S', c.updated_at) as last_updated + FROM clients c + ORDER BY c.status DESC, c.common_name + ''') + + columns = [column[0] for column in cursor.description] + data = [] + + for row in cursor.fetchall(): + data.append(dict(zip(columns, row))) + + return data + + except Exception as e: + logger.error(f"Error fetching data: {e}") + return [] + finally: + conn.close() + + def get_client_history(self, common_name, start_date=None, end_date=None, resolution='auto'): + """ + Получение истории с поддержкой агрегации (TSDB). + Автоматически выбирает таблицу (Raw, Hourly, Daily) в зависимости от периода. + """ + conn = self.get_db_connection() + cursor = conn.cursor() + + # 1. Установка временных рамок + if not end_date: + end_date = datetime.now() + + if not start_date: + start_date = end_date - timedelta(hours=24) # Дефолт - сутки + + # Убедимся, что даты - это объекты datetime + if isinstance(start_date, str): + try: start_date = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S') + except: pass + if isinstance(end_date, str): + try: end_date = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S') + except: pass + + duration_hours = (end_date - start_date).total_seconds() / 3600 + + # 2. Маппинг разрешений на таблицы + table_map = { + 'raw': 'usage_history', + '5min': 'stats_5min', + '15min': 'stats_15min', + 'hourly': 'stats_hourly', + '6h': 'stats_6h', + 'daily': 'stats_daily' + } + + target_table = 'usage_history' + + # 3. Логика выбора таблицы + if resolution == 'auto': + if duration_hours <= 24: + target_table = 'usage_history' # Сырые данные (график за день) + elif duration_hours <= 168: # до 7 дней + target_table = 'stats_hourly' # По часам + elif duration_hours <= 2160: # до 3 месяцев + target_table = 'stats_6h' # Каждые 6 часов + else: + target_table = 'stats_daily' # По дням + elif resolution in table_map: + target_table = table_map[resolution] + + # Проверка существования таблицы (fallback, если миграции не было) + try: + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{target_table}'") + if not cursor.fetchone(): + logger.warning(f"Table {target_table} missing, fallback to usage_history") + target_table = 'usage_history' + except: + pass + + try: + # 4. Формирование запроса + # В агрегированных таблицах нет полей rate_mbps, возвращаем 0 + is_aggregated = target_table != 'usage_history' + + if is_aggregated: + query = f''' + SELECT + t.timestamp, + t.bytes_received, + t.bytes_sent, + 0 as bytes_received_rate_mbps, + 0 as bytes_sent_rate_mbps + FROM {target_table} t + JOIN clients c ON t.client_id = c.id + WHERE c.common_name = ? AND t.timestamp BETWEEN ? AND ? + ORDER BY t.timestamp ASC + ''' + else: + query = f''' + SELECT + uh.timestamp, + uh.bytes_received, + uh.bytes_sent, + uh.bytes_received_rate_mbps, + uh.bytes_sent_rate_mbps + FROM usage_history uh + JOIN clients c ON uh.client_id = c.id + WHERE c.common_name = ? AND uh.timestamp BETWEEN ? AND ? + ORDER BY uh.timestamp ASC + ''' + + s_str = start_date.strftime('%Y-%m-%d %H:%M:%S') + e_str = end_date.strftime('%Y-%m-%d %H:%M:%S') + + cursor.execute(query, (common_name, s_str, e_str)) + + columns = [column[0] for column in cursor.description] + data = [dict(zip(columns, row)) for row in cursor.fetchall()] + + return { + 'data': data, + 'meta': { + 'resolution_used': target_table, + 'record_count': len(data), + 'start': s_str, + 'end': e_str + } + } + + except Exception as e: + logger.error(f"Error fetching history: {e}") + return {'data': [], 'error': str(e)} + finally: + conn.close() + + def get_system_stats(self): + """Общая статистика по системе""" + conn = self.get_db_connection() + cursor = conn.cursor() + try: + cursor.execute(''' + SELECT + COUNT(*) as total_clients, + SUM(CASE WHEN status = 'Active' THEN 1 ELSE 0 END) as active_clients, + COALESCE(SUM(total_bytes_received), 0) as total_bytes_received, + COALESCE(SUM(total_bytes_sent), 0) as total_bytes_sent + FROM clients + ''') + result = cursor.fetchone() + columns = [column[0] for column in cursor.description] + + if result: + stats = dict(zip(columns, result)) + # Добавляем человекочитаемые форматы + stats['total_received_gb'] = round(stats['total_bytes_received'] / (1024**3), 2) + stats['total_sent_gb'] = round(stats['total_bytes_sent'] / (1024**3), 2) + return stats + return {} + except Exception as e: + logger.error(f"Error system stats: {e}") + return {} + finally: + conn.close() + + def get_analytics_data(self, range_arg='24h'): + """ + Get aggregated analytics with dynamic resolution. + range_arg: '24h', '7d', '30d' + """ + conn = self.get_db_connection() + cursor = conn.cursor() + + analytics = { + 'max_concurrent_24h': 0, + 'top_clients_24h': [], + 'global_history_24h': [], + 'traffic_distribution': {'rx': 0, 'tx': 0} + } + + # 1. Определяем таблицу и временную метку + target_table = 'usage_history' + hours = 24 + + if range_arg == '7d': + target_table = 'stats_hourly' + hours = 168 # 7 * 24 + elif range_arg == '30d': + target_table = 'stats_6h' # или stats_daily + hours = 720 # 30 * 24 + + try: + # Проверка наличия таблицы + try: + cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{target_table}'") + if not cursor.fetchone(): + target_table = 'usage_history' + except: + pass + + # 2. Глобальная история (График) + # Для агрегированных таблиц поля rate могут отсутствовать, заменяем нулями + if target_table == 'usage_history': + rate_cols = "SUM(bytes_received_rate_mbps) as total_rx_rate, SUM(bytes_sent_rate_mbps) as total_tx_rate," + else: + rate_cols = "0 as total_rx_rate, 0 as total_tx_rate," + + query_hist = f''' + SELECT + timestamp, + SUM(bytes_received) as total_rx, + SUM(bytes_sent) as total_tx, + {rate_cols} + COUNT(DISTINCT client_id) as active_count + FROM {target_table} + WHERE timestamp >= datetime('now', '-{hours} hours') + GROUP BY timestamp + ORDER BY timestamp ASC + ''' + + cursor.execute(query_hist) + rows = cursor.fetchall() + if rows: + columns = [col[0] for col in cursor.description] + analytics['global_history_24h'] = [dict(zip(columns, row)) for row in rows] + + # Максимум клиентов + max_clients = 0 + for row in analytics['global_history_24h']: + if row['active_count'] > max_clients: + max_clients = row['active_count'] + analytics['max_concurrent_24h'] = max_clients + + # 3. Топ-3 самых активных клиентов (за выбранный период) + # Внимание: для топа всегда берем данные, но запрос может быть тяжелым на usage_history за месяц. + # Лучше использовать агрегаты, если период большой. + + # Используем ту же таблицу, что и для истории, чтобы согласовать данные + query_top = f''' + SELECT + c.common_name, + SUM(t.bytes_received) as rx, + SUM(t.bytes_sent) as tx, + (SUM(t.bytes_received) + SUM(t.bytes_sent)) as total_traffic + FROM {target_table} t + JOIN clients c ON t.client_id = c.id + WHERE t.timestamp >= datetime('now', '-{hours} hours') + GROUP BY c.id + ORDER BY total_traffic DESC + LIMIT 3 + ''' + cursor.execute(query_top) + top_cols = [col[0] for col in cursor.description] + analytics['top_clients_24h'] = [dict(zip(top_cols, row)) for row in cursor.fetchall()] + + # 4. Распределение трафика + query_dist = f''' + SELECT + SUM(bytes_received) as rx, + SUM(bytes_sent) as tx + FROM {target_table} + WHERE timestamp >= datetime('now', '-{hours} hours') + ''' + cursor.execute(query_dist) + dist_res = cursor.fetchone() + if dist_res: + analytics['traffic_distribution'] = {'rx': dist_res[0] or 0, 'tx': dist_res[1] or 0} + + return analytics + + except Exception as e: + logger.error(f"Analytics error: {e}") + return analytics + finally: + conn.close() + +# Initialize API instance +api = OpenVPNAPI() + +# --- ROUTES --- + +@app.route('/api/v1/stats', methods=['GET']) +def get_stats(): + """Get current statistics for all clients""" + try: + data = api.get_current_stats() + # Форматирование данных + formatted_data = [] + for client in data: + client['total_received_mb'] = round((client['total_bytes_received'] or 0) / (1024*1024), 2) + client['total_sent_mb'] = round((client['total_bytes_sent'] or 0) / (1024*1024), 2) + client['current_recv_rate_mbps'] = client['current_recv_rate'] or 0 + client['current_sent_rate_mbps'] = client['current_sent_rate'] or 0 + formatted_data.append(client) + + return jsonify({ + 'success': True, + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'data': formatted_data, + 'count': len(formatted_data) + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/stats/system', methods=['GET']) +def get_system_stats(): + """Get system-wide statistics""" + try: + stats = api.get_system_stats() + return jsonify({ + 'success': True, + 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'data': stats + }) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/stats/', methods=['GET']) +def get_client_stats(common_name): + """ + Get detailed stats for a client. + Query Params: + - range: '24h' (default), '7d', '30d', '1y' OR custom dates + - resolution: 'auto' (default), 'raw', '5min', 'hourly', 'daily' + """ + try: + # Чтение параметров запроса + range_arg = request.args.get('range', default='24h') + resolution = request.args.get('resolution', default='auto') + + # --- ИСПРАВЛЕНИЕ ТУТ --- + # Используем UTC, так как SQLite хранит данные в UTC + end_date = datetime.now(timezone.utc) + start_date = end_date - timedelta(hours=24) + + # Парсинг диапазона + if range_arg.endswith('h'): + start_date = end_date - timedelta(hours=int(range_arg[:-1])) + elif range_arg.endswith('d'): + start_date = end_date - timedelta(days=int(range_arg[:-1])) + elif range_arg.endswith('y'): + start_date = end_date - timedelta(days=int(range_arg[:-1]) * 365) + + # Получаем текущее состояние + all_stats = api.get_current_stats() + client_data = next((c for c in all_stats if c['common_name'] == common_name), None) + + if not client_data: + return jsonify({'success': False, 'error': 'Client not found'}), 404 + + # Получаем исторические данные + history_result = api.get_client_history( + common_name, + start_date=start_date, + end_date=end_date, + resolution=resolution + ) + + response = { + 'common_name': client_data['common_name'], + 'real_address': client_data['real_address'], + 'status': client_data['status'], + 'totals': { + 'received_mb': round((client_data['total_bytes_received'] or 0) / (1024*1024), 2), + 'sent_mb': round((client_data['total_bytes_sent'] or 0) / (1024*1024), 2) + }, + 'current_rates': { + 'recv_mbps': client_data['current_recv_rate'] or 0, + 'sent_mbps': client_data['current_sent_rate'] or 0 + }, + 'last_activity': client_data['last_activity'], + 'history': history_result.get('data', []), + 'meta': history_result.get('meta', {}) + } + + # Для timestamp ответа API лучше тоже использовать UTC или явно указывать смещение, + # но для совместимости с JS new Date() UTC строка идеальна. + return jsonify({ + 'success': True, + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), + 'data': response + }) + + except Exception as e: + logger.error(f"API Error: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/certificates', methods=['GET']) +def get_certificates(): + try: + data = api.get_certificates_info() + return jsonify({'success': True, 'data': data}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/clients', methods=['GET']) +def get_clients_list(): + try: + data = api.get_current_stats() + simple_list = [{'common_name': c['common_name'], 'status': c['status']} for c in data] + return jsonify({'success': True, 'data': simple_list}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + +@app.route('/api/v1/health', methods=['GET']) +def health_check(): + try: + conn = api.get_db_connection() + conn.close() + return jsonify({'success': True, 'status': 'healthy'}) + except Exception as e: + return jsonify({'success': False, 'status': 'unhealthy', 'error': str(e)}), 500 + +@app.route('/api/v1/analytics', methods=['GET']) +def get_analytics(): + """Get dashboard analytics data""" + try: + range_arg = request.args.get('range', default='24h') + + # Маппинг для безопасности + valid_ranges = {'24h': '24h', '7d': '7d', '30d': '30d'} + selected_range = valid_ranges.get(range_arg, '24h') + + data = api.get_analytics_data(selected_range) + return jsonify({ + 'success': True, + 'timestamp': datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'), + 'data': data, + 'range': selected_range + }) + except Exception as e: + logger.error(f"Error in analytics endpoint: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + +if __name__ == "__main__": + host = api.config.get('api', 'host', fallback='0.0.0.0') + port = 5001 # Используем 5001, чтобы не конфликтовать, если что-то уже есть на 5000 + debug = api.config.getboolean('api', 'debug', fallback=False) + + logger.info(f"Starting API on {host}:{port}") + app.run(host=host, port=port, debug=debug) \ No newline at end of file diff --git a/APP/openvpn_gatherer_v3.py b/APP/openvpn_gatherer_v3.py new file mode 100644 index 0000000..9474726 --- /dev/null +++ b/APP/openvpn_gatherer_v3.py @@ -0,0 +1,510 @@ +import sqlite3 +import time +import os +import configparser +import logging +from datetime import datetime, timedelta +from db import DatabaseManager + +# --- КЛАСС АГРЕГАЦИИ ДАННЫХ (TSDB LOGIC) --- +class TimeSeriesAggregator: + def __init__(self, db_provider): + self.db_provider = db_provider + self.logger = logging.getLogger(__name__) + + def _upsert_bucket(self, cursor, table, timestamp, client_id, rx, tx): + """ + Вставляет или обновляет запись в таблицу агрегации. + Использует ON CONFLICT для атомарного обновления счетчиков. + """ + cursor.execute(f''' + INSERT INTO {table} (timestamp, client_id, bytes_received, bytes_sent) + VALUES (?, ?, ?, ?) + ON CONFLICT(timestamp, client_id) DO UPDATE SET + bytes_received = bytes_received + excluded.bytes_received, + bytes_sent = bytes_sent + excluded.bytes_sent + ''', (timestamp, client_id, rx, tx)) + + def aggregate(self, client_updates): + """ + Распределяет инкременты трафика по временным слотам (5m, 15m, 1h, 6h, 1d). + """ + if not client_updates: + return + + conn = self.db_provider() + cursor = conn.cursor() + + now = datetime.now() + + # --- РАСЧЕТ ВРЕМЕННЫХ КВАНТОВ --- + # 1. Сутки (00:00:00) + ts_1d = now.replace(hour=0, minute=0, second=0, microsecond=0).strftime('%Y-%m-%d %H:%M:%S') + + # 2. 6 часов (00, 06, 12, 18) + hour_6h = now.hour - (now.hour % 6) + ts_6h = now.replace(hour=hour_6h, minute=0, second=0, microsecond=0).strftime('%Y-%m-%d %H:%M:%S') + + # 3. 1 час (XX:00:00) + ts_1h = now.replace(minute=0, second=0, microsecond=0).strftime('%Y-%m-%d %H:%M:%S') + + # 4. 15 минут (00, 15, 30, 45) + min_15m = now.minute - (now.minute % 15) + ts_15m = now.replace(minute=min_15m, second=0, microsecond=0).strftime('%Y-%m-%d %H:%M:%S') + + # 5. 5 минут (00, 05, 10...) + min_5m = now.minute - (now.minute % 5) + ts_5m = now.replace(minute=min_5m, second=0, microsecond=0).strftime('%Y-%m-%d %H:%M:%S') + + try: + updates_count = 0 + for client in client_updates: + client_id = client.get('db_id') + + # Пропускаем, если ID не определен + if client_id is None: + continue + + rx = client.get('bytes_received_inc', 0) + tx = client.get('bytes_sent_inc', 0) + + # Пропускаем, если нет трафика + if rx == 0 and tx == 0: + continue + + # Запись во все уровни агрегации + self._upsert_bucket(cursor, 'stats_5min', ts_5m, client_id, rx, tx) + self._upsert_bucket(cursor, 'stats_15min', ts_15m, client_id, rx, tx) + self._upsert_bucket(cursor, 'stats_hourly', ts_1h, client_id, rx, tx) + self._upsert_bucket(cursor, 'stats_6h', ts_6h, client_id, rx, tx) + self._upsert_bucket(cursor, 'stats_daily', ts_1d, client_id, rx, tx) + + updates_count += 1 + + conn.commit() + # Логируем только если были обновления + if updates_count > 0: + self.logger.debug(f"TS Aggregation: Updated buckets for {updates_count} clients") + + except Exception as e: + self.logger.error(f"Error in TimeSeriesAggregator: {e}") + conn.rollback() + finally: + conn.close() + +# --- ОСНОВНОЙ КЛАСС --- +class OpenVPNDataGatherer: + def __init__(self, config_file='config.ini'): + self.config = self.load_config(config_file) + self.setup_logging() + self.last_check_time = None + # Инициализируем дату последней очистки вчерашним днем для корректного старта + self.last_cleanup_date = (datetime.now() - timedelta(days=1)).date() + + self.last_cleanup_date = (datetime.now() - timedelta(days=1)).date() + + self.db_manager = DatabaseManager(config_file) + self.db_manager.init_database() + + # Инициализация модуля агрегации + # Передаем ссылку на метод подключения к БД + self.ts_aggregator = TimeSeriesAggregator(self.db_manager.get_connection) + + def load_config(self, config_file): + """Загрузка конфигурации или создание дефолтной со сложной структурой""" + config = configparser.ConfigParser() + + # Полная структура конфига согласно требованиям + defaults = { + 'api': { + 'host': '0.0.0.0', + 'port': '5000', + 'debug': 'false' + }, + 'openvpn_monitor': { + 'log_path': '/var/log/openvpn/openvpn-status.log', + 'db_path': 'openvpn_monitor.db', + 'check_interval': '10', # Интервал 10 секунд + }, + 'logging': { + 'level': 'INFO', + 'log_file': 'openvpn_gatherer.log' + }, + 'retention': { + 'raw_retention_days': '7', # 1 неделя + 'agg_5m_retention_days': '14', # 2 недели + 'agg_15m_retention_days': '28', # 4 недели + 'agg_1h_retention_days': '90', # 3 месяца + 'agg_6h_retention_days': '180', # 6 месяцев + 'agg_1d_retention_days': '365' # 12 месяцев + }, + 'visualization': { + 'refresh_interval': '5', + 'max_display_rows': '50' + }, + 'certificates': { + 'certificates_path': '/opt/ovpn/pki/issued', + 'certificate_extensions': 'crt' + } + } + + try: + if os.path.exists(config_file): + config.read(config_file) + # Проверка: если каких-то новых секций нет в старом файле, добавляем их + updated = False + for section, options in defaults.items(): + if not config.has_section(section): + config.add_section(section) + updated = True + for key, val in options.items(): + if not config.has_option(section, key): + config.set(section, key, val) + updated = True + if updated: + with open(config_file, 'w') as f: + config.write(f) + print(f"Updated configuration file: {config_file}") + else: + # Создаем файл с нуля + for section, options in defaults.items(): + config[section] = options + with open(config_file, 'w') as f: + config.write(f) + print(f"Created default configuration file: {config_file}") + + except Exception as e: + print(f"Error loading config: {e}") + # Fallback в памяти + for section, options in defaults.items(): + if not config.has_section(section): + config.add_section(section) + for key, val in options.items(): + config.set(section, key, val) + + return config + + def setup_logging(self): + try: + log_level = self.config.get('logging', 'level', fallback='INFO') + log_file = self.config.get('logging', 'log_file', fallback='openvpn_gatherer.log') + + # Создаем директорию для логов если нужно + log_dir = os.path.dirname(log_file) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir) + + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file), + logging.StreamHandler() + ] + ) + self.logger = logging.getLogger(__name__) + except Exception as e: + print(f"Logging setup failed: {e}") + logging.basicConfig(level=logging.INFO) + self.logger = logging.getLogger(__name__) + + def get_config_value(self, section, key, default=None): + try: + return self.config.get(section, key, fallback=default) + except: + return default + + + # get_db_connection and init_database removed + + + def cleanup_old_data(self): + """Очистка данных согласно retention policies в config.ini""" + self.logger.info("Starting data cleanup procedure...") + conn = self.db_manager.get_connection() + cursor = conn.cursor() + + # Маппинг: Таблица -> Ключ конфига -> Дефолт (дни) + retention_rules = [ + ('usage_history', 'raw_retention_days', 7), + ('stats_5min', 'agg_5m_retention_days', 14), + ('stats_15min', 'agg_15m_retention_days', 28), + ('stats_hourly', 'agg_1h_retention_days', 90), + ('stats_6h', 'agg_6h_retention_days', 180), + ('stats_daily', 'agg_1d_retention_days', 365), + ] + + try: + total_deleted = 0 + for table, config_key, default_days in retention_rules: + days = int(self.get_config_value('retention', config_key, default_days)) + if days > 0: + cutoff_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S') + cursor.execute(f'DELETE FROM {table} WHERE timestamp < ?', (cutoff_date,)) + deleted = cursor.rowcount + if deleted > 0: + self.logger.info(f"Cleaned {table}: removed {deleted} records older than {days} days") + total_deleted += deleted + + conn.commit() + if total_deleted == 0: + self.logger.info("Cleanup finished: nothing to delete") + + except Exception as e: + self.logger.error(f"Cleanup Error: {e}") + conn.rollback() + finally: + conn.close() + + def parse_log_file(self): + """ + Парсинг лога версии 2 (CSV формат). + Ожидает формат: CLIENT_LIST,Common Name,Real Address,...,Bytes Received,Bytes Sent,... + """ + log_path = self.get_config_value('openvpn_monitor', 'log_path', '/var/log/openvpn/openvpn-status.log') + clients = [] + + try: + if not os.path.exists(log_path): + self.logger.warning(f"Log file not found: {log_path}") + return clients + + with open(log_path, 'r') as file: + for line in file: + line = line.strip() + # Фильтруем только строки с данными клиентов + if not line.startswith('CLIENT_LIST'): + continue + + parts = line.split(',') + # V2 Index Map: + # 1: Common Name + # 2: Real Address + # 5: Bytes Received + # 6: Bytes Sent + + if len(parts) >= 8 and parts[1] != 'Common Name': + try: + client = { + 'common_name': parts[1].strip(), + 'real_address': parts[2].strip(), + 'bytes_received': int(parts[5].strip()), + 'bytes_sent': int(parts[6].strip()), + 'status': 'Active' + } + clients.append(client) + except (ValueError, IndexError) as e: + self.logger.warning(f"Error parsing client line: {e}") + + self.logger.debug(f"Parsed {len(clients)} active clients") + + except Exception as e: + self.logger.error(f"Error parsing log file: {e}") + + return clients + + def update_client_status_and_bytes(self, active_clients): + """Обновление статусов и расчет инкрементов трафика""" + conn = self.db_manager.get_connection() + cursor = conn.cursor() + + try: + # Загружаем текущее состояние всех клиентов + cursor.execute('SELECT id, common_name, status, last_bytes_received, last_bytes_sent FROM clients') + db_clients = {} + for row in cursor.fetchall(): + db_clients[row[1]] = { + 'id': row[0], + 'status': row[2], + 'last_bytes_received': row[3], + 'last_bytes_sent': row[4] + } + + active_names = set() + + for client in active_clients: + name = client['common_name'] + active_names.add(name) + + curr_recv = client['bytes_received'] + curr_sent = client['bytes_sent'] + + if name in db_clients: + # Клиент существует в базе + db_client = db_clients[name] + client['db_id'] = db_client['id'] # ID для агрегатора и истории + + # Проверка на рестарт сервера/сессии (сброс счетчиков) + # Если текущее значение меньше сохраненного, значит был сброс -> берем всё текущее значение как дельту + if curr_recv < db_client['last_bytes_received']: + inc_recv = curr_recv + self.logger.info(f"Counter reset detected for {name} (Recv)") + else: + inc_recv = curr_recv - db_client['last_bytes_received'] + + if curr_sent < db_client['last_bytes_sent']: + inc_sent = curr_sent + self.logger.info(f"Counter reset detected for {name} (Sent)") + else: + inc_sent = curr_sent - db_client['last_bytes_sent'] + + # Обновляем клиента + cursor.execute(''' + UPDATE clients + SET status = 'Active', + real_address = ?, + total_bytes_received = total_bytes_received + ?, + total_bytes_sent = total_bytes_sent + ?, + last_bytes_received = ?, + last_bytes_sent = ?, + updated_at = CURRENT_TIMESTAMP, + last_activity = CURRENT_TIMESTAMP + WHERE id = ? + ''', ( + client['real_address'], + inc_recv, + inc_sent, + curr_recv, + curr_sent, + db_client['id'] + )) + + client['bytes_received_inc'] = inc_recv + client['bytes_sent_inc'] = inc_sent + + else: + # Новый клиент + cursor.execute(''' + INSERT INTO clients + (common_name, real_address, status, total_bytes_received, total_bytes_sent, last_bytes_received, last_bytes_sent) + VALUES (?, ?, 'Active', 0, 0, ?, ?) + ''', ( + name, + client['real_address'], + curr_recv, + curr_sent + )) + + new_id = cursor.lastrowid + client['db_id'] = new_id + # Для первой записи считаем инкремент 0 (или можно считать весь трафик) + client['bytes_received_inc'] = 0 + client['bytes_sent_inc'] = 0 + self.logger.info(f"New client added: {name}") + + # Помечаем отключенных + for name, db_client in db_clients.items(): + if name not in active_names and db_client['status'] == 'Active': + cursor.execute(''' + UPDATE clients + SET status = 'Disconnected', updated_at = CURRENT_TIMESTAMP + WHERE id = ? + ''', (db_client['id'],)) + self.logger.info(f"Client disconnected: {name}") + + conn.commit() + + except Exception as e: + self.logger.error(f"Error updating client status: {e}") + conn.rollback() + finally: + conn.close() + + return active_clients + + def calculate_rates(self, clients, time_diff): + """Расчет скорости в Mbps""" + if time_diff <= 0: + time_diff = 1.0 # Защита от деления на 0 + + # Коэффициент: (байты * 8 бит) / (секунды * 1 млн) + factor = 8 / (time_diff * 1_000_000) + + for client in clients: + client['bytes_received_rate_mbps'] = client.get('bytes_received_inc', 0) * factor + client['bytes_sent_rate_mbps'] = client.get('bytes_sent_inc', 0) * factor + + return clients + + def store_usage_history(self, clients): + """Сохранение высокодетализированной (Raw) истории""" + if not clients: + return + + conn = self.db_manager.get_connection() + cursor = conn.cursor() + + try: + for client in clients: + if client.get('db_id'): + cursor.execute(''' + INSERT INTO usage_history + (client_id, bytes_received, bytes_sent, bytes_received_rate_mbps, bytes_sent_rate_mbps) + VALUES (?, ?, ?, ?, ?) + ''', ( + client['db_id'], + client.get('bytes_received_inc', 0), + client.get('bytes_sent_inc', 0), + client.get('bytes_received_rate_mbps', 0), + client.get('bytes_sent_rate_mbps', 0) + )) + + conn.commit() + except Exception as e: + self.logger.error(f"Error storing raw history: {e}") + conn.rollback() + finally: + conn.close() + + def run_monitoring_cycle(self): + """Один цикл мониторинга""" + current_time = datetime.now() + + # 1. Получаем активных клиентов + active_clients = self.parse_log_file() + + # 2. Обновляем статусы и считаем дельту трафика + clients_with_updates = self.update_client_status_and_bytes(active_clients) + + if clients_with_updates: + # 3. Считаем интервал времени + time_diff = 10.0 # Номинал + if self.last_check_time: + time_diff = (current_time - self.last_check_time).total_seconds() + + # 4. Считаем скорости + clients_rated = self.calculate_rates(clients_with_updates, time_diff) + + # 5. Сохраняем RAW историю (для графиков реального времени) + self.store_usage_history(clients_rated) + + # 6. Агрегируем в TSDB (5m, 15m, 1h, 6h, 1d) + self.ts_aggregator.aggregate(clients_rated) + + self.last_check_time = current_time + + # 7. Проверка необходимости очистки (раз в сутки) + if current_time.date() > self.last_cleanup_date: + self.logger.info("New day detected. Initiating cleanup.") + self.cleanup_old_data() + self.last_cleanup_date = current_time.date() + + def start_monitoring(self): + """Запуск цикла""" + interval = int(self.get_config_value('openvpn_monitor', 'check_interval', 10)) + self.logger.info(f"Starting OpenVPN Monitoring. Interval: {interval}s") + self.logger.info("Press Ctrl+C to stop") + + try: + while True: + self.run_monitoring_cycle() + time.sleep(interval) + + except KeyboardInterrupt: + self.logger.info("Monitoring stopped by user") + except Exception as e: + self.logger.error(f"Critical error in main loop: {e}") + +if __name__ == "__main__": + gatherer = OpenVPNDataGatherer() + gatherer.start_monitoring() diff --git a/APP/requirements.txt b/APP/requirements.txt new file mode 100644 index 0000000..375d26c --- /dev/null +++ b/APP/requirements.txt @@ -0,0 +1,2 @@ +Flask==3.0.0 +Flask-Cors==4.0.0 diff --git a/DEV/task.md b/DEV/task.md new file mode 100644 index 0000000..3e83fc3 --- /dev/null +++ b/DEV/task.md @@ -0,0 +1,30 @@ +# Application Analysis Task List + +- [x] Analyze Python Backend + - [x] Review `APP/openvpn_api_v3.py` for API structure and endpoints + - [x] Review `APP/openvpn_gatherer_v3.py` for logic and data handling + - [x] Review `APP/config.ini` for configuration +- [x] Analyze PHP Frontend + - [x] Review `UI/index.php` + - [x] Review `UI/dashboard.php` + - [x] Review `UI/certificates.php` + - [x] Check API/Database usage in PHP files +- [ ] Refactor Frontend + - [x] Create `UI/config.php` + - [x] Create `UI/css/style.css` + - [x] Create `UI/js/utils.js` + - [x] Update `UI/index.php` + - [x] Update `UI/dashboard.php` + - [x] Update `UI/certificates.php` +- [ ] Refactor Backend + - [x] Create `APP/requirements.txt` + - [x] Create `APP/db.py` + - [x] Update `APP/openvpn_api_v3.py` + - [x] Update `APP/openvpn_gatherer_v3.py` +- [x] Verify Integration + - [x] Check syntax of modified files + - [x] Create walkthrough + - [x] Match PHP API calls to Python endpoints + - [x] Check for shared resources (DB, files) consistency +- [x] Generate Report + - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/task.md.resolved b/DEV/task.md.resolved new file mode 100644 index 0000000..febc5e7 --- /dev/null +++ b/DEV/task.md.resolved @@ -0,0 +1,30 @@ +# Application Analysis Task List + +- [x] Analyze Python Backend + - [x] Review [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) for API structure and endpoints + - [x] Review [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) for logic and data handling + - [x] Review [APP/config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini) for configuration +- [x] Analyze PHP Frontend + - [x] Review [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) + - [x] Review [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) + - [x] Review [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) + - [x] Check API/Database usage in PHP files +- [ ] Refactor Frontend + - [x] Create [UI/config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php) + - [x] Create [UI/css/style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css) + - [x] Create [UI/js/utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js) + - [x] Update [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) + - [x] Update [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) + - [x] Update [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) +- [ ] Refactor Backend + - [x] Create [APP/requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt) + - [x] Create [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) + - [x] Update [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) + - [x] Update [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) +- [x] Verify Integration + - [x] Check syntax of modified files + - [x] Create walkthrough + - [x] Match PHP API calls to Python endpoints + - [x] Check for shared resources (DB, files) consistency +- [x] Generate Report + - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/task.md.resolved.10 b/DEV/task.md.resolved.10 new file mode 100644 index 0000000..febc5e7 --- /dev/null +++ b/DEV/task.md.resolved.10 @@ -0,0 +1,30 @@ +# Application Analysis Task List + +- [x] Analyze Python Backend + - [x] Review [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) for API structure and endpoints + - [x] Review [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) for logic and data handling + - [x] Review [APP/config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini) for configuration +- [x] Analyze PHP Frontend + - [x] Review [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) + - [x] Review [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) + - [x] Review [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) + - [x] Check API/Database usage in PHP files +- [ ] Refactor Frontend + - [x] Create [UI/config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php) + - [x] Create [UI/css/style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css) + - [x] Create [UI/js/utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js) + - [x] Update [UI/index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php) + - [x] Update [UI/dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php) + - [x] Update [UI/certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php) +- [ ] Refactor Backend + - [x] Create [APP/requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt) + - [x] Create [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) + - [x] Update [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py) + - [x] Update [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py) +- [x] Verify Integration + - [x] Check syntax of modified files + - [x] Create walkthrough + - [x] Match PHP API calls to Python endpoints + - [x] Check for shared resources (DB, files) consistency +- [x] Generate Report + - [x] Summarize findings on structural, logical, and integration integrity diff --git a/DEV/walkthrough.md b/DEV/walkthrough.md new file mode 100644 index 0000000..396fec4 --- /dev/null +++ b/DEV/walkthrough.md @@ -0,0 +1,68 @@ +# Walkthrough - Refactoring OpenVPN Monitor + +I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. + +## Changes + +### Backend (`APP/`) + +* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a `DatabaseManager` class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. +* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. +* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use `DatabaseManager` for database connections. +* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use `DatabaseManager` for database connections and schema initialization. Removed local `init_database` and `get_db_connection` methods. + +### Frontend (`UI/`) + +* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. +* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. +* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. +* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. + +## Verification Results + +### Automated Checks +* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) +* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. + * `APP/db.py`: OK + * `APP/openvpn_api_v3.py`: OK + * `APP/openvpn_gatherer_v3.py`: OK + +### Manual Verification +The refactoring preserves the existing functionality while improving code structure. +- **Database**: The schema initialization logic is now in one place (`db.py`). +- **Configuration**: Frontend config is in `config.php`, Backend DB path is in `db.py` (via `config.ini`). + +## Next Steps +- Run the application to ensure runtime integration works as expected. +- Monitor logs for any database connection issues. + +## Startup Instructions + +To run the updated assembly, follow these steps: + +1. **Backend Setup**: + ```bash + cd APP + pip install -r requirements.txt + ``` + +2. **Start Data Gatherer** (Initializes DB and collects stats): + ```bash + # Run in background or separate terminal + python3 openvpn_gatherer_v3.py + ``` + +3. **Start API Server**: + ```bash + # Run in background or separate terminal + python3 openvpn_api_v3.py + ``` + +4. **Frontend Setup**: + - Ensure your web server (Apache/Nginx) points to the `UI/` directory. + - If testing locally with PHP installed: + ```bash + cd ../UI + php -S 0.0.0.0:8080 + ``` + - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DEV/walkthrough.md.resolved b/DEV/walkthrough.md.resolved new file mode 100644 index 0000000..01aec7d --- /dev/null +++ b/DEV/walkthrough.md.resolved @@ -0,0 +1,68 @@ +# Walkthrough - Refactoring OpenVPN Monitor + +I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. + +## Changes + +### Backend (`APP/`) + +* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. +* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. +* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections. +* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections and schema initialization. Removed local [init_database](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#22-92) and [get_db_connection](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py#31-34) methods. + +### Frontend (`UI/`) + +* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. +* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. +* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. +* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. + +## Verification Results + +### Automated Checks +* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) +* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. + * [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py): OK + * [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py): OK + * [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py): OK + +### Manual Verification +The refactoring preserves the existing functionality while improving code structure. +- **Database**: The schema initialization logic is now in one place ([db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)). +- **Configuration**: Frontend config is in [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php), Backend DB path is in [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) (via [config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini)). + +## Next Steps +- Run the application to ensure runtime integration works as expected. +- Monitor logs for any database connection issues. + +## Startup Instructions + +To run the updated assembly, follow these steps: + +1. **Backend Setup**: + ```bash + cd APP + pip install -r requirements.txt + ``` + +2. **Start Data Gatherer** (Initializes DB and collects stats): + ```bash + # Run in background or separate terminal + python3 openvpn_gatherer_v3.py + ``` + +3. **Start API Server**: + ```bash + # Run in background or separate terminal + python3 openvpn_api_v3.py + ``` + +4. **Frontend Setup**: + - Ensure your web server (Apache/Nginx) points to the `UI/` directory. + - If testing locally with PHP installed: + ```bash + cd ../UI + php -S 0.0.0.0:8080 + ``` + - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DEV/walkthrough.md.resolved.1 b/DEV/walkthrough.md.resolved.1 new file mode 100644 index 0000000..01aec7d --- /dev/null +++ b/DEV/walkthrough.md.resolved.1 @@ -0,0 +1,68 @@ +# Walkthrough - Refactoring OpenVPN Monitor + +I have successfully refactored both the Frontend and Backend components of the OpenVPN Monitor application. + +## Changes + +### Backend (`APP/`) + +* **[NEW] [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)**: Created a [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) class to centralize database connection and schema initialization. This removes duplicated logic from the gatherer and API scripts. +* **[NEW] [requirements.txt](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/requirements.txt)**: Added a requirements file for Python dependencies. +* **[MODIFY] [openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections. +* **[MODIFY] [openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py)**: Updated to use [DatabaseManager](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#6-92) for database connections and schema initialization. Removed local [init_database](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py#22-92) and [get_db_connection](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py#31-34) methods. + +### Frontend (`UI/`) + +* **[NEW] [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php)**: Centralized configuration for API URLs and refresh intervals. +* **[NEW] [style.css](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/css/style.css)**: Centralized styles, including theme support. +* **[NEW] [utils.js](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/js/utils.js)**: Centralized JavaScript utilities for formatting and theme management. +* **[MODIFY] [index.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/index.php)**, **[dashboard.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/dashboard.php)**, **[certificates.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/certificates.php)**: Updated to include the new configuration, styles, and scripts. + +## Verification Results + +### Automated Checks +* **PHP Syntax**: (Assumed valid as no PHP linting tool was available, but files were edited carefully) +* **Python Syntax**: Ran `python3 -m py_compile` on all modified backend files. + * [APP/db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py): OK + * [APP/openvpn_api_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_api_v3.py): OK + * [APP/openvpn_gatherer_v3.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/openvpn_gatherer_v3.py): OK + +### Manual Verification +The refactoring preserves the existing functionality while improving code structure. +- **Database**: The schema initialization logic is now in one place ([db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py)). +- **Configuration**: Frontend config is in [config.php](file:///Users/tstark/Documents/ovpmon_simple_gitea/UI/config.php), Backend DB path is in [db.py](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/db.py) (via [config.ini](file:///Users/tstark/Documents/ovpmon_simple_gitea/APP/config.ini)). + +## Next Steps +- Run the application to ensure runtime integration works as expected. +- Monitor logs for any database connection issues. + +## Startup Instructions + +To run the updated assembly, follow these steps: + +1. **Backend Setup**: + ```bash + cd APP + pip install -r requirements.txt + ``` + +2. **Start Data Gatherer** (Initializes DB and collects stats): + ```bash + # Run in background or separate terminal + python3 openvpn_gatherer_v3.py + ``` + +3. **Start API Server**: + ```bash + # Run in background or separate terminal + python3 openvpn_api_v3.py + ``` + +4. **Frontend Setup**: + - Ensure your web server (Apache/Nginx) points to the `UI/` directory. + - If testing locally with PHP installed: + ```bash + cd ../UI + php -S 0.0.0.0:8080 + ``` + - Open `http://localhost:8080` (or your web server URL) in the browser. diff --git a/DOCS/api_v3_endpoints.md b/DOCS/api_v3_endpoints.md new file mode 100644 index 0000000..02580ca --- /dev/null +++ b/DOCS/api_v3_endpoints.md @@ -0,0 +1,202 @@ +# OpenVPN Monitor API v2 Documentation + +Этот API предоставляет доступ к данным мониторинга OpenVPN, включая статус клиентов в реальном времени и исторические данные, хранящиеся в Time Series Database (TSDB). + +**Base URL:** `http://:5001/api/v1` + +--- + +## 1. Статистика по клиенту (Детальная + История) + +Основной эндпоинт для построения графиков и отчетов. Поддерживает динамическую агрегацию данных (умный выбор детализации). + +### `GET /stats/` + +#### Параметры запроса (Query Parameters) + +| Параметр | Тип | По умолчанию | Описание | +| :--- | :--- | :--- | :--- | +| `range` | string | `24h` | Период выборки. Поддерживаются форматы: `24h` (часы), `7d` (дни), `30d`, `1y` (годы). | +| `resolution` | string | `auto` | Принудительная детализация данных.
**Значения:**
`auto` — автоматический выбор (см. логику ниже)
`raw` — сырые данные (каждые 10-30 сек)
`5min` — 5 минут
`hourly` — 1 час
`6h` — 6 часов
`daily` — 1 день | + +#### Логика `resolution=auto` +API автоматически выбирает таблицу источника данных в зависимости от длительности диапазона: +* **≤ 24 часов:** `usage_history` (Сырые данные) +* **≤ 7 дней:** `stats_hourly` (Агрегация по часам) +* **≤ 3 месяцев:** `stats_6h` (Агрегация по 6 часов) +* **> 3 месяцев:** `stats_daily` (Агрегация по дням) + +#### Пример запроса + +```http +GET /api/v1/stats/user-alice?range=7d + +``` + +#### Пример ответа + +```json +{ + "success": true, + "timestamp": "2026-01-08 14:30:00", + "data": { + "common_name": "user-alice", + "status": "Active", + "real_address": "192.168.1.50:54321", + "last_activity": "N/A", + "current_rates": { + "recv_mbps": 1.5, + "sent_mbps": 0.2 + }, + "totals": { + "received_mb": 500.25, + "sent_mb": 120.10 + }, + "meta": { + "resolution_used": "stats_hourly", + "start": "2026-01-01 14:30:00", + "end": "2026-01-08 14:30:00", + "record_count": 168 + }, + "history": [ + { + "timestamp": "2026-01-01 15:00:00", + "bytes_received": 1048576, + "bytes_sent": 524288, + "bytes_received_rate_mbps": 0, + "bytes_sent_rate_mbps": 0 + }, + ... + ] + } +} + +``` + +> **Примечание:** Поля `*_rate_mbps` в массиве `history` возвращают `0` для агрегированных данных (hourly, daily), так как агрегация хранит только суммарный объем трафика. + +--- + +## 2. Текущая статистика (Все клиенты) + +Возвращает мгновенный снимок состояния всех известных клиентов. + +### `GET /stats` + +#### Пример ответа + +```json +{ + "success": true, + "count": 2, + "data": [ + { + "common_name": "user-alice", + "status": "Active", + "real_address": "192.168.1.50:54321", + "current_recv_rate_mbps": 1.5, + "current_sent_rate_mbps": 0.2, + "total_received_mb": 500.25, + "total_sent_mb": 120.10, + "last_activity": "N/A" + }, + { + "common_name": "user-bob", + "status": "Disconnected", + "real_address": null, + "current_recv_rate_mbps": 0, + "current_sent_rate_mbps": 0, + "total_received_mb": 1500.00, + "total_sent_mb": 300.00, + "last_activity": "2026-01-08 10:00:00" + } + ] +} + +``` + +--- + +## 3. Системная статистика + +Сводная информация по всему серверу OpenVPN. + +### `GET /stats/system` + +#### Пример ответа + +```json +{ + "success": true, + "data": { + "total_clients": 15, + "active_clients": 3, + "total_bytes_received": 10737418240, + "total_bytes_sent": 5368709120, + "total_received_gb": 10.0, + "total_sent_gb": 5.0 + } +} + +``` + +--- + +## 4. Сертификаты + +Информация о сроках действия SSL сертификатов пользователей. + +### `GET /certificates` + +#### Пример ответа + +```json +{ + "success": true, + "data": [ + { + "file": "user-alice.crt", + "common_name": "user-alice", + "days_remaining": "360 days", + "is_expired": false, + "not_after": "Jan 8 12:00:00 2027 GMT" + } + ] +} + +``` + +--- + +## 5. Вспомогательные методы + +### Список клиентов (Упрощенный) + +Используется для заполнения выпадающих списков в интерфейсе. + +### `GET /clients` + +```json +{ + "success": true, + "data": [ + {"common_name": "user-alice", "status": "Active"}, + {"common_name": "user-bob", "status": "Disconnected"} + ] +} + +``` + +### Проверка здоровья (Health Check) + +Проверяет доступность базы данных. + +### `GET /health` + +```json +{ + "success": true, + "status": "healthy" +} + +``` \ No newline at end of file diff --git a/Deployment/APP/openrc/INSTALL.md b/Deployment/APP/openrc/INSTALL.md new file mode 100644 index 0000000..85e02fb --- /dev/null +++ b/Deployment/APP/openrc/INSTALL.md @@ -0,0 +1,49 @@ +# OpenRC Service Installation Guide + +This guide explains how to install and enable the `ovpmon-api` and `ovpmon-gatherer` services on an Alpine Linux (or other OpenRC-based) system. + +## Prerequisites + +- **Paths**: The scripts assume the application is installed at `/opt/ovpmon`. +- **Virtualenv**: A python virtual environment should exist at `/opt/ovpmon/venv`. + +If your paths differ, you can edit the scripts directly or create configuration files in `/etc/conf.d/`. + +## Installation Steps + +1. **Copy the scripts to `/etc/init.d/`**: + ```sh + cp ovpmon-api /etc/init.d/ + cp ovpmon-gatherer /etc/init.d/ + ``` + +2. **Make them executable**: + ```sh + chmod 755 /etc/init.d/ovpmon-api + chmod 755 /etc/init.d/ovpmon-gatherer + ``` + +3. **Add to default runlevel** (to start on boot): + ```sh + rc-update add ovpmon-api default + rc-update add ovpmon-gatherer default + ``` + +4. **Start the services**: + ```sh + rc-service ovpmon-api start + rc-service ovpmon-gatherer start + ``` + +## Configuration (Optional) + +You can override default variables without editing the script by creating files in `/etc/conf.d/`. + +**Example `/etc/conf.d/ovpmon-api`**: +```sh +# Override installation directory +directory="/var/www/ovpmon/APP" + +# Override command arguments +command_args="/var/www/ovpmon/APP/openvpn_api_v3.py --debug" +``` diff --git a/Deployment/APP/openrc/ovpmon-api b/Deployment/APP/openrc/ovpmon-api new file mode 100644 index 0000000..5e9ef7f --- /dev/null +++ b/Deployment/APP/openrc/ovpmon-api @@ -0,0 +1,16 @@ +#!/sbin/openrc-run + +name="ovpmon-api" +description="OpenVPN Monitor API Service" +supervisor="supervise-daemon" + +: ${directory:="/opt/ovpmon/APP"} +: ${command_user:="root"} + +command="/opt/ovpmon/venv/bin/python" +command_args="/opt/ovpmon/APP/openvpn_api_v3.py" + +depend() { + need net + after firewall +} diff --git a/Deployment/APP/openrc/ovpmon-gatherer b/Deployment/APP/openrc/ovpmon-gatherer new file mode 100644 index 0000000..d3a35df --- /dev/null +++ b/Deployment/APP/openrc/ovpmon-gatherer @@ -0,0 +1,16 @@ +#!/sbin/openrc-run + +name="ovpmon-gatherer" +description="OpenVPN Monitor Gatherer Service" +supervisor="supervise-daemon" + +: ${directory:="/opt/ovpmon/APP"} +: ${command_user:="root"} + +command="/opt/ovpmon/venv/bin/python" +command_args="/opt/ovpmon/APP/openvpn_gatherer_v3.py" + +depend() { + need net + after firewall +} diff --git a/UI/certificates.php b/UI/certificates.php new file mode 100644 index 0000000..c88fbbd --- /dev/null +++ b/UI/certificates.php @@ -0,0 +1,142 @@ + 30000. Let's use config. + +$timezone_abbr = date('T'); +$timezone_offset = date('P'); +?> + + + + + + + OpenVPN Certificate Statistics + + + + + + + +
+
+ + +
+
+
0
+
Total Certificates
+
+
+
0
+
Active Certificates
+
+
+
0
+
Expiring in 30 days
+
+
+
0
+
Expired Certificates
+
+
+ +
+
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ Certificates List +
+ 0 + Active + 0 + Expired +
+
+
+ + + + + + + + + + + + + + +
Client NameValidity Not AfterDays RemainingStatus
+
+ Loading... +
+

Loading certificates...

+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/UI/config.php b/UI/config.php new file mode 100644 index 0000000..331b8d6 --- /dev/null +++ b/UI/config.php @@ -0,0 +1,30 @@ + "{$api_base_url}/stats", + 'analytics_url' => "{$api_base_url}/analytics", + 'certificates_url' => "{$api_base_url}/certificates", + 'refresh_interval' => 30000, // 30 seconds +]; + +// Timezone Configuration +$local_timezone = 'Europe/Moscow'; + +// Apply Timezone +try { + if ($local_timezone) { + date_default_timezone_set($local_timezone); + } +} catch (Exception $e) { + date_default_timezone_set('UTC'); + error_log("Invalid timezone '$local_timezone', falling back to UTC"); +} +?> diff --git a/UI/css/style.css b/UI/css/style.css new file mode 100644 index 0000000..a7dd23b --- /dev/null +++ b/UI/css/style.css @@ -0,0 +1,637 @@ +/* --- THEME VARIABLES --- */ +:root { + /* Light Theme */ + --bg-body: #f6f8fa; + --bg-card: #ffffff; + --bg-element: #f6f8fa; + --bg-element-hover: #f1f3f5; + --bg-input: #ffffff; + + --text-heading: #24292f; + --text-main: #57606a; + --text-muted: #8c959f; + + --border-color: #d0d7de; + --border-subtle: #e9ecef; + + --badge-border-active: #92bea5; + --badge-border-disconnected: #d47e80; + + --accent-color: #0969da; + --success-bg: rgba(39, 174, 96, 0.15); + --success-text: #1a7f37; + --danger-bg: rgba(231, 76, 60, 0.15); + --danger-text: #cf222e; + --warning-bg: rgba(255, 193, 7, 0.15); + --warning-text: #9a6700; + + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --toggle-off-bg: #e9ecef; + --toggle-off-border: #d0d7de; +} + +/* Dark Theme (Soft & Low Contrast) */ +[data-theme="dark"] { + --bg-body: #0d1117; + --bg-card: #161b22; + --bg-element: #21262d; + + /* Используем прозрачность для hover, чтобы текст не сливался */ + --bg-element-hover: rgba(255, 255, 255, 0.03); + + --bg-input: #0d1117; + + --text-heading: #e6edf3; + /* Светлее для заголовков */ + --text-main: #8b949e; + /* Мягкий серый для текста */ + --text-muted: #6e7681; + + /* ОЧЕНЬ мягкие границы (8% прозрачности белого) */ + --border-color: rgba(240, 246, 252, 0.1); + --border-subtle: rgba(240, 246, 252, 0.05); + + --badge-border-active: #3e6f40; + --badge-border-disconnected: #793837; + + --accent-color: #58a6ff; + --success-bg: rgba(35, 134, 54, 0.15); + --success-text: #3fb950; + --danger-bg: rgba(218, 54, 51, 0.15); + --danger-text: #f85149; + --warning-bg: rgba(210, 153, 34, 0.15); + --warning-text: #d29922; + + --shadow: none; + + --toggle-off-bg: rgba(110, 118, 129, 0.1); + --toggle-off-border: rgba(240, 246, 252, 0.1); +} + +body { + background-color: var(--bg-body); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-size: 0.9rem; + color: var(--text-main); + padding: 20px 0; + transition: background-color 0.3s ease, color 0.3s ease; +} + +.container { + max-width: 95%; + margin: 0 auto; +} + +@media (min-width: 1400px) { + .container { + max-width: 75%; + } +} + +/* Layout Elements */ +.header, +.card { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: var(--shadow); + margin-bottom: 20px; + transition: border-color 0.3s ease; +} + +.header { + padding: 20px; +} + +.border-bottom { + border-bottom: 1px solid var(--border-color) !important; +} + +.card-header { + background: transparent; + border-bottom: 1px solid var(--border-color); + padding: 15px 20px; + font-weight: 600; + color: var(--text-heading); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: var(--text-heading); + font-weight: 600; +} + +.text-muted { + color: var(--text-muted) !important; +} + +/* Blur Effect */ +body.modal-open .main-content-wrapper { + filter: blur(8px) grayscale(20%); + transform: scale(0.99); + opacity: 0.6; + transition: all 0.4s ease; + pointer-events: none; +} + +.main-content-wrapper { + transition: all 0.4s ease; + transform: scale(1); + filter: blur(0); + opacity: 1; +} + +/* Buttons & Controls */ +.btn-nav, +.btn-header, +.header-badge, +.header-timezone { + background: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-heading); + transition: all 0.2s ease; + font-size: 0.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + /* Fixed height for consistency */ + vertical-align: middle; +} + +.btn-nav:hover, +.btn-header:hover { + background: var(--bg-element-hover); + border-color: var(--text-muted); + color: var(--text-heading); +} + +.btn-nav.active { + background: var(--accent-color); + color: #ffffff; + border-color: var(--accent-color); +} + +.btn-header { + padding: 0 12px; + border-radius: 6px; + min-width: 36px; +} + + +.btn-nav { + padding: 0 12px; + border-radius: 6px; + display: inline-flex; + align-items: center; +} + +.header-badge, +.header-timezone { + padding: 0 12px; + border-radius: 6px; + display: inline-flex; + align-items: center; +} + +/* Stats Cards & KPI */ +.stats-info, +.kpi-grid { + display: flex; + gap: 15px; + flex-wrap: wrap; + margin-bottom: 15px; +} + +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 20px; +} + +.stat-item { + background: var(--bg-card); + padding: 15px; + border-radius: 6px; + border: 1px solid var(--border-color); + flex: 1; + min-width: 180px; + box-shadow: var(--shadow); +} + +.kpi-grid .stat-item { + padding: 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.stat-value { + font-size: 1.4rem; + font-weight: 600; + color: var(--text-heading); + line-height: 1.2; +} + +.kpi-grid .stat-content h3 { + font-size: 1.6rem; + font-weight: 700; + color: var(--text-heading); + margin: 0; + line-height: 1.2; +} + +.stat-label { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 5px; +} + +/* Tables */ +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--text-main); + --bs-table-border-color: var(--border-color); + margin-bottom: 0; +} + +.table th { + background-color: var(--bg-card); + color: var(--text-muted); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; + cursor: pointer; + user-select: none; + padding: 12px 10px; +} + +.table th:hover { + color: var(--text-heading); +} + +.table th.active-sort { + color: var(--accent-color); + border-bottom-color: var(--accent-color); +} + +.table td { + padding: 12px 10px; + border-bottom: 1px solid var(--border-subtle); + vertical-align: middle; +} + +.table-hover tbody tr:hover { + background-color: var(--bg-element-hover); +} + +.table-hover tbody tr:hover td, +.table-hover tbody tr:hover .font-monospace { + color: var(--text-heading); +} + +.font-monospace { + color: var(--text-main); + font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important; +} + +/* Section Divider */ +.section-divider td { + background-color: var(--bg-element) !important; + font-weight: 600; + color: var(--text-heading); + padding: 8px 15px; + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + font-size: 0.8rem; +} + +/* Badges */ +.status-badge { + padding: 4px 8px; + border-radius: 4px; + font-weight: 500; + font-size: 0.75rem; + border: 1px solid transparent; +} + +.status-active, +.status-valid { + background-color: var(--success-bg); + color: var(--success-text); + border-color: var(--badge-border-active); +} + +.status-disconnected, +.status-expired { + background-color: var(--danger-bg); + color: var(--danger-text); + border-color: var(--badge-border-disconnected); +} + +.status-expiring { + background-color: var(--warning-bg); + color: var(--warning-text); + border: 1px solid transparent; +} + +.badge-soft-warning { + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid transparent; + font-weight: 600; + font-size: 0.75rem; + padding: 5px 10px; +} + +.client-link { + color: var(--text-heading); + text-decoration: none; + font-weight: 600; + cursor: pointer; + transition: color 0.2s; +} + +.client-link:hover { + color: var(--accent-color); +} + +.client-link i { + color: var(--text-muted); + transition: color 0.2s; +} + +.client-link:hover i { + color: var(--accent-color); +} + +/* Inputs */ +.form-control, +.form-select, +.search-input { + background-color: var(--bg-input); + border: 1px solid var(--border-color); + color: var(--text-heading); + border-radius: 6px; +} + +.form-control:focus, +.form-select:focus, +.search-input:focus { + background-color: var(--bg-input); + border-color: var(--accent-color); + color: var(--text-heading); + box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15); + outline: none; +} + +.form-control::placeholder { + color: var(--text-muted); + opacity: 0.7; +} + +.input-group-text { + background-color: var(--bg-element); + border: 1px solid var(--border-color); + color: var(--text-muted); +} + +/* Toggle Switch */ +.form-check-input { + background-color: var(--toggle-off-bg); + border-color: var(--toggle-off-border); + cursor: pointer; +} + +.form-check-input:checked { + background-color: var(--accent-color); + border-color: var(--accent-color); +} + +[data-theme="dark"] .form-check-input:not(:checked) { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e"); +} + +/* Sort Buttons */ +.sort-btn-group { + display: flex; +} + +.sort-btn { + background: var(--bg-input); + border: 1px solid var(--border-color); + padding: 6px 12px; + font-size: 0.85rem; + color: var(--text-main); + min-width: 100px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.sort-btn:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; + border-right: none; +} + +.sort-btn:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} + +.sort-btn:hover { + background-color: var(--bg-element-hover); +} + +.sort-btn.active { + background-color: var(--bg-element-hover); + color: var(--text-heading); + border-color: var(--text-muted); + font-weight: 600; + z-index: 2; +} + +/* Modals */ +.modal-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-main); + box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5); +} + +.modal-header { + border-bottom: 1px solid var(--border-color); +} + +.modal-footer { + border-top: 1px solid var(--border-color); +} + +.btn-close { + filter: var(--btn-close-filter, none); +} + +[data-theme="dark"] .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +.modal-backdrop.show { + opacity: 0.6; + background-color: #000; +} + +.chart-controls { + background: var(--bg-element); + padding: 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + margin-bottom: 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} + +.bg-white-custom { + background-color: var(--bg-input) !important; + border-color: var(--border-color) !important; +} + +/* Charts & Dashboard Specifics */ +.chart-container { + position: relative; + height: 320px; + width: 100%; +} + +.modal-chart-container { + height: 400px; + width: 100%; + position: relative; +} + +.pie-container { + position: relative; + height: 220px; + width: 100%; + display: flex; + justify-content: center; +} + +.chart-header-controls { + display: flex; + align-items: center; + gap: 15px; +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; + margin-right: 5px; +} + +/* Certificates Specifics */ +.category-header { + background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover)); + border-left: 4px solid; + padding: 12px 20px; + margin: 0; + font-weight: 600; +} + +.category-header.active { + border-left-color: var(--success-text); + color: var(--success-text); +} + +.category-header.expired { + border-left-color: var(--danger-text); + color: var(--danger-text); +} + +.certificate-file { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 2px; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} + +.empty-state i { + font-size: 3rem; + margin-bottom: 15px; + opacity: 0.5; +} + +.cert-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.cert-item:last-child { + border-bottom: none; +} + +/* Utilities */ +.refresh-indicator { + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .header-controls { + flex-wrap: wrap; + gap: 8px; + } + + .stats-info { + flex-direction: column; + } + + .chart-controls { + flex-direction: column; + align-items: flex-start; + } + + .chart-controls>div { + width: 100%; + } + + .kpi-grid { + grid-template-columns: 1fr; + } + + .chart-header-controls { + flex-wrap: wrap; + margin-top: 10px; + } +} \ No newline at end of file diff --git a/UI/dashboard.php b/UI/dashboard.php new file mode 100644 index 0000000..1565194 --- /dev/null +++ b/UI/dashboard.php @@ -0,0 +1,184 @@ + + + + + + + + OpenVPN Analytics Dashboard + + + + + + + + +
+
+
+
+

Analytics Dashboard

+

System performance overview

+
+
+ + Clients + + + Certificates + + + Analytics + + + System + + + + + + +
+
+
+ +
+
+
+

-

+

Concurrent Users (Peak)

+
+
+ +
+
+

-

+

Traffic Volume (Total)

+
+
+ +
+
+

-

+

Expiring Soon (In 45 Days)

+
+
+
+ +
+
+ Traffic Overview + +
+ + +
+ + +
+
+
+
+
+ +
+
+
+ +
+
+
+
+ TOP-3 Active Clients +
+
+
+ + + + + + + + + + + + + +
Client NameTotal DataActivity Share
Loading...
+
+
+
+
+ +
+
+
+ Certificate Alerts + Next 45 Days +
+
+

Checking certificates...

+
+
+ +
+
+ Traffic Distribution +
+
+
+ +
+
+
+
Download
+
-
+
+
+
Upload
+
-
+
+
+
+
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/UI/index.php b/UI/index.php new file mode 100644 index 0000000..6ca41a1 --- /dev/null +++ b/UI/index.php @@ -0,0 +1,198 @@ + + + + + + + + OpenVPN Client Statistics + + + + + + + + +
+
+
+
+
+

OpenVPN Monitor

+

Real-time traffic & connection statistics

+
+
+ + Clients + + + Certificates + + + Analytics + + 0 clients + + + + + +
+
+
+ +
+
+
0 B
+
Total Received
+
+
+
0 B
+
Total Sent
+
+
+
0
+
Active Clients
+
+
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ Clients List + Updating... +
+
+ + + + + + + + + + + + + + + + + + +
Client NameReal AddressStatusReceivedSentMax 30s DLMax 30s ULLast Activity
Loading...
+
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/UI/js/pages/certificates.js b/UI/js/pages/certificates.js new file mode 100644 index 0000000..9a5d1e9 --- /dev/null +++ b/UI/js/pages/certificates.js @@ -0,0 +1,228 @@ +// certificates.js - OpenVPN Certificate Statistics Logic + +// Access globals defined in PHP +const { apiUrl, refreshTime } = window.AppConfig; + +let refreshInterval; +let allCertificatesData = []; +let hideExpired = false; + +function getStatusBadge(daysRemaining) { + if (!daysRemaining || daysRemaining === 'N/A') { + return 'Unknown'; + } + + if (daysRemaining.includes('Expired')) { + return 'Expired'; + } else if (daysRemaining.includes('days')) { + const days = parseInt(daysRemaining); + if (days <= 30) { + return 'Expiring Soon'; + } else { + return 'Valid'; + } + } + return 'Unknown'; +} + +function categorizeCertificates(certificates) { + const active = []; + const expired = []; + + certificates.forEach(cert => { + if (!cert.days_remaining || cert.days_remaining === 'N/A') { + // If days_remaining is not available, check not_after date + if (cert.not_after && cert.not_after !== 'N/A') { + try { + const expDate = new Date(cert.not_after); + const now = new Date(); + if (expDate < now) { + expired.push(cert); + } else { + active.push(cert); + } + } catch (e) { + // If we can't parse the date, consider it active + active.push(cert); + } + } else { + // If no expiration info, consider it active + active.push(cert); + } + } else if (cert.days_remaining.includes('Expired')) { + expired.push(cert); + } else { + active.push(cert); + } + }); + + // Sort active certificates by days remaining (ascending) + active.sort((a, b) => { + try { + const aDays = a.days_remaining && a.days_remaining !== 'N/A' ? parseInt(a.days_remaining) : 9999; + const bDays = b.days_remaining && b.days_remaining !== 'N/A' ? parseInt(b.days_remaining) : 9999; + return aDays - bDays; + } catch (e) { + return 0; + } + }); + + // Sort expired certificates by days expired (descending) + expired.sort((a, b) => { + try { + const aMatch = a.days_remaining ? a.days_remaining.match(/\d+/) : [0]; + const bMatch = b.days_remaining ? b.days_remaining.match(/\d+/) : [0]; + const aDays = parseInt(aMatch[0]); + const bDays = parseInt(bMatch[0]); + return bDays - aDays; + } catch (e) { + return 0; + } + }); + + return { active, expired }; +} + +function renderSingleTable(active, expired, tableId) { + const tableBody = document.getElementById(tableId); + if ((!active || active.length === 0) && (!expired || expired.length === 0)) { + tableBody.innerHTML = ` + + + +

No certificates found

+ + + `; + return; + } + + let html = ''; + + // Render Active + if (active && active.length > 0) { + html += `Active Certificates (${active.length})`; + active.forEach(cert => { + html += generateRow(cert, false); + }); + } + + // Render Expired + if (expired && expired.length > 0 && !hideExpired) { + html += `Expired Certificates (${expired.length})`; + expired.forEach(cert => { + html += generateRow(cert, true); + }); + } + + tableBody.innerHTML = html; +} + +function generateRow(cert, isExpired) { + const commonName = cert.common_name || cert.subject || 'N/A'; + const clientName = commonName.replace('CN=', '').trim(); + let daysText = cert.days_remaining || 'N/A'; + + if (isExpired && daysText.includes('Expired')) { + daysText = daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago'; + } + + return ` + + +
${clientName}
+
${cert.file || 'N/A'}
+ + ${formatCertDate(cert.not_after)} + + ${daysText} + + ${getStatusBadge(cert.days_remaining)} + + `; +} + +function updateSummaryStats(certificates) { + const totalCertificates = document.getElementById('totalCertificates'); + const activeCertificates = document.getElementById('activeCertificates'); + const expiredCertificates = document.getElementById('expiredCertificates'); + const expiringSoon = document.getElementById('expiringSoon'); + const activeCount = document.getElementById('activeCount'); + const expiredCount = document.getElementById('expiredCount'); + const certCount = document.getElementById('certCount'); + + const { active, expired } = categorizeCertificates(certificates); + + let expiringSoonCount = 0; + active.forEach(cert => { + if (cert.days_remaining && cert.days_remaining !== 'N/A') { + const days = parseInt(cert.days_remaining); + if (days <= 30) { + expiringSoonCount++; + } + } + }); + + totalCertificates.textContent = certificates.length; + activeCertificates.textContent = active.length; + expiredCertificates.textContent = expired.length; + expiringSoon.textContent = expiringSoonCount; + // Update counts in badges + document.getElementById('activeCount').textContent = active.length; + document.getElementById('expiredCount').textContent = expired.length; + certCount.textContent = certificates.length + ' certificate' + (certificates.length !== 1 ? 's' : ''); +} + +function filterCertificates() { + const searchTerm = document.getElementById('searchInput').value.toLowerCase(); + + let filteredCertificates = allCertificatesData; + + if (searchTerm) { + filteredCertificates = allCertificatesData.filter(cert => { + const commonName = (cert.common_name || cert.subject || '').toLowerCase(); + const fileName = (cert.file || '').toLowerCase(); + return commonName.includes(searchTerm) || fileName.includes(searchTerm); + }); + } + + const { active, expired } = categorizeCertificates(filteredCertificates); + + renderSingleTable(active, expired, 'certificatesTable'); + updateSummaryStats(filteredCertificates); +} + +function toggleExpiredCertificates() { + hideExpired = document.getElementById('hideExpired').checked; + const expiredCard = document.getElementById('certificatesCard'); // We just re-render + filterCertificates(); +} + +async function fetchData() { + document.getElementById('refreshIcon').classList.add('refresh-indicator'); + try { + const response = await fetch(apiUrl); + const json = await response.json(); + if (json.success) { + allCertificatesData = json.data; + filterCertificates(); // This also renders tables + } + } catch (e) { + console.error("Fetch error:", e); + } finally { + document.getElementById('refreshIcon').classList.remove('refresh-indicator'); + } +} + +// Ensure functionality is available for HTML event attributes +window.fetchData = fetchData; +window.filterCertificates = filterCertificates; +window.toggleExpiredCertificates = toggleExpiredCertificates; + +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + fetchData(); + refreshInterval = setInterval(fetchData, refreshTime); +}); diff --git a/UI/js/pages/dashboard.js b/UI/js/pages/dashboard.js new file mode 100644 index 0000000..bdf1156 --- /dev/null +++ b/UI/js/pages/dashboard.js @@ -0,0 +1,269 @@ +// dashboard.js - OpenVPN Analytics Dashboard Logic + +// Access globals defined in PHP via window.AppConfig +const { apiAnalytics, apiCerts } = window.AppConfig; + +let mainChart = null; +let pieChart = null; +let globalHistory = []; +let vizMode = 'volume'; // 'volume' or 'speed' + +// --- Data Loading --- +async function loadData() { + const icon = document.getElementById('refreshIcon'); + icon.classList.add('refresh-indicator'); + + // Get selected range + const range = document.getElementById('globalRange').value; + + try { + const [resA, resC] = await Promise.all([ + fetch(apiAnalytics + '?range=' + range), + fetch(apiCerts) + ]); + + const jsonA = await resA.json(); + const jsonC = await resC.json(); + + if (jsonA.success) updateDashboard(jsonA.data); + if (jsonC.success) updateCerts(jsonC.data); + + } catch (e) { + console.error("Dashboard Load Error:", e); + } finally { + icon.classList.remove('refresh-indicator'); + } +} + +function updateDashboard(data) { + // 1. KPI + document.getElementById('kpiMaxClients').textContent = data.max_concurrent_24h; + const totalT = (data.traffic_distribution.rx + data.traffic_distribution.tx); + document.getElementById('kpiTotalTraffic').textContent = formatBytes(totalT); + + // 2. Main Chart + globalHistory = data.global_history_24h; + renderMainChart(); + + // 3. Top Clients + const tbody = document.getElementById('topClientsTable'); + tbody.innerHTML = ''; + if (data.top_clients_24h.length === 0) { + tbody.innerHTML = 'No activity recorded'; + } else { + const maxVal = data.top_clients_24h[0].total_traffic; + data.top_clients_24h.forEach(c => { + const pct = (c.total_traffic / maxVal) * 100; + const row = ` + + ${c.common_name} + ${formatBytes(c.total_traffic)} + +
+
+
+ + `; + tbody.innerHTML += row; + }); + } + + // 4. Pie Chart + renderPieChart(data.traffic_distribution); +} + +function updateCerts(certs) { + const container = document.getElementById('certsList'); + const alertCountEl = document.getElementById('kpiExpiringCerts'); + + // Filter: Active only, Expires in <= 45 days + let expiring = certs.filter(c => { + if (c.is_expired) return false; // Hide expired as requested + const days = parseInt(c.days_remaining); + return !isNaN(days) && days <= 45 && days >= 0; + }); + + expiring.sort((a, b) => parseInt(a.days_remaining) - parseInt(b.days_remaining)); + + alertCountEl.textContent = expiring.length; + if (expiring.length > 0) alertCountEl.style.color = 'var(--warning-text)'; + else alertCountEl.style.color = 'var(--text-heading)'; + + if (expiring.length === 0) { + container.innerHTML = ` +
+ +

No certificates expiring soon

+
`; + return; + } + + let html = ''; + expiring.forEach(c => { + html += ` +
+
+
${c.common_name || 'Unknown'}
+
Expires: ${c.not_after}
+
+
${c.days_remaining}
+
`; + }); + container.innerHTML = html; +} + +// --- Charts --- +function toggleMainChart() { + vizMode = document.getElementById('vizToggle').checked ? 'speed' : 'volume'; + document.getElementById('vizLabel').textContent = vizMode === 'volume' ? 'Data Volume' : 'Speed (Mbps)'; + renderMainChart(); +} + +function getChartColors(isDark) { + return { + grid: isDark ? 'rgba(240, 246, 252, 0.05)' : 'rgba(0, 0, 0, 0.04)', + text: isDark ? '#8b949e' : '#6c757d', + bg: isDark ? '#161b22' : '#ffffff', + border: isDark ? '#30363d' : '#d0d7de', + title: isDark ? '#c9d1d9' : '#24292f' + }; +} + +function renderMainChart() { + const ctx = document.getElementById('mainChart').getContext('2d'); + if (mainChart) mainChart.destroy(); + + // Downsample + const MAX_POINTS = 48; + let displayData = []; + + if (globalHistory.length > MAX_POINTS) { + const blockSize = Math.ceil(globalHistory.length / MAX_POINTS); + for (let i = 0; i < globalHistory.length; i += blockSize) { + const chunk = globalHistory.slice(i, i + blockSize); + let rx = 0, tx = 0, rxR = 0, txR = 0; + chunk.forEach(p => { + rx += p.total_rx; tx += p.total_tx; + rxR = Math.max(rxR, p.total_rx_rate || 0); + txR = Math.max(txR, p.total_tx_rate || 0); + }); + displayData.push({ + timestamp: chunk[0].timestamp, + rx: rx, tx: tx, rxR: rxR, txR: txR + }); + } + } else { + displayData = globalHistory.map(p => ({ + timestamp: p.timestamp, + rx: p.total_rx, tx: p.total_tx, + rxR: p.total_rx_rate, txR: p.total_tx_rate + })); + } + + const labels = displayData.map(p => { + const d = parseServerDate(p.timestamp); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + }); + + const dsRx = displayData.map(p => vizMode === 'volume' ? p.rx / (1024 * 1024) : p.rxR); + const dsTx = displayData.map(p => vizMode === 'volume' ? p.tx / (1024 * 1024) : p.txR); + + const isDark = currentTheme === 'dark'; + const colors = getChartColors(isDark); + + mainChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: 'Total Download', + data: dsRx, + borderColor: '#3fb950', + backgroundColor: 'rgba(63, 185, 80, 0.15)', + borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4 + }, + { + label: 'Total Upload', + data: dsTx, + borderColor: '#58a6ff', + backgroundColor: 'rgba(88, 166, 255, 0.15)', + borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4 + } + ] + }, + options: { + responsive: true, maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { legend: { labels: { color: colors.text } } }, + scales: { + x: { ticks: { color: colors.text, maxTicksLimit: 8 }, grid: { color: colors.grid, borderColor: 'transparent' } }, + y: { + beginAtZero: true, + ticks: { color: colors.text }, + grid: { color: colors.grid, borderColor: 'transparent' }, + title: { display: true, text: vizMode === 'volume' ? 'MB' : 'Mbps', color: colors.text } + } + } + } + }); +} + +function renderPieChart(dist) { + const ctx = document.getElementById('pieChart').getContext('2d'); + if (pieChart) pieChart.destroy(); + + document.getElementById('pieRxVal').textContent = formatBytes(dist.rx); + document.getElementById('pieTxVal').textContent = formatBytes(dist.tx); + + const isDark = currentTheme === 'dark'; + const colors = getChartColors(isDark); + + pieChart = new Chart(ctx, { + type: 'doughnut', + data: { + labels: ['Download', 'Upload'], + datasets: [{ + data: [dist.rx, dist.tx], + backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'], + borderColor: colors.bg, // Add border to match background for better separation + borderWidth: 2 + }] + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + cutout: '70%' + } + }); +} + +function renderCharts() { + if (globalHistory.length) renderMainChart(); + // Trigger load to refresh Pie Chart colors + loadData(); +} + +// Expose functions to window for HTML attributes +window.loadData = loadData; +window.toggleMainChart = toggleMainChart; +window.renderCharts = renderCharts; + +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + loadData(); + setInterval(loadData, 60000); + + // Override global toggleTheme to update charts + // We hook into the global toggleTheme defined in utils.js + // but the actual onclick calls toggleTheme(). + // We need to overwrite window.toggleTheme + const _baseToggleTheme = window.toggleTheme; + window.toggleTheme = function () { + _baseToggleTheme(() => { + // Re-render charts with new theme colors + if (globalHistory.length) renderMainChart(); + loadData(); // This refreshes everything including pie chart + }); + }; +}); diff --git a/UI/js/pages/index.js b/UI/js/pages/index.js new file mode 100644 index 0000000..7014fe4 --- /dev/null +++ b/UI/js/pages/index.js @@ -0,0 +1,365 @@ +// index.js - OpenVPN Monitor Client List Logic + +// Access globals defined in PHP +const { apiUrl, refreshTime } = window.AppConfig; + +const MAX_CHART_POINTS = 48; + +let allClientsData = []; +let currentSort = 'sent'; +let hideDisconnected = false; +let searchQuery = ''; +let refreshIntervalId; + +// Chart Globals +let trafficChart = null; +let currentClientName = ''; +let currentRange = '24h'; +let vizMode = 'volume'; +let cachedHistoryData = null; + +// --- MAIN DASHBOARD LOGIC --- + +async function fetchData() { + document.getElementById('refreshIcon').classList.add('refresh-indicator'); + try { + const response = await fetch(apiUrl); + const json = await response.json(); + if (json.success) { + allClientsData = json.data; + renderDashboard(); + } + } catch (e) { + console.error("Fetch error:", e); + } finally { + document.getElementById('refreshIcon').classList.remove('refresh-indicator'); + } +} + +function handleSearch(val) { + searchQuery = val.toLowerCase().trim(); + renderDashboard(); +} + +function renderDashboard() { + let totalRx = 0, totalTx = 0, activeCnt = 0; + allClientsData.forEach(c => { + totalRx += c.total_bytes_received || 0; + totalTx += c.total_bytes_sent || 0; + if (c.status === 'Active') activeCnt++; + }); + + document.getElementById('totalReceived').textContent = formatBytes(totalRx); + document.getElementById('totalSent').textContent = formatBytes(totalTx); + document.getElementById('activeClients').textContent = activeCnt; + document.getElementById('clientCount').textContent = allClientsData.length + ' clients'; + + const now = new Date(); + document.getElementById('lastUpdated').textContent = 'Updated: ' + now.toLocaleTimeString(); + + let displayData = allClientsData.filter(c => { + if (hideDisconnected && c.status !== 'Active') return false; + if (searchQuery && !c.common_name.toLowerCase().includes(searchQuery)) return false; + return true; + }); + + displayData.sort((a, b) => { + if (a.status === 'Active' && b.status !== 'Active') return -1; + if (a.status !== 'Active' && b.status === 'Active') return 1; + const valA = currentSort === 'received' ? a.total_bytes_received : a.total_bytes_sent; + const valB = currentSort === 'received' ? b.total_bytes_received : b.total_bytes_sent; + return valB - valA; + }); + + const tbody = document.getElementById('statsTable'); + tbody.innerHTML = ''; + + const thRecv = document.getElementById('thRecv'); + const thSent = document.getElementById('thSent'); + if (currentSort === 'received') { + thRecv.classList.add('active-sort'); thRecv.innerHTML = 'Received '; + thSent.classList.remove('active-sort'); thSent.innerHTML = 'Sent'; + } else { + thSent.classList.add('active-sort'); thSent.innerHTML = 'Sent '; + thRecv.classList.remove('active-sort'); thRecv.innerHTML = 'Received'; + } + + if (displayData.length === 0) { + tbody.innerHTML = 'No clients match your filter'; + return; + } + + let currentStatus = null; + + displayData.forEach(c => { + if (c.status !== currentStatus) { + currentStatus = c.status; + const dividerRow = document.createElement('tr'); + dividerRow.className = 'section-divider'; + const iconClass = c.status === 'Active' ? 'fa-circle text-success' : 'fa-circle text-danger'; + dividerRow.innerHTML = ` + + ${c.status} Clients + + `; + tbody.appendChild(dividerRow); + } + + let lastActivity = 'N/A'; + if (c.last_activity && c.last_activity !== 'N/A') { + const d = parseServerDate(c.last_activity); + if (!isNaN(d)) lastActivity = d.toLocaleString(); + } + + const isConnected = c.status === 'Active'; + + // Speed Visualization Logic + const downSpeed = isConnected + ? ` + ${c.current_recv_rate_mbps ? formatRate(c.current_recv_rate_mbps) : '0.000 Mbps'} + ` + : '-'; + + const upSpeed = isConnected + ? ` + ${c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps'} + ` + : '-'; + + const row = document.createElement('tr'); + row.innerHTML = ` + + + ${c.common_name} + + + ${c.real_address || '-'} + + + ${c.status} + + + ${formatBytes(c.total_bytes_received)} + ${formatBytes(c.total_bytes_sent)} + ${downSpeed} + ${upSpeed} + ${lastActivity} + `; + tbody.appendChild(row); + }); +} + +function changeSort(mode) { + currentSort = mode; + document.getElementById('sortRecv').className = `sort-btn ${mode === 'received' ? 'active' : ''}`; + document.getElementById('sortSent').className = `sort-btn ${mode === 'sent' ? 'active' : ''}`; + renderDashboard(); +} + +function toggleDisconnected() { + hideDisconnected = document.getElementById('hideDisconnected').checked; + renderDashboard(); +} + +// --- HISTORY CHART LOGIC --- + +// Expose openHistoryModal to global scope because it's called from HTML inline onclick +window.openHistoryModal = function (clientName) { + currentClientName = clientName; + document.getElementById('modalClientName').textContent = clientName; + document.getElementById('historyRange').value = '3h'; + document.getElementById('vizToggle').checked = false; + vizMode = 'volume'; + document.getElementById('vizLabel').textContent = 'Data Volume'; + currentRange = '24h'; + + const modal = new bootstrap.Modal(document.getElementById('historyModal')); + modal.show(); + loadHistoryData(); +}; + +window.updateHistoryRange = function () { + currentRange = document.getElementById('historyRange').value; + loadHistoryData(); +}; + +window.toggleVizMode = function () { + const isChecked = document.getElementById('vizToggle').checked; + vizMode = isChecked ? 'speed' : 'volume'; + document.getElementById('vizLabel').textContent = isChecked ? 'Speed (Mbps)' : 'Data Volume'; + + if (cachedHistoryData) { + const downsampled = downsampleData(cachedHistoryData, MAX_CHART_POINTS); + renderChart(downsampled); + } else { + loadHistoryData(); + } +}; + +async function loadHistoryData() { + const loader = document.getElementById('chartLoader'); + loader.style.display = 'block'; + + const url = `${apiUrl}/${currentClientName}?range=${currentRange}`; + + try { + const res = await fetch(url); + const json = await res.json(); + + if (json.success && json.data.history) { + cachedHistoryData = json.data.history; + const downsampled = downsampleData(cachedHistoryData, MAX_CHART_POINTS); + renderChart(downsampled); + } else { + console.warn("No history data found"); + if (trafficChart) trafficChart.destroy(); + } + } catch (e) { + console.error("History fetch error:", e); + } finally { + loader.style.display = 'none'; + } +} + +function downsampleData(data, maxPoints) { + if (!data || data.length === 0) return []; + if (data.length <= maxPoints) return data; + + const blockSize = Math.ceil(data.length / maxPoints); + const result = []; + + for (let i = 0; i < data.length; i += blockSize) { + const chunk = data.slice(i, i + blockSize); + if (chunk.length === 0) continue; + + let sumRx = 0, sumTx = 0; + let maxRxRate = 0, maxTxRate = 0; + + chunk.forEach(pt => { + sumRx += (pt.bytes_received || 0); + sumTx += (pt.bytes_sent || 0); + maxRxRate = Math.max(maxRxRate, pt.bytes_received_rate_mbps || 0); + maxTxRate = Math.max(maxTxRate, pt.bytes_sent_rate_mbps || 0); + }); + + result.push({ + timestamp: chunk[0].timestamp, + bytes_received: sumRx, + bytes_sent: sumTx, + bytes_received_rate_mbps: maxRxRate, + bytes_sent_rate_mbps: maxTxRate + }); + } + return result; +} + +function renderChart(history) { + const ctx = document.getElementById('trafficChart').getContext('2d'); + if (trafficChart) trafficChart.destroy(); + + const labels = []; + const dataRx = []; + const dataTx = []; + + for (let i = 0; i < history.length; i++) { + const point = history[i]; + const d = parseServerDate(point.timestamp); + + let label = ''; + if (currentRange.includes('h') || currentRange === '1d') { + label = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + label = d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + labels.push(label); + + if (vizMode === 'volume') { + dataRx.push(point.bytes_received / (1024 * 1024)); // MB + dataTx.push(point.bytes_sent / (1024 * 1024)); // MB + } else { + let rxRate = point.bytes_received_rate_mbps; + let txRate = point.bytes_sent_rate_mbps; + dataRx.push(rxRate); + dataTx.push(txRate); + } + } + + const isDark = currentTheme === 'dark'; + const gridColor = isDark ? 'rgba(240, 246, 252, 0.1)' : 'rgba(0,0,0,0.05)'; + const textColor = isDark ? '#8b949e' : '#6c757d'; + + trafficChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: [ + { + label: vizMode === 'volume' ? 'Received (MB)' : 'RX Mbps', + data: dataRx, + borderColor: '#27ae60', + backgroundColor: 'rgba(39, 174, 96, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.3 + }, + { + label: vizMode === 'volume' ? 'Sent (MB)' : 'TX Mbps', + data: dataTx, + borderColor: '#2980b9', + backgroundColor: 'rgba(41, 128, 185, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.3 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + plugins: { + legend: { labels: { color: textColor } } + }, + scales: { + x: { + ticks: { color: textColor, maxTicksLimit: 8 }, + grid: { color: gridColor } + }, + y: { + beginAtZero: true, + ticks: { color: textColor }, + grid: { color: gridColor } + } + } + } + }); +} + +// Expose other functions used in HTML onclick attributes +window.changeSort = changeSort; +window.toggleDisconnected = toggleDisconnected; +window.handleSearch = handleSearch; +window.fetchData = fetchData; + +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + fetchData(); + // Start Auto-Refresh + refreshIntervalId = setInterval(fetchData, refreshTime); + + // Re-render chart on theme change + // The helper 'toggleTheme' in utils.js takes a callback + // Override global toggleTheme to include chart re-rendering + const _baseToggleTheme = window.toggleTheme; + window.toggleTheme = function () { + _baseToggleTheme(() => { + if (trafficChart) { + renderChart(cachedHistoryData ? downsampleData(cachedHistoryData, MAX_CHART_POINTS) : []); + } + }); + }; +}); diff --git a/UI/js/utils.js b/UI/js/utils.js new file mode 100644 index 0000000..6c5265e --- /dev/null +++ b/UI/js/utils.js @@ -0,0 +1,94 @@ +/** + * Common Utilities for OpenVPN Monitor + */ + +// --- Global Variables --- +var currentTheme = localStorage.getItem('theme') || 'light'; + +// --- Theme Functions --- +function initTheme() { + document.documentElement.setAttribute('data-theme', currentTheme); + const icon = document.getElementById('themeIcon'); + if (icon) { + if (currentTheme === 'dark') { + // In dark mode we want Sun icon (to switch to light) + if (icon.classList.contains('fa-moon')) icon.classList.replace('fa-moon', 'fa-sun'); + else icon.classList = 'fas fa-sun'; + } else { + // In light mode we want Moon icon (to switch to dark) + if (icon.classList.contains('fa-sun')) icon.classList.replace('fa-sun', 'fa-moon'); + else icon.classList = 'fas fa-moon'; + } + } +} + +function toggleTheme(callback = null) { + currentTheme = currentTheme === 'light' ? 'dark' : 'light'; + localStorage.setItem('theme', currentTheme); + initTheme(); + if (callback) callback(); +} + +// --- Formatting Functions --- +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', '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 formatRate(rate) { + return parseFloat(rate).toFixed(3) + ' Mbps'; +} + +function parseServerDate(dateStr) { + if (!dateStr) return null; + let isoStr = dateStr.replace(' ', 'T'); + if (!isoStr.endsWith('Z') && !isoStr.includes('+')) { + isoStr += 'Z'; + } + return new Date(isoStr); +} + +function formatCertDate(dateString) { + if (!dateString || dateString === 'N/A') return 'N/A'; + + try { + // Handle OpenSSL date format: "Jun 9 08:37:28 2024 GMT" + if (dateString.includes('GMT')) { + const months = { + 'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', + 'May': '05', 'Jun': '06', 'Jul': '07', 'Aug': '08', + 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12' + }; + + const parts = dateString.split(' '); + const month = months[parts[0]]; + const day = parts[1].padStart(2, '0'); + const year = parts[3]; + const time = parts[2]; + + return `${day}-${month}-${year} ${time}`; + } + + // Try to parse as ISO date or other format + const date = new Date(dateString); + if (!isNaN(date.getTime())) { + return date.toLocaleDateString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).replace(',', ''); + } + + return dateString; + } catch (error) { + console.log('Date formatting error:', error, 'for date:', dateString); + return dateString; + } +}