move legacy UI in .php to artifacts

This commit is contained in:
Антон
2026-01-09 21:07:45 +03:00
parent de09326c38
commit 520dd04789
9 changed files with 0 additions and 0 deletions

View 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/artifacts/config.php Normal file
View 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/artifacts/css/style.css Normal file
View 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/artifacts/dashboard.php Normal file
View 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/artifacts/index.php Normal file
View 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>

View 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);
});

View 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
});
};
});

View 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/artifacts/js/utils.js Normal file
View 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;
}
}