move from PHP to VUE, improved Certificate listning

This commit is contained in:
Антон
2026-01-09 10:30:49 +03:00
parent c9af0a5bb1
commit 9b501a8585
23 changed files with 4235 additions and 3 deletions

View 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>