366 lines
13 KiB
JavaScript
366 lines
13 KiB
JavaScript
|
|
// 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) : []);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
};
|
||
|
|
});
|