438 lines
15 KiB
Vue
438 lines
15 KiB
Vue
<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">
|
|
<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 = 96;
|
|
|
|
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>
|