init commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
BIN
APP/__pycache__/db.cpython-314.pyc
Normal file
BIN
APP/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
APP/__pycache__/openvpn_api_v3.cpython-314.pyc
Normal file
BIN
APP/__pycache__/openvpn_api_v3.cpython-314.pyc
Normal file
Binary file not shown.
BIN
APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc
Normal file
BIN
APP/__pycache__/openvpn_gatherer_v3.cpython-314.pyc
Normal file
Binary file not shown.
32
APP/config.ini
Normal file
32
APP/config.ini
Normal file
@@ -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
|
||||
|
||||
91
APP/db.py
Normal file
91
APP/db.py
Normal file
@@ -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()
|
||||
574
APP/openvpn_api_v3.py
Normal file
574
APP/openvpn_api_v3.py
Normal file
@@ -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/<string:common_name>', 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)
|
||||
510
APP/openvpn_gatherer_v3.py
Normal file
510
APP/openvpn_gatherer_v3.py
Normal file
@@ -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()
|
||||
2
APP/requirements.txt
Normal file
2
APP/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Flask==3.0.0
|
||||
Flask-Cors==4.0.0
|
||||
30
DEV/task.md
Normal file
30
DEV/task.md
Normal file
@@ -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
|
||||
30
DEV/task.md.resolved
Normal file
30
DEV/task.md.resolved
Normal file
@@ -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
|
||||
30
DEV/task.md.resolved.10
Normal file
30
DEV/task.md.resolved.10
Normal file
@@ -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
|
||||
68
DEV/walkthrough.md
Normal file
68
DEV/walkthrough.md
Normal file
@@ -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.
|
||||
68
DEV/walkthrough.md.resolved
Normal file
68
DEV/walkthrough.md.resolved
Normal file
@@ -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.
|
||||
68
DEV/walkthrough.md.resolved.1
Normal file
68
DEV/walkthrough.md.resolved.1
Normal file
@@ -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.
|
||||
202
DOCS/api_v3_endpoints.md
Normal file
202
DOCS/api_v3_endpoints.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# OpenVPN Monitor API v2 Documentation
|
||||
|
||||
Этот API предоставляет доступ к данным мониторинга OpenVPN, включая статус клиентов в реальном времени и исторические данные, хранящиеся в Time Series Database (TSDB).
|
||||
|
||||
**Base URL:** `http://<your-server-ip>:5001/api/v1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Статистика по клиенту (Детальная + История)
|
||||
|
||||
Основной эндпоинт для построения графиков и отчетов. Поддерживает динамическую агрегацию данных (умный выбор детализации).
|
||||
|
||||
### `GET /stats/<common_name>`
|
||||
|
||||
#### Параметры запроса (Query Parameters)
|
||||
|
||||
| Параметр | Тип | По умолчанию | Описание |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `range` | string | `24h` | Период выборки. Поддерживаются форматы: `24h` (часы), `7d` (дни), `30d`, `1y` (годы). |
|
||||
| `resolution` | string | `auto` | Принудительная детализация данных. <br>**Значения:**<br>`auto` — автоматический выбор (см. логику ниже)<br>`raw` — сырые данные (каждые 10-30 сек)<br>`5min` — 5 минут<br>`hourly` — 1 час<br>`6h` — 6 часов<br>`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"
|
||||
}
|
||||
|
||||
```
|
||||
49
Deployment/APP/openrc/INSTALL.md
Normal file
49
Deployment/APP/openrc/INSTALL.md
Normal file
@@ -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"
|
||||
```
|
||||
16
Deployment/APP/openrc/ovpmon-api
Normal file
16
Deployment/APP/openrc/ovpmon-api
Normal file
@@ -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
|
||||
}
|
||||
16
Deployment/APP/openrc/ovpmon-gatherer
Normal file
16
Deployment/APP/openrc/ovpmon-gatherer
Normal file
@@ -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
|
||||
}
|
||||
142
UI/certificates.php
Normal file
142
UI/certificates.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// Access variables from config.php
|
||||
$api_url = $api_config['certificates_url'];
|
||||
$refresh_interval = $api_config['refresh_interval']; // default or custom? config says 30s, file had 60s. config.php has 'refresh_interval' => 30000. Let's use config.
|
||||
|
||||
$timezone_abbr = date('T');
|
||||
$timezone_offset = date('P');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenVPN Certificate Statistics</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="css/style.css?v=<?php echo time(); ?>" rel="stylesheet">
|
||||
<script src="js/utils.js?v=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main-content-wrapper">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="d-flex justify-content-between align-items-flex-start">
|
||||
<div class="header-title-container">
|
||||
<h1 class="h3 mb-1">OpenVPN Certificate Statistics</h1>
|
||||
<p class="text-muted mb-0 header-description">Certificate validity and expiration monitoring</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<a href="index.php" class="btn-nav">
|
||||
<i class="fas fa-list me-2"></i>Clients
|
||||
</a>
|
||||
<a href="certificates.php" class="btn-nav active">
|
||||
<i class="fas fa-certificate me-2"></i>Certificates
|
||||
</a>
|
||||
<a href="dashboard.php" class="btn-nav">
|
||||
<i class="fas fa-chart-pie me-2"></i>Analytics
|
||||
</a>
|
||||
<span class="header-badge" id="certCount">0 certificates</span>
|
||||
<span class="header-timezone">
|
||||
<i class="fas fa-globe me-1 text-muted"></i><?php echo $timezone_abbr; ?>
|
||||
</span>
|
||||
<button class="btn-header" onclick="toggleTheme()" title="Toggle Theme">
|
||||
<i class="fas fa-moon" id="themeIcon"></i>
|
||||
</button>
|
||||
<button class="btn-header" onclick="fetchData()" title="Refresh">
|
||||
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-info" id="statsInfo">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="totalCertificates">0</div>
|
||||
<div class="stat-label">Total Certificates</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="activeCertificates">0</div>
|
||||
<div class="stat-label">Active Certificates</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="expiringSoon">0</div>
|
||||
<div class="stat-label">Expiring in 30 days</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="expiredCertificates">0</div>
|
||||
<div class="stat-label">Expired Certificates</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
|
||||
<div class="d-flex gap-3 align-items-center flex-wrap">
|
||||
<div class="input-group input-group-sm" style="width: 250px;">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" id="searchInput" class="form-control" placeholder="Search by client name..."
|
||||
oninput="filterCertificates()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="hideExpired"
|
||||
onchange="toggleExpiredCertificates()">
|
||||
<label class="form-check-label user-select-none text-muted" for="hideExpired">Hide Expired
|
||||
Certificates</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Certificates List Card -->
|
||||
<div class="card" id="certificatesCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
|
||||
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
||||
<div>
|
||||
<span class="status-badge status-valid me-1" id="activeCountBadge"><i
|
||||
class="fas fa-check-circle me-1"></i><span id="activeCount">0</span>
|
||||
Active</span>
|
||||
<span class="status-badge status-expired" id="expiredCountBadge"><i
|
||||
class="fas fa-times-circle me-1"></i><span id="expiredCount">0</span>
|
||||
Expired</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client Name</th>
|
||||
<th>Validity Not After</th>
|
||||
<th>Days Remaining</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="certificatesTable">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2 mb-0">Loading certificates...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/utils.js"></script>
|
||||
<script>
|
||||
window.AppConfig = {
|
||||
apiUrl: '<?php echo $api_url; ?>',
|
||||
refreshTime: <?php echo $refresh_interval; ?>
|
||||
};
|
||||
</script>
|
||||
<script src="js/pages/certificates.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
30
UI/config.php
Normal file
30
UI/config.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
/**
|
||||
* Global Configuration
|
||||
*/
|
||||
|
||||
// API Configuration
|
||||
$api_host = '172.16.5.1';
|
||||
$api_port = '5001';
|
||||
$api_base_url = "http://{$api_host}:{$api_port}/api/v1";
|
||||
|
||||
$api_config = [
|
||||
'stats_url' => "{$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");
|
||||
}
|
||||
?>
|
||||
637
UI/css/style.css
Normal file
637
UI/css/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
184
UI/dashboard.php
Normal file
184
UI/dashboard.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// Access variables from config.php
|
||||
$api_url_analytics = $api_config['analytics_url'];
|
||||
$api_url_certs = $api_config['certificates_url'];
|
||||
|
||||
$timezone_abbr = date('T');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenVPN Analytics Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="css/style.css?v=<?php echo time(); ?>" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="js/utils.js?v=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
||||
<div class="mb-3 mb-md-0">
|
||||
<h1 class="h3 mb-1">Analytics Dashboard</h1>
|
||||
<p class="text-muted mb-0">System performance overview</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<a href="index.php" class="btn-nav">
|
||||
<i class="fas fa-list me-2"></i>Clients
|
||||
</a>
|
||||
<a href="certificates.php" class="btn-nav">
|
||||
<i class="fas fa-certificate me-2"></i>Certificates
|
||||
</a>
|
||||
<a href="dashboard.php" class="btn-nav active">
|
||||
<i class="fas fa-chart-pie me-2"></i>Analytics
|
||||
</a>
|
||||
<!-- Dashboard doesn't have a specific count badge in header usually, using placeholder or removing if not needed. User asked for identical elements. -->
|
||||
<span class="header-badge">System</span>
|
||||
|
||||
<span class="header-timezone">
|
||||
<i class="fas fa-globe me-1 text-muted"></i><?php echo $timezone_abbr; ?>
|
||||
</span>
|
||||
<button class="btn-header" onclick="toggleTheme()" title="Toggle Theme">
|
||||
<i class="fas fa-moon" id="themeIcon"></i>
|
||||
</button>
|
||||
<button class="btn-header" onclick="loadData()" title="Refresh">
|
||||
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3 id="kpiMaxClients">-</h3>
|
||||
<p>Concurrent Users (Peak)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3 id="kpiTotalTraffic">-</h3>
|
||||
<p>Traffic Volume (Total)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3 id="kpiExpiringCerts">-</h3>
|
||||
<p>Expiring Soon (In 45 Days)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center flex-wrap">
|
||||
<span><i class="fas fa-chart-area me-2"></i>Traffic Overview</span>
|
||||
|
||||
<div class="chart-header-controls">
|
||||
<select class="form-select form-select-sm" style="width:auto;" id="globalRange"
|
||||
onchange="loadData()">
|
||||
<option value="24h" selected>Last 24 Hours</option>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 1 Month</option>
|
||||
</select>
|
||||
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="vizToggle"
|
||||
onchange="toggleMainChart()">
|
||||
<label class="form-check-label small fw-bold" style="color: var(--text-heading);"
|
||||
for="vizToggle" id="vizLabel">Speed</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas id="mainChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-trophy me-2"></i>TOP-3 Active Clients
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client Name</th>
|
||||
<th>Total Data</th>
|
||||
<th>Activity Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="topClientsTable">
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-5">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-exclamation-circle me-2"></i>Certificate Alerts</span>
|
||||
<span class="badge bg-secondary" style="font-size: 0.7em;">Next 45 Days</span>
|
||||
</div>
|
||||
<div class="card-body p-3" id="certsList"
|
||||
style="min-height: 120px; max-height: 200px; overflow-y: auto;">
|
||||
<p class="text-muted text-center py-3 mb-0">Checking certificates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-chart-pie me-2"></i>Traffic Distribution
|
||||
</div>
|
||||
<div class="card-body d-flex align-items-center justify-content-around p-4">
|
||||
<div class="pie-container" style="width: 140px; height: 140px; flex: 0 0 auto;">
|
||||
<canvas id="pieChart"></canvas>
|
||||
</div>
|
||||
<div class="ms-3 flex-grow-1">
|
||||
<div class="mb-3">
|
||||
<div class="small text-muted mb-1"><span class="legend-dot"
|
||||
style="background:#3fb950"></span>Download</div>
|
||||
<div class="h5 mb-0" style="color: var(--text-heading);" id="pieRxVal">-</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="small text-muted mb-1"><span class="legend-dot"
|
||||
style="background:#58a6ff"></span>Upload</div>
|
||||
<div class="h5 mb-0" style="color: var(--text-heading);" id="pieTxVal">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script>
|
||||
window.AppConfig = {
|
||||
apiAnalytics: '<?php echo $api_url_analytics; ?>',
|
||||
apiCerts: '<?php echo $api_url_certs; ?>'
|
||||
};
|
||||
</script>
|
||||
<script src="js/pages/dashboard.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
198
UI/index.php
Normal file
198
UI/index.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
require_once 'config.php';
|
||||
|
||||
// Wrapper variables for compatibility with existing template logic
|
||||
$api_url = $api_config['stats_url'];
|
||||
$refresh_interval = $api_config['refresh_interval'];
|
||||
|
||||
$timezone_abbr = date('T');
|
||||
$timezone_offset = date('P');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpenVPN Client Statistics</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="css/style.css?v=<?php echo time(); ?>" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="js/utils.js?v=<?php echo time(); ?>"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main-content-wrapper">
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap">
|
||||
<div class="mb-3 mb-md-0">
|
||||
<h1 class="h3 mb-1">OpenVPN Monitor</h1>
|
||||
<p class="text-muted mb-0">Real-time traffic & connection statistics</p>
|
||||
</div>
|
||||
<div class="d-flex align-items-center flex-wrap gap-2">
|
||||
<a href="index.php" class="btn-nav active">
|
||||
<i class="fas fa-list me-2"></i>Clients
|
||||
</a>
|
||||
<a href="certificates.php" class="btn-nav">
|
||||
<i class="fas fa-certificate me-2"></i>Certificates
|
||||
</a>
|
||||
<a href="dashboard.php" class="btn-nav">
|
||||
<i class="fas fa-chart-pie me-2"></i>Analytics
|
||||
</a>
|
||||
<span class="header-badge" id="clientCount">0 clients</span>
|
||||
<span class="header-timezone">
|
||||
<i class="fas fa-globe me-1 text-muted"></i><?php echo $timezone_abbr; ?>
|
||||
</span>
|
||||
<button class="btn-header" onclick="toggleTheme()" title="Toggle Theme">
|
||||
<i class="fas fa-moon" id="themeIcon"></i>
|
||||
</button>
|
||||
<button class="btn-header" onclick="fetchData()" title="Refresh">
|
||||
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-info" id="statsInfo">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="totalReceived">0 B</div>
|
||||
<div class="stat-label">Total Received</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="totalSent">0 B</div>
|
||||
<div class="stat-label">Total Sent</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" id="activeClients">0</div>
|
||||
<div class="stat-label">Active Clients</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
|
||||
<div class="d-flex gap-3 align-items-center flex-wrap">
|
||||
<div class="sort-btn-group">
|
||||
<button class="sort-btn" onclick="changeSort('received')" id="sortRecv">Received</button>
|
||||
<button class="sort-btn active" onclick="changeSort('sent')" id="sortSent">Sent</button>
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm" style="width: 250px;">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" id="clientSearch" placeholder="Search client..."
|
||||
onkeyup="handleSearch(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="hideDisconnected"
|
||||
onchange="toggleDisconnected()">
|
||||
<label class="form-check-label user-select-none text-muted" for="hideDisconnected">Hide
|
||||
Disconnected</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
|
||||
<span><i class="fas fa-network-wired me-2"></i>Clients List</span>
|
||||
<small class="text-muted" id="lastUpdated">Updating...</small>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client Name</th>
|
||||
<th>Real Address</th>
|
||||
<th>Status</th>
|
||||
<th onclick="changeSort('received')" id="thRecv">Received</th>
|
||||
<th onclick="changeSort('sent')" id="thSent">Sent</th>
|
||||
<th>Max 30s DL</th>
|
||||
<th>Max 30s UL</th>
|
||||
<th>Last Activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="statsTable">
|
||||
<tr>
|
||||
<td colspan="8" class="text-center py-4 text-muted">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-chart-area me-2" style="color: var(--accent-color);"></i>
|
||||
<span id="modalClientName">Client Name</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
<div class="chart-controls">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label for="historyRange" class="text-muted"><i class="far fa-clock me-1"></i>
|
||||
Range:</label>
|
||||
<select id="historyRange" class="form-select form-select-sm"
|
||||
style="width: auto; min-width: 200px;" onchange="updateHistoryRange()">
|
||||
<option value="1h">Last 1 Hour (30s agg)</option>
|
||||
<option value="3h">Last 3 Hours (1m agg)</option>
|
||||
<option value="6h">Last 6 Hours (1m agg)</option>
|
||||
<option value="12h">Last 12 Hours (1m agg)</option>
|
||||
<option value="24h" selected>Last 24 Hours (1m agg)</option>
|
||||
<option disabled>──────────</option>
|
||||
<option value="24h">Last 1 Day (15m agg)</option>
|
||||
<option value="2d">Last 2 Days (15m agg)</option>
|
||||
<option value="3d">Last 3 Days (15m agg)</option>
|
||||
<option disabled>──────────</option>
|
||||
<option value="4d">Last 4 Days (1h agg)</option>
|
||||
<option value="5d">Last 5 Days (1h agg)</option>
|
||||
<option value="6d">Last 6 Days (1h agg)</option>
|
||||
<option value="7d">Last 7 Days (1h agg)</option>
|
||||
<option value="14d">Last 14 Days (1h agg)</option>
|
||||
<option value="30d">Last 1 Month (1h agg)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 bg-white-custom px-3 py-1 border rounded">
|
||||
<span class="small fw-bold text-muted">Metric:</span>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="vizToggle"
|
||||
onchange="toggleVizMode()">
|
||||
<label class="form-check-label text-main" for="vizToggle" id="vizLabel">Data
|
||||
Volume</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-chart-container">
|
||||
<canvas id="trafficChart"></canvas>
|
||||
<div id="chartLoader" class="position-absolute top-50 start-50 translate-middle"
|
||||
style="display: none;">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/utils.js"></script>
|
||||
<script>
|
||||
// Pass PHP Configuration to JS
|
||||
window.AppConfig = {
|
||||
apiUrl: '<?php echo $api_url; ?>',
|
||||
refreshTime: <?php echo $refresh_interval; ?>
|
||||
};
|
||||
</script>
|
||||
<script src="js/pages/index.js?v=<?php echo time(); ?>"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
228
UI/js/pages/certificates.js
Normal file
228
UI/js/pages/certificates.js
Normal file
@@ -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 '<span class="status-badge text-muted">Unknown</span>';
|
||||
}
|
||||
|
||||
if (daysRemaining.includes('Expired')) {
|
||||
return '<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>';
|
||||
} else if (daysRemaining.includes('days')) {
|
||||
const days = parseInt(daysRemaining);
|
||||
if (days <= 30) {
|
||||
return '<span class="status-badge status-expiring"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
||||
} else {
|
||||
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
|
||||
}
|
||||
}
|
||||
return '<span class="status-badge text-muted">Unknown</span>';
|
||||
}
|
||||
|
||||
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 = `
|
||||
<tr>
|
||||
<td colspan="4" class="empty-state">
|
||||
<i class="fas fa-certificate"></i>
|
||||
<p>No certificates found</p>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// Render Active
|
||||
if (active && active.length > 0) {
|
||||
html += `<tr class="section-divider"><td colspan="4">Active Certificates (${active.length})</td></tr>`;
|
||||
active.forEach(cert => {
|
||||
html += generateRow(cert, false);
|
||||
});
|
||||
}
|
||||
|
||||
// Render Expired
|
||||
if (expired && expired.length > 0 && !hideExpired) {
|
||||
html += `<tr class="section-divider"><td colspan="4">Expired Certificates (${expired.length})</td></tr>`;
|
||||
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 `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">${clientName}</div>
|
||||
<div class="certificate-file">${cert.file || 'N/A'}</div>
|
||||
</td>
|
||||
<td>${formatCertDate(cert.not_after)}</td>
|
||||
<td class="fw-semibold ${isExpired ? 'text-danger' :
|
||||
(daysText !== 'N/A' && parseInt(daysText) <= 30 ? 'text-warning' : 'text-success')
|
||||
}">
|
||||
${daysText}
|
||||
</td>
|
||||
<td>${getStatusBadge(cert.days_remaining)}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
269
UI/js/pages/dashboard.js
Normal file
269
UI/js/pages/dashboard.js
Normal file
@@ -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 = '<tr><td colspan="3" class="text-center text-muted py-4">No activity recorded</td></tr>';
|
||||
} 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 = `
|
||||
<tr>
|
||||
<td class="fw-bold" style="color:var(--text-heading)">${c.common_name}</td>
|
||||
<td class="small font-monospace" style="color:var(--text-main)">${formatBytes(c.total_traffic)}</td>
|
||||
<td style="width: 35%; vertical-align:middle;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
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 = `
|
||||
<div class="text-center py-3">
|
||||
<i class="fas fa-check-circle fa-2x mb-2" style="color: var(--success-text); opacity:0.5;"></i>
|
||||
<p class="text-muted small mb-0">No certificates expiring soon</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
expiring.forEach(c => {
|
||||
html += `
|
||||
<div class="cert-item">
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600; color:var(--text-heading); font-size:0.9rem;">${c.common_name || 'Unknown'}</div>
|
||||
<div class="small text-muted">Expires: ${c.not_after}</div>
|
||||
</div>
|
||||
<div><span class="badge badge-soft-warning">${c.days_remaining}</span></div>
|
||||
</div>`;
|
||||
});
|
||||
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
|
||||
});
|
||||
};
|
||||
});
|
||||
365
UI/js/pages/index.js
Normal file
365
UI/js/pages/index.js
Normal file
@@ -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 <i class="fas fa-sort-down ms-1"></i>';
|
||||
thSent.classList.remove('active-sort'); thSent.innerHTML = 'Sent';
|
||||
} else {
|
||||
thSent.classList.add('active-sort'); thSent.innerHTML = 'Sent <i class="fas fa-sort-down ms-1"></i>';
|
||||
thRecv.classList.remove('active-sort'); thRecv.innerHTML = 'Received';
|
||||
}
|
||||
|
||||
if (displayData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">No clients match your filter</td></tr>';
|
||||
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 = `
|
||||
<td colspan="8">
|
||||
<i class="fas ${iconClass} me-2 small"></i>${c.status} Clients
|
||||
</td>
|
||||
`;
|
||||
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
|
||||
? `<span class="${c.current_recv_rate_mbps > 0.01 ? 'text-success fw-bold' : 'text-muted opacity-75'}">
|
||||
${c.current_recv_rate_mbps ? formatRate(c.current_recv_rate_mbps) : '0.000 Mbps'}
|
||||
</span>`
|
||||
: '<span class="text-muted opacity-25">-</span>';
|
||||
|
||||
const upSpeed = isConnected
|
||||
? `<span class="${c.current_sent_rate_mbps > 0.01 ? 'text-primary fw-bold' : 'text-muted opacity-75'}">
|
||||
${c.current_sent_rate_mbps ? formatRate(c.current_sent_rate_mbps) : '0.000 Mbps'}
|
||||
</span>`
|
||||
: '<span class="text-muted opacity-25">-</span>';
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
<a onclick="openHistoryModal('${c.common_name}')" class="client-link">
|
||||
${c.common_name} <i class="fas fa-chart-area ms-1 small opacity-50"></i>
|
||||
</a>
|
||||
</td>
|
||||
<td class="small text-muted">${c.real_address || '-'}</td>
|
||||
<td>
|
||||
<span class="status-badge ${c.status === 'Active' ? 'status-active' : 'status-disconnected'}">
|
||||
${c.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="font-monospace small">${formatBytes(c.total_bytes_received)}</td>
|
||||
<td class="font-monospace small">${formatBytes(c.total_bytes_sent)}</td>
|
||||
<td class="font-monospace small">${downSpeed}</td>
|
||||
<td class="font-monospace small">${upSpeed}</td>
|
||||
<td class="small text-muted">${lastActivity}</td>
|
||||
`;
|
||||
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) : []);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
94
UI/js/utils.js
Normal file
94
UI/js/utils.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user