init commit
This commit is contained in:
269
UI/js/pages/dashboard.js
Normal file
269
UI/js/pages/dashboard.js
Normal file
@@ -0,0 +1,269 @@
|
||||
// dashboard.js - OpenVPN Analytics Dashboard Logic
|
||||
|
||||
// Access globals defined in PHP via window.AppConfig
|
||||
const { apiAnalytics, apiCerts } = window.AppConfig;
|
||||
|
||||
let mainChart = null;
|
||||
let pieChart = null;
|
||||
let globalHistory = [];
|
||||
let vizMode = 'volume'; // 'volume' or 'speed'
|
||||
|
||||
// --- Data Loading ---
|
||||
async function loadData() {
|
||||
const icon = document.getElementById('refreshIcon');
|
||||
icon.classList.add('refresh-indicator');
|
||||
|
||||
// Get selected range
|
||||
const range = document.getElementById('globalRange').value;
|
||||
|
||||
try {
|
||||
const [resA, resC] = await Promise.all([
|
||||
fetch(apiAnalytics + '?range=' + range),
|
||||
fetch(apiCerts)
|
||||
]);
|
||||
|
||||
const jsonA = await resA.json();
|
||||
const jsonC = await resC.json();
|
||||
|
||||
if (jsonA.success) updateDashboard(jsonA.data);
|
||||
if (jsonC.success) updateCerts(jsonC.data);
|
||||
|
||||
} catch (e) {
|
||||
console.error("Dashboard Load Error:", e);
|
||||
} finally {
|
||||
icon.classList.remove('refresh-indicator');
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboard(data) {
|
||||
// 1. KPI
|
||||
document.getElementById('kpiMaxClients').textContent = data.max_concurrent_24h;
|
||||
const totalT = (data.traffic_distribution.rx + data.traffic_distribution.tx);
|
||||
document.getElementById('kpiTotalTraffic').textContent = formatBytes(totalT);
|
||||
|
||||
// 2. Main Chart
|
||||
globalHistory = data.global_history_24h;
|
||||
renderMainChart();
|
||||
|
||||
// 3. Top Clients
|
||||
const tbody = document.getElementById('topClientsTable');
|
||||
tbody.innerHTML = '';
|
||||
if (data.top_clients_24h.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-4">No activity recorded</td></tr>';
|
||||
} else {
|
||||
const maxVal = data.top_clients_24h[0].total_traffic;
|
||||
data.top_clients_24h.forEach(c => {
|
||||
const pct = (c.total_traffic / maxVal) * 100;
|
||||
const row = `
|
||||
<tr>
|
||||
<td class="fw-bold" style="color:var(--text-heading)">${c.common_name}</td>
|
||||
<td class="small font-monospace" style="color:var(--text-main)">${formatBytes(c.total_traffic)}</td>
|
||||
<td style="width: 35%; vertical-align:middle;">
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
tbody.innerHTML += row;
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Pie Chart
|
||||
renderPieChart(data.traffic_distribution);
|
||||
}
|
||||
|
||||
function updateCerts(certs) {
|
||||
const container = document.getElementById('certsList');
|
||||
const alertCountEl = document.getElementById('kpiExpiringCerts');
|
||||
|
||||
// Filter: Active only, Expires in <= 45 days
|
||||
let expiring = certs.filter(c => {
|
||||
if (c.is_expired) return false; // Hide expired as requested
|
||||
const days = parseInt(c.days_remaining);
|
||||
return !isNaN(days) && days <= 45 && days >= 0;
|
||||
});
|
||||
|
||||
expiring.sort((a, b) => parseInt(a.days_remaining) - parseInt(b.days_remaining));
|
||||
|
||||
alertCountEl.textContent = expiring.length;
|
||||
if (expiring.length > 0) alertCountEl.style.color = 'var(--warning-text)';
|
||||
else alertCountEl.style.color = 'var(--text-heading)';
|
||||
|
||||
if (expiring.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-3">
|
||||
<i class="fas fa-check-circle fa-2x mb-2" style="color: var(--success-text); opacity:0.5;"></i>
|
||||
<p class="text-muted small mb-0">No certificates expiring soon</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
expiring.forEach(c => {
|
||||
html += `
|
||||
<div class="cert-item">
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:600; color:var(--text-heading); font-size:0.9rem;">${c.common_name || 'Unknown'}</div>
|
||||
<div class="small text-muted">Expires: ${c.not_after}</div>
|
||||
</div>
|
||||
<div><span class="badge badge-soft-warning">${c.days_remaining}</span></div>
|
||||
</div>`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// --- Charts ---
|
||||
function toggleMainChart() {
|
||||
vizMode = document.getElementById('vizToggle').checked ? 'speed' : 'volume';
|
||||
document.getElementById('vizLabel').textContent = vizMode === 'volume' ? 'Data Volume' : 'Speed (Mbps)';
|
||||
renderMainChart();
|
||||
}
|
||||
|
||||
function getChartColors(isDark) {
|
||||
return {
|
||||
grid: isDark ? 'rgba(240, 246, 252, 0.05)' : 'rgba(0, 0, 0, 0.04)',
|
||||
text: isDark ? '#8b949e' : '#6c757d',
|
||||
bg: isDark ? '#161b22' : '#ffffff',
|
||||
border: isDark ? '#30363d' : '#d0d7de',
|
||||
title: isDark ? '#c9d1d9' : '#24292f'
|
||||
};
|
||||
}
|
||||
|
||||
function renderMainChart() {
|
||||
const ctx = document.getElementById('mainChart').getContext('2d');
|
||||
if (mainChart) mainChart.destroy();
|
||||
|
||||
// Downsample
|
||||
const MAX_POINTS = 48;
|
||||
let displayData = [];
|
||||
|
||||
if (globalHistory.length > MAX_POINTS) {
|
||||
const blockSize = Math.ceil(globalHistory.length / MAX_POINTS);
|
||||
for (let i = 0; i < globalHistory.length; i += blockSize) {
|
||||
const chunk = globalHistory.slice(i, i + blockSize);
|
||||
let rx = 0, tx = 0, rxR = 0, txR = 0;
|
||||
chunk.forEach(p => {
|
||||
rx += p.total_rx; tx += p.total_tx;
|
||||
rxR = Math.max(rxR, p.total_rx_rate || 0);
|
||||
txR = Math.max(txR, p.total_tx_rate || 0);
|
||||
});
|
||||
displayData.push({
|
||||
timestamp: chunk[0].timestamp,
|
||||
rx: rx, tx: tx, rxR: rxR, txR: txR
|
||||
});
|
||||
}
|
||||
} else {
|
||||
displayData = globalHistory.map(p => ({
|
||||
timestamp: p.timestamp,
|
||||
rx: p.total_rx, tx: p.total_tx,
|
||||
rxR: p.total_rx_rate, txR: p.total_tx_rate
|
||||
}));
|
||||
}
|
||||
|
||||
const labels = displayData.map(p => {
|
||||
const d = parseServerDate(p.timestamp);
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
});
|
||||
|
||||
const dsRx = displayData.map(p => vizMode === 'volume' ? p.rx / (1024 * 1024) : p.rxR);
|
||||
const dsTx = displayData.map(p => vizMode === 'volume' ? p.tx / (1024 * 1024) : p.txR);
|
||||
|
||||
const isDark = currentTheme === 'dark';
|
||||
const colors = getChartColors(isDark);
|
||||
|
||||
mainChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Total Download',
|
||||
data: dsRx,
|
||||
borderColor: '#3fb950',
|
||||
backgroundColor: 'rgba(63, 185, 80, 0.15)',
|
||||
borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Total Upload',
|
||||
data: dsTx,
|
||||
borderColor: '#58a6ff',
|
||||
backgroundColor: 'rgba(88, 166, 255, 0.15)',
|
||||
borderWidth: 2, fill: true, tension: 0.3, pointRadius: 0, pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: { legend: { labels: { color: colors.text } } },
|
||||
scales: {
|
||||
x: { ticks: { color: colors.text, maxTicksLimit: 8 }, grid: { color: colors.grid, borderColor: 'transparent' } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: colors.text },
|
||||
grid: { color: colors.grid, borderColor: 'transparent' },
|
||||
title: { display: true, text: vizMode === 'volume' ? 'MB' : 'Mbps', color: colors.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderPieChart(dist) {
|
||||
const ctx = document.getElementById('pieChart').getContext('2d');
|
||||
if (pieChart) pieChart.destroy();
|
||||
|
||||
document.getElementById('pieRxVal').textContent = formatBytes(dist.rx);
|
||||
document.getElementById('pieTxVal').textContent = formatBytes(dist.tx);
|
||||
|
||||
const isDark = currentTheme === 'dark';
|
||||
const colors = getChartColors(isDark);
|
||||
|
||||
pieChart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Download', 'Upload'],
|
||||
datasets: [{
|
||||
data: [dist.rx, dist.tx],
|
||||
backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'],
|
||||
borderColor: colors.bg, // Add border to match background for better separation
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
cutout: '70%'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCharts() {
|
||||
if (globalHistory.length) renderMainChart();
|
||||
// Trigger load to refresh Pie Chart colors
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Expose functions to window for HTML attributes
|
||||
window.loadData = loadData;
|
||||
window.toggleMainChart = toggleMainChart;
|
||||
window.renderCharts = renderCharts;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
loadData();
|
||||
setInterval(loadData, 60000);
|
||||
|
||||
// Override global toggleTheme to update charts
|
||||
// We hook into the global toggleTheme defined in utils.js
|
||||
// but the actual onclick calls toggleTheme().
|
||||
// We need to overwrite window.toggleTheme
|
||||
const _baseToggleTheme = window.toggleTheme;
|
||||
window.toggleTheme = function () {
|
||||
_baseToggleTheme(() => {
|
||||
// Re-render charts with new theme colors
|
||||
if (globalHistory.length) renderMainChart();
|
||||
loadData(); // This refreshes everything including pie chart
|
||||
});
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user