move from PHP to VUE, improved Certificate listning
This commit is contained in:
437
UI/client/src/views/Analytics.vue
Normal file
437
UI/client/src/views/Analytics.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div class="kpi-grid mb-4">
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3>{{ kpi.concurrentUsers }}</h3>
|
||||
<p class="stat-label">Concurrent Users (Peak)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3>{{ formatBytes(kpi.totalTraffic) }}</h3>
|
||||
<p class="stat-label">Traffic Volume (Total)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<div class="stat-content">
|
||||
<h3>{{ kpi.expiringCerts }}</h3>
|
||||
<p class="stat-label">Expiring Soon (In 45 Days)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<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;" v-model="range" @change="loadAnalytics">
|
||||
<option value="24h">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" v-model="isSpeedMode"
|
||||
@change="renderMainChart">
|
||||
<label class="form-check-label small fw-bold" style="color: var(--text-heading);" for="vizToggle">
|
||||
Speed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas ref="mainChartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<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>
|
||||
<tr v-if="loading.analytics">
|
||||
<td colspan="3" class="text-center py-4 text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="topClients.length === 0">
|
||||
<td colspan="3" class="text-center py-4 text-muted">No data available</td>
|
||||
</tr>
|
||||
<tr v-else v-for="c in topClients" :key="c.name">
|
||||
<td>
|
||||
<span class="user-select-all fw-bold" style="color: var(--text-heading);">{{ c.name }}</span>
|
||||
</td>
|
||||
<td class="font-monospace text-muted">{{ formatBytes(c.total) }}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress flex-grow-1" style="height: 6px;">
|
||||
<div class="progress-bar" role="progressbar" :style="{ width: c.percent + '%' }"></div>
|
||||
</div>
|
||||
<span class="ms-2 small text-muted w-25 text-end">{{ c.percent }}%</span>
|
||||
</div>
|
||||
</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" style="min-height: 120px; max-height: 200px; overflow-y: auto;">
|
||||
<p v-if="loading.certs" class="text-muted text-center py-3 mb-0">Checking certificates...</p>
|
||||
<p v-else-if="expiringCertsList.length === 0" class="text-muted text-center py-3 mb-0">
|
||||
<i class="fas fa-check-circle text-success me-2"></i>All Good
|
||||
</p>
|
||||
<div v-else class="list-group list-group-flush">
|
||||
<div v-for="cert in expiringCertsList" :key="cert.common_name" class="list-group-item px-0 py-2 d-flex justify-content-between align-items-center border-0">
|
||||
<div>
|
||||
<div class="fw-bold small">{{ cert.common_name }}</div>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">Expires: {{ cert.expiration_date }}</div>
|
||||
</div>
|
||||
<span class="badge bg-warning text-dark">{{ cert.days_left }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
</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" style="min-height: 200px;">
|
||||
<div class="pie-container" style="width: 140px; height: 140px; flex: 0 0 auto;">
|
||||
<canvas ref="pieChartRef"></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);">{{ kpi.totalReceivedString }}</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);">{{ kpi.totalSentString }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
import { useApi } from '../composables/useApi';
|
||||
import { useFormatters } from '../composables/useFormatters';
|
||||
import { useAppConfig } from '../composables/useAppConfig';
|
||||
|
||||
const { fetchAnalytics, fetchCertificates } = useApi();
|
||||
const { formatBytes, parseServerDate } = useFormatters();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Refs
|
||||
const mainChartRef = ref(null);
|
||||
const pieChartRef = ref(null);
|
||||
let mainChartInstance = null;
|
||||
let pieChartInstance = null;
|
||||
|
||||
// State
|
||||
const loading = reactive({ analytics: true, certs: true });
|
||||
const range = ref('24h');
|
||||
const isSpeedMode = ref(false);
|
||||
|
||||
const kpi = reactive({
|
||||
concurrentUsers: '-',
|
||||
totalTraffic: 0,
|
||||
expiringCerts: '-',
|
||||
totalReceived: 0,
|
||||
totalSent: 0,
|
||||
totalReceivedString: '-',
|
||||
totalSentString: '-'
|
||||
});
|
||||
|
||||
const topClients = ref([]);
|
||||
const expiringCertsList = ref([]);
|
||||
let cachedHistory = null;
|
||||
|
||||
// Helpers
|
||||
const MAX_CHART_POINTS = 48;
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
loading.analytics = true;
|
||||
try {
|
||||
const res = await fetchAnalytics(range.value);
|
||||
if (res.success) {
|
||||
proccessData(res.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.analytics = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCerts = async () => {
|
||||
loading.certs = true;
|
||||
try {
|
||||
const res = await fetchCertificates();
|
||||
if(res.success) {
|
||||
const now = new Date();
|
||||
const warningThreshold = new Date();
|
||||
warningThreshold.setDate(now.getDate() + 45);
|
||||
|
||||
let count = 0;
|
||||
const list = [];
|
||||
|
||||
res.data.forEach(cert => {
|
||||
if (cert.status === 'revoked') return;
|
||||
const expDate = new Date(cert.expiration_date); // Assuming API returns ISO or parsable date
|
||||
|
||||
if (expDate <= warningThreshold) {
|
||||
count++;
|
||||
const diffTime = Math.abs(expDate - now);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
list.push({
|
||||
...cert,
|
||||
days_left: diffDays
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
kpi.expiringCerts = count;
|
||||
expiringCertsList.value = list.sort((a,b) => a.days_left - b.days_left);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.certs = false;
|
||||
}
|
||||
};
|
||||
|
||||
const proccessData = (data) => {
|
||||
kpi.concurrentUsers = data.max_concurrent_24h || 0;
|
||||
|
||||
// Traffic Distribution
|
||||
const dist = data.traffic_distribution || { rx: 0, tx: 0 };
|
||||
kpi.totalReceived = Number(dist.rx || 0);
|
||||
kpi.totalSent = Number(dist.tx || 0);
|
||||
kpi.totalTraffic = kpi.totalReceived + kpi.totalSent;
|
||||
|
||||
kpi.totalReceivedString = formatBytes(kpi.totalReceived);
|
||||
kpi.totalSentString = formatBytes(kpi.totalSent);
|
||||
|
||||
// Top Clients
|
||||
topClients.value = (data.top_clients_24h || []).map(c => ({
|
||||
name: c.common_name,
|
||||
total: Number(c.total_traffic || 0),
|
||||
percent: kpi.totalTraffic > 0 ? ((Number(c.total_traffic) / kpi.totalTraffic) * 100).toFixed(1) : 0
|
||||
}));
|
||||
|
||||
cachedHistory = data.global_history_24h || [];
|
||||
|
||||
renderMainChart();
|
||||
renderPieChart();
|
||||
};
|
||||
|
||||
const renderMainChart = () => {
|
||||
if (!mainChartRef.value) return;
|
||||
if (mainChartInstance) mainChartInstance.destroy();
|
||||
|
||||
const downsampled = downsampleData(cachedHistory, MAX_CHART_POINTS);
|
||||
const labels = [];
|
||||
const dataRx = [];
|
||||
const dataTx = [];
|
||||
|
||||
downsampled.forEach(point => {
|
||||
const d = parseServerDate(point.timestamp);
|
||||
let label = '';
|
||||
if(range.value.includes('h') || range.value === '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 (!isSpeedMode.value) {
|
||||
dataRx.push(point.bytes_received / (1024 * 1024)); // MB
|
||||
dataTx.push(point.bytes_sent / (1024 * 1024)); // MB
|
||||
} else {
|
||||
dataRx.push(point.rx_rate_mbps || 0);
|
||||
dataTx.push(point.tx_rate_mbps || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const gridColor = isDark ? 'rgba(240, 246, 252, 0.1)' : 'rgba(0,0,0,0.05)';
|
||||
const textColor = isDark ? '#8b949e' : '#6c757d';
|
||||
|
||||
const ctx = mainChartRef.value.getContext('2d');
|
||||
mainChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
|
||||
data: dataRx,
|
||||
borderColor: '#3fb950', // Legacy Green
|
||||
backgroundColor: 'rgba(63, 185, 80, 0.15)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps',
|
||||
data: dataTx,
|
||||
borderColor: '#58a6ff', // Legacy Blue
|
||||
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: textColor } } },
|
||||
scales: {
|
||||
x: { ticks: { color: textColor, maxTicksLimit: 8 }, grid: { color: gridColor } },
|
||||
y: { beginAtZero: true, ticks: { color: textColor }, grid: { color: gridColor } }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderPieChart = () => {
|
||||
if (!pieChartRef.value) return;
|
||||
if (pieChartInstance) pieChartInstance.destroy();
|
||||
|
||||
const ctx = pieChartRef.value.getContext('2d');
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const bgColor = isDark ? '#161b22' : '#ffffff';
|
||||
|
||||
pieChartInstance = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Received', 'Sent'],
|
||||
datasets: [{
|
||||
data: [kpi.totalReceived, kpi.totalSent],
|
||||
backgroundColor: ['rgba(63, 185, 80, 0.8)', 'rgba(88, 166, 255, 0.8)'],
|
||||
borderColor: bgColor,
|
||||
borderWidth: 2,
|
||||
hoverOffset: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
plugins: { legend: { display: false } }
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downsampleData = (data, maxPoints) => {
|
||||
if (!data || data.length === 0) return [];
|
||||
if (data.length <= maxPoints) {
|
||||
// Map raw fields to internal standardized names
|
||||
return data.map(p => ({
|
||||
timestamp: p.timestamp,
|
||||
bytes_received: p.total_rx,
|
||||
bytes_sent: p.total_tx,
|
||||
rx_rate_mbps: p.total_rx_rate,
|
||||
tx_rate_mbps: p.total_tx_rate
|
||||
}));
|
||||
}
|
||||
|
||||
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 maxRxR = 0, maxTxR = 0;
|
||||
|
||||
chunk.forEach(pt => {
|
||||
sumRx += (pt.total_rx || 0);
|
||||
sumTx += (pt.total_tx || 0);
|
||||
maxRxR = Math.max(maxRxR, pt.total_rx_rate || 0);
|
||||
maxTxR = Math.max(maxTxR, pt.total_tx_rate || 0);
|
||||
});
|
||||
|
||||
result.push({
|
||||
timestamp: chunk[0].timestamp,
|
||||
bytes_received: sumRx,
|
||||
bytes_sent: sumTx,
|
||||
rx_rate_mbps: maxRxR,
|
||||
tx_rate_mbps: maxTxR
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
let intervalId;
|
||||
|
||||
onMounted(() => {
|
||||
loadAnalytics();
|
||||
loadCerts();
|
||||
|
||||
// Support Theme Toggle re-render like in Clients
|
||||
// We observe the attribute on html
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
|
||||
renderMainChart();
|
||||
renderPieChart();
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
|
||||
const refreshTime = config.value?.refresh_interval || 30000;
|
||||
intervalId = setInterval(() => {
|
||||
loadAnalytics();
|
||||
loadCerts();
|
||||
}, refreshTime);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
if (mainChartInstance) mainChartInstance.destroy();
|
||||
if (pieChartInstance) pieChartInstance.destroy();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user