move legacy UI in .php to artifacts
This commit is contained in:
228
UI/artifacts/js/pages/certificates.js
Normal file
228
UI/artifacts/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/artifacts/js/pages/dashboard.js
Normal file
269
UI/artifacts/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/artifacts/js/pages/index.js
Normal file
365
UI/artifacts/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) : []);
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user