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

24
UI/client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
UI/client/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
UI/client/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

16
UI/client/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenVPN Monitor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1890
UI/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
UI/client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"bootstrap": "^5.3.8",
"chart.js": "^4.5.1",
"sass": "^1.97.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,8 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>

View File

@@ -0,0 +1,6 @@
{
"api_host": "172.16.5.1",
"api_port": "5001",
"api_base_url": "http://172.16.5.1:5001/api/v1",
"refresh_interval": 30000
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

65
UI/client/src/App.vue Normal file
View File

@@ -0,0 +1,65 @@
<template>
<div v-if="!isLoaded" class="d-flex justify-content-center align-items-center vh-100">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<div v-else class="main-content-wrapper">
<div class="container">
<div class="header">
<div class="d-flex justify-content-between align-items-start flex-wrap">
<div class="mb-3 mb-md-0">
<h1 class="h3 mb-1">OpenVPN Monitor</h1>
<p class="text-muted mb-0">Real-time traffic & connection statistics</p>
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<router-link to="/" class="btn-nav" active-class="active">
<i class="fas fa-list me-2"></i>Clients
</router-link>
<router-link to="/certificates" class="btn-nav" active-class="active">
<i class="fas fa-certificate me-2"></i>Certificates
</router-link>
<router-link to="/analytics" class="btn-nav" active-class="active">
<i class="fas fa-chart-pie me-2"></i>Analytics
</router-link>
<span class="header-timezone">
<i class="fas fa-globe me-1 text-muted"></i>{{ timezoneAbbr }}
</span>
<button class="btn-header" @click="toggleTheme" title="Toggle Theme">
<i class="fas" :class="isDark ? 'fa-sun' : 'fa-moon'" id="themeIcon"></i>
</button>
</div>
</div>
</div>
<router-view></router-view>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useAppConfig } from './composables/useAppConfig';
const { loadConfig, isLoaded } = useAppConfig();
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
const isDark = ref(false);
const toggleTheme = () => {
isDark.value = !isDark.value;
const theme = isDark.value ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
};
onMounted(async () => {
await loadConfig();
// Init Theme
const savedTheme = localStorage.getItem('theme') || 'light';
isDark.value = savedTheme === 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
});
</script>

View File

@@ -0,0 +1,639 @@
/* --- THEME VARIABLES --- */
:root {
/* Light Theme */
--bg-body: #f6f8fa;
--bg-card: #ffffff;
--bg-element: #f6f8fa;
--bg-element-hover: #f1f3f5;
--bg-input: #ffffff;
--text-heading: #24292f;
--text-main: #57606a;
--text-muted: #8c959f;
--border-color: #d0d7de;
--border-subtle: #e9ecef;
--badge-border-active: #92bea5;
--badge-border-disconnected: #d47e80;
--accent-color: #0969da;
--success-bg: rgba(39, 174, 96, 0.15);
--success-text: #1a7f37;
--danger-bg: rgba(231, 76, 60, 0.15);
--danger-text: #cf222e;
--warning-bg: rgba(255, 193, 7, 0.15);
--warning-text: #9a6700;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--toggle-off-bg: #e9ecef;
--toggle-off-border: #d0d7de;
}
/* Dark Theme (Soft & Low Contrast) */
[data-theme="dark"] {
--bg-body: #0d1117;
--bg-card: #161b22;
--bg-element: #21262d;
/* Используем прозрачность для hover, чтобы текст не сливался */
--bg-element-hover: rgba(255, 255, 255, 0.03);
--bg-input: #0d1117;
--text-heading: #e6edf3;
/* Светлее для заголовков */
--text-main: #8b949e;
/* Мягкий серый для текста */
--text-muted: #6e7681;
/* ОЧЕНЬ мягкие границы (8% прозрачности белого) */
--border-color: rgba(240, 246, 252, 0.1);
--border-subtle: rgba(240, 246, 252, 0.05);
--badge-border-active: #3e6f40;
--badge-border-disconnected: #793837;
--accent-color: #58a6ff;
--success-bg: rgba(35, 134, 54, 0.15);
--success-text: #3fb950;
--danger-bg: rgba(218, 54, 51, 0.15);
--danger-text: #f85149;
--warning-bg: rgba(210, 153, 34, 0.15);
--warning-text: #d29922;
--shadow: none;
--toggle-off-bg: rgba(110, 118, 129, 0.1);
--toggle-off-border: rgba(240, 246, 252, 0.1);
}
body {
background-color: var(--bg-body);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 0.9rem;
color: var(--text-main);
padding: 20px 0;
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
max-width: 95%;
margin: 0 auto;
}
@media (min-width: 1400px) {
.container {
max-width: 75%;
}
}
/* Layout Elements */
.header,
.card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: var(--shadow);
margin-bottom: 20px;
transition: border-color 0.3s ease;
}
.header {
padding: 20px;
}
.border-bottom {
border-bottom: 1px solid var(--border-color) !important;
}
.card-header {
background: transparent;
border-bottom: 1px solid var(--border-color);
padding: 15px 20px;
font-weight: 600;
color: var(--text-heading);
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--text-heading);
font-weight: 600;
}
.text-muted {
color: var(--text-muted) !important;
}
/* Blur Effect */
body.modal-open .main-content-wrapper {
filter: blur(8px) grayscale(20%);
transform: scale(0.99);
opacity: 0.6;
transition: all 0.4s ease;
pointer-events: none;
}
.main-content-wrapper {
transition: all 0.4s ease;
transform: scale(1);
filter: blur(0);
opacity: 1;
}
/* Buttons & Controls */
.btn-nav,
.btn-header,
.header-badge,
.header-timezone {
background: var(--bg-element);
border: 1px solid var(--border-color);
color: var(--text-heading);
transition: all 0.2s ease;
font-size: 0.85rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
/* Fixed height for consistency */
vertical-align: middle;
}
.btn-nav:hover,
.btn-header:hover {
background: var(--bg-element-hover);
border-color: var(--text-muted);
color: var(--text-heading);
}
.btn-nav.active {
background: var(--accent-color);
color: #ffffff;
border-color: var(--accent-color);
}
.btn-header {
padding: 0 12px;
border-radius: 6px;
min-width: 36px;
}
.btn-nav {
padding: 0 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
text-decoration: none;
margin-right: 0.5rem;
}
.header-badge,
.header-timezone {
padding: 0 12px;
border-radius: 6px;
display: inline-flex;
align-items: center;
}
/* Stats Cards & KPI */
.stats-info,
.kpi-grid {
display: flex;
gap: 15px;
flex-wrap: wrap;
margin-bottom: 15px;
}
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
}
.stat-item {
background: var(--bg-card);
padding: 15px;
border-radius: 6px;
border: 1px solid var(--border-color);
flex: 1;
min-width: 180px;
box-shadow: var(--shadow);
}
.kpi-grid .stat-item {
padding: 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.stat-value {
font-size: 1.4rem;
font-weight: 600;
color: var(--text-heading);
line-height: 1.2;
}
.kpi-grid .stat-content h3 {
font-size: 1.6rem;
font-weight: 700;
color: var(--text-heading);
margin: 0;
line-height: 1.2;
}
.stat-label {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 5px;
}
/* Tables */
.table {
--bs-table-bg: transparent;
--bs-table-color: var(--text-main);
--bs-table-border-color: var(--border-color);
margin-bottom: 0;
}
.table th {
background-color: var(--bg-card);
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 10;
cursor: pointer;
user-select: none;
padding: 12px 10px;
}
.table th:hover {
color: var(--text-heading);
}
.table th.active-sort {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}
.table td {
padding: 12px 10px;
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
.table-hover tbody tr:hover {
background-color: var(--bg-element-hover);
}
.table-hover tbody tr:hover td,
.table-hover tbody tr:hover .font-monospace {
color: var(--text-heading);
}
.font-monospace {
color: var(--text-main);
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
}
/* Section Divider */
.section-divider td {
background-color: var(--bg-element) !important;
font-weight: 600;
color: var(--text-heading);
padding: 8px 15px;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
font-size: 0.8rem;
}
/* Badges */
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-weight: 500;
font-size: 0.75rem;
border: 1px solid transparent;
}
.status-active,
.status-valid {
background-color: var(--success-bg);
color: var(--success-text);
border-color: var(--badge-border-active);
}
.status-disconnected,
.status-expired {
background-color: var(--danger-bg);
color: var(--danger-text);
border-color: var(--badge-border-disconnected);
}
.status-expiring {
background-color: var(--warning-bg);
color: var(--warning-text);
border: 1px solid transparent;
}
.badge-soft-warning {
background: var(--warning-bg);
color: var(--warning-text);
border: 1px solid transparent;
font-weight: 600;
font-size: 0.75rem;
padding: 5px 10px;
}
.client-link {
color: var(--text-heading);
text-decoration: none;
font-weight: 600;
cursor: pointer;
transition: color 0.2s;
}
.client-link:hover {
color: var(--accent-color);
}
.client-link i {
color: var(--text-muted);
transition: color 0.2s;
}
.client-link:hover i {
color: var(--accent-color);
}
/* Inputs */
.form-control,
.form-select,
.search-input {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
color: var(--text-heading);
border-radius: 6px;
}
.form-control:focus,
.form-select:focus,
.search-input:focus {
background-color: var(--bg-input);
border-color: var(--accent-color);
color: var(--text-heading);
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.15);
outline: none;
}
.form-control::placeholder {
color: var(--text-muted);
opacity: 0.7;
}
.input-group-text {
background-color: var(--bg-element);
border: 1px solid var(--border-color);
color: var(--text-muted);
}
/* Toggle Switch */
.form-check-input {
background-color: var(--toggle-off-bg);
border-color: var(--toggle-off-border);
cursor: pointer;
}
.form-check-input:checked {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
[data-theme="dark"] .form-check-input:not(:checked) {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%238b949e'/%3e%3c/svg%3e");
}
/* Sort Buttons */
.sort-btn-group {
display: flex;
}
.sort-btn {
background: var(--bg-input);
border: 1px solid var(--border-color);
padding: 6px 12px;
font-size: 0.85rem;
color: var(--text-main);
min-width: 100px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.sort-btn:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border-right: none;
}
.sort-btn:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
.sort-btn:hover {
background-color: var(--bg-element-hover);
}
.sort-btn.active {
background-color: var(--bg-element-hover);
color: var(--text-heading);
border-color: var(--text-muted);
font-weight: 600;
z-index: 2;
}
/* Modals */
.modal-content {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-main);
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5);
}
.modal-header {
border-bottom: 1px solid var(--border-color);
}
.modal-footer {
border-top: 1px solid var(--border-color);
}
.btn-close {
filter: var(--btn-close-filter, none);
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
.modal-backdrop.show {
opacity: 0.6;
background-color: #000;
}
.chart-controls {
background: var(--bg-element);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.bg-white-custom {
background-color: var(--bg-input) !important;
border-color: var(--border-color) !important;
}
/* Charts & Dashboard Specifics */
.chart-container {
position: relative;
height: 320px;
width: 100%;
}
.modal-chart-container {
height: 400px;
width: 100%;
position: relative;
}
.pie-container {
position: relative;
height: 220px;
width: 100%;
display: flex;
justify-content: center;
}
.chart-header-controls {
display: flex;
align-items: center;
gap: 15px;
}
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
}
/* Certificates Specifics */
.category-header {
background: linear-gradient(135deg, var(--bg-element), var(--bg-element-hover));
border-left: 4px solid;
padding: 12px 20px;
margin: 0;
font-weight: 600;
}
.category-header.active {
border-left-color: var(--success-text);
color: var(--success-text);
}
.category-header.expired {
border-left-color: var(--danger-text);
color: var(--danger-text);
}
.certificate-file {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 2px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-state i {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}
.cert-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--border-subtle);
}
.cert-item:last-child {
border-bottom: none;
}
/* Utilities */
.refresh-indicator {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 768px) {
.header-controls {
flex-wrap: wrap;
gap: 8px;
}
.stats-info {
flex-direction: column;
}
.chart-controls {
flex-direction: column;
align-items: flex-start;
}
.chart-controls>div {
width: 100%;
}
.kpi-grid {
grid-template-columns: 1fr;
}
.chart-header-controls {
flex-wrap: wrap;
margin-top: 10px;
}
}

View File

@@ -0,0 +1,252 @@
<template>
<Teleport to="body">
<div class="modal fade" id="historyModal" tabindex="-1" aria-hidden="true" ref="modalRef">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-chart-area me-2" style="color: var(--accent-color);"></i>
<span>{{ clientName }}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="chart-controls">
<div class="d-flex align-items-center gap-2">
<label class="text-muted"><i class="far fa-clock me-1"></i> Range:</label>
<select v-model="range" class="form-select form-select-sm" style="width: auto; min-width: 200px;"
@change="loadHistory">
<option value="1h">Last 1 Hour (30s agg)</option>
<option value="3h">Last 3 Hours (1m agg)</option>
<option value="6h">Last 6 Hours (1m agg)</option>
<option value="12h">Last 12 Hours (1m agg)</option>
<option value="24h">Last 24 Hours (1m agg)</option>
<option disabled></option>
<option value="1d">Last 1 Day (15m agg)</option>
<option value="2d">Last 2 Days (15m agg)</option>
<option value="3d">Last 3 Days (15m agg)</option>
<option disabled></option>
<option value="4d">Last 4 Days (1h agg)</option>
<option value="5d">Last 5 Days (1h agg)</option>
<option value="6d">Last 6 Days (1h agg)</option>
<option value="7d">Last 7 Days (1h agg)</option>
<option value="14d">Last 14 Days (1h agg)</option>
<option value="30d">Last 1 Month (1h agg)</option>
</select>
</div>
<div class="d-flex align-items-center gap-3 bg-white-custom px-3 py-1 border rounded">
<span class="small fw-bold text-muted">Metric:</span>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="vizToggle" v-model="isSpeedMode"
@change="renderChart">
<label class="form-check-label text-main" for="vizToggle">
{{ isSpeedMode ? 'Speed (Mbps)' : 'Data Volume' }}
</label>
</div>
</div>
</div>
<div class="modal-chart-container">
<canvas ref="chartCanvas"></canvas>
<div v-if="loading" class="position-absolute top-50 start-50 translate-middle">
<div class="spinner-border text-primary" role="status"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
import Chart from 'chart.js/auto';
import { useApi } from '../composables/useApi';
import { useFormatters } from '../composables/useFormatters';
import { Modal } from 'bootstrap';
const props = defineProps(['modelValue']); // If we wanted v-model control, but using manual open method for now
const { fetchClientHistory } = useApi();
const { parseServerDate } = useFormatters();
const modalRef = ref(null);
const chartCanvas = ref(null);
const clientName = ref('');
const range = ref('24h');
const isSpeedMode = ref(false);
const loading = ref(false);
let bsModal = null;
let chartInstance = null;
let cachedData = null;
const MAX_CHART_POINTS = 48;
const open = (name) => {
clientName.value = name;
range.value = '24h';
isSpeedMode.value = false;
bsModal?.show();
loadHistory();
};
const close = () => {
bsModal?.hide();
};
const loadHistory = async () => {
if (!clientName.value) return;
loading.value = true;
try {
const res = await fetchClientHistory(clientName.value, range.value);
if (res.success && res.data.history) {
cachedData = res.data.history;
renderChart();
} else {
cachedData = [];
if (chartInstance) chartInstance.destroy();
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const renderChart = () => {
if (!chartCanvas.value || !cachedData) return;
if (chartInstance) chartInstance.destroy();
const ctx = chartCanvas.value.getContext('2d');
const downsampled = downsampleData(cachedData, 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.bytes_received_rate_mbps);
dataTx.push(point.bytes_sent_rate_mbps);
}
});
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';
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [
{
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
data: dataRx,
borderColor: '#27ae60',
backgroundColor: 'rgba(39, 174, 96, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.3
},
{
label: !isSpeedMode.value ? '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 }
}
}
}
});
};
const 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;
}
onMounted(() => {
bsModal = new Modal(modalRef.value);
// Clean up chart on close
modalRef.value.addEventListener('hidden.bs.modal', () => {
if (chartInstance) {
chartInstance.destroy();
chartInstance = null;
}
});
});
onUnmounted(() => {
if (chartInstance) chartInstance.destroy();
});
defineExpose({ open, close });
</script>

View File

@@ -0,0 +1,57 @@
import { useAppConfig } from './useAppConfig';
export function useApi() {
const { config } = useAppConfig();
const getBaseUrl = () => {
// Ensure config is loaded or use default/fallback
return config.value?.api_base_url || '/api/v1';
};
const fetchStats = async () => {
try {
const res = await fetch(`${getBaseUrl()}/stats`);
return await res.json();
} catch (e) {
console.error('Fetch Stats Error:', e);
throw e;
}
};
const fetchClientHistory = async (clientId, range) => {
try {
const res = await fetch(`${getBaseUrl()}/stats/${clientId}?range=${range}`);
return await res.json();
} catch (e) {
console.error('Fetch History Error:', e);
throw e;
}
};
const fetchAnalytics = async (range) => {
try {
const res = await fetch(`${getBaseUrl()}/analytics?range=${range}`);
return await res.json();
} catch (e) {
console.error('Fetch Analytics Error:', e);
throw e;
}
};
const fetchCertificates = async () => {
try {
const res = await fetch(`${getBaseUrl()}/certificates`);
return await res.json();
} catch (e) {
console.error('Fetch Certificates Error:', e);
throw e;
}
};
return {
fetchStats,
fetchClientHistory,
fetchAnalytics,
fetchCertificates
};
}

View File

@@ -0,0 +1,28 @@
import { ref } from 'vue';
const config = ref(null);
const isLoaded = ref(false);
export function useAppConfig() {
const loadConfig = async () => {
if (isLoaded.value) return;
try {
const response = await fetch('/config.json');
config.value = await response.json();
isLoaded.value = true;
} catch (error) {
console.error('Failed to load configuration:', error);
// Fallback or critical error handling
config.value = {
api_base_url: 'http://localhost:5001/api/v1',
refresh_interval: 30000
};
}
};
return {
config,
isLoaded,
loadConfig
};
}

View File

@@ -0,0 +1,29 @@
export function useFormatters() {
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function formatRate(rate) {
return parseFloat(rate).toFixed(3) + ' Mbps';
}
function parseServerDate(dateStr) {
if (!dateStr) return null;
let isoStr = dateStr.replace(' ', 'T');
if (!isoStr.endsWith('Z') && !isoStr.includes('+')) {
isoStr += 'Z';
}
return new Date(isoStr);
}
return {
formatBytes,
formatRate,
parseServerDate
};
}

15
UI/client/src/main.js Normal file
View File

@@ -0,0 +1,15 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
// Import Bootstrap CSS and JS
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import '@fortawesome/fontawesome-free/css/all.min.css';
import './assets/main.css';
const app = createApp(App);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,27 @@
import { createRouter, createWebHistory } from 'vue-router';
import Clients from '../views/Clients.vue';
const routes = [
{
path: '/',
name: 'Clients',
component: Clients
},
{
path: '/analytics',
name: 'Analytics',
component: () => import('../views/Analytics.vue')
},
{
path: '/certificates',
name: 'Certificates',
component: () => import('../views/Certificates.vue')
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;

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>

View File

@@ -0,0 +1,289 @@
<template>
<div class="stats-info mb-4" id="statsInfo">
<div class="stat-item">
<div class="stat-value">{{ totalCerts }}</div>
<div class="stat-label">Total Certificates</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ activeCerts.length }}</div>
<div class="stat-label">Active Certificates</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ expiringCount }}</div>
<div class="stat-label">Expiring in 30 days</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ expiredCerts.length }}</div>
<div class="stat-label">Expired Certificates</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
<div class="d-flex gap-3 align-items-center flex-wrap">
<div class="input-group input-group-sm" style="width: 250px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="Search by client name..." v-model="searchQuery">
</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="hideExpired" v-model="hideExpired">
<label class="form-check-label user-select-none text-muted" for="hideExpired">Hide Expired Certificates</label>
</div>
</div>
<div class="card" id="certificatesCard">
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
<div>
<span class="status-badge status-valid me-1">
<i class="fas fa-check-circle me-1"></i><span>{{ activeCerts.length }}</span> Active
</span>
<span class="status-badge status-expired">
<i class="fas fa-times-circle me-1"></i><span>{{ expiredCerts.length }}</span> Expired
</span>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Client Name</th>
<th>Validity Not After</th>
<th>Days Remaining</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="4" class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 mb-0">Loading certificates...</p>
</td>
</tr>
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
<td colspan="4" class="empty-state text-center py-5">
<i class="fas fa-certificate fa-2x mb-3 text-muted"></i>
<p class="text-muted">No certificates found</p>
</td>
</tr>
<template v-else>
<!-- Active Section -->
<tr v-if="activeCerts.length > 0" class="section-divider">
<td colspan="4">Active Certificates ({{ activeCerts.length }})</td>
</tr>
<tr v-for="cert in activeCerts" :key="cert.common_name + '_active'">
<td>
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
</td>
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
{{ cert.days_remaining || 'N/A' }}
</td>
<td v-html="getStatusBadgeHTML(cert.days_remaining)"></td>
</tr>
<!-- Expired Section -->
<template v-if="!hideExpired && expiredCerts.length > 0">
<tr class="section-divider">
<td colspan="4">Expired Certificates ({{ expiredCerts.length }})</td>
</tr>
<tr v-for="cert in expiredCerts" :key="cert.common_name + '_expired'">
<td>
<div class="fw-semibold" style="color: var(--text-heading);">{{ getClientName(cert) }}</div>
<div class="certificate-file text-muted small">{{ cert.file || 'N/A' }}</div>
</td>
<td>{{ formatDate(cert.not_after || cert.expiration_date) }}</td>
<td class="fw-semibold text-danger">
{{ formatExpiredDays(cert.days_remaining) }}
</td>
<td>
<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>
</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useApi } from '../composables/useApi';
import { useFormatters } from '../composables/useFormatters';
const { fetchCertificates } = useApi();
// use locally defined format logic to match legacy specificities if needed, but simple date string is likely fine
const { formatDate: _formatDate } = useFormatters();
const loading = ref(true);
const allCertificates = ref([]);
const searchQuery = ref('');
const hideExpired = ref(false);
const getClientName = (cert) => {
const cn = cert.common_name || cert.subject || 'N/A';
return cn.replace('CN=', '').trim();
}
const formatDate = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return 'N/A';
try {
const d = new Date(dateStr);
if(isNaN(d)) return dateStr;
// Legacy: 'May 16, 2033 11:32:06 AM' format roughly
// Using standard locale string which is close enough and better
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
} catch(e) { return dateStr; }
};
const getDaysClass = (daysText) => {
if (!daysText || daysText === 'N/A') return 'text-success'; // default valid
if (daysText.includes('Expired')) return 'text-danger';
const days = parseInt(daysText);
if (!isNaN(days) && days <= 30) return 'text-warning';
return 'text-success';
};
const getStatusBadgeHTML = (daysText) => {
if (!daysText || daysText === 'N/A') return '<span class="status-badge text-muted">Unknown</span>';
const days = parseInt(daysText);
if (!isNaN(days)) {
if (days <= 30) {
return '<span class="status-badge status-expiring"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
} else {
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Valid</span>';
}
}
return '<span class="status-badge text-muted">Unknown</span>';
};
const formatExpiredDays = (daysText) => {
if(!daysText) return 'N/A';
if (daysText.includes('Expired')) {
return daysText.replace('Expired (', '').replace(' days ago)', '') + ' days ago';
}
return daysText;
};
// Data Processing
const filteredData = computed(() => {
let data = allCertificates.value;
if (searchQuery.value) {
const term = searchQuery.value.toLowerCase();
data = data.filter(c => {
const commonName = (c.common_name || c.subject || '').toLowerCase();
const fileName = (c.file || '').toLowerCase();
return commonName.includes(term) || fileName.includes(term);
});
}
return data;
});
const categorized = computed(() => {
const active = [];
const expired = [];
filteredData.value.forEach(cert => {
let isExpired = false;
if (cert.days_remaining && typeof cert.days_remaining === 'string' && cert.days_remaining.includes('Expired')) {
isExpired = true;
} else if ((!cert.days_remaining || cert.days_remaining === 'N/A') && cert.not_after) {
const expDate = new Date(cert.not_after);
if (expDate < new Date()) isExpired = true;
}
if (isExpired) expired.push(cert);
else active.push(cert);
});
// Sort logic from legacy
active.sort((a,b) => {
const aDays = parseInt(a.days_remaining) || 9999;
const bDays = parseInt(b.days_remaining) || 9999;
return aDays - bDays;
});
expired.sort((a,b) => {
const aMatch = (a.days_remaining||'').match(/\d+/);
const bMatch = (b.days_remaining||'').match(/\d+/);
const aDays = aMatch ? parseInt(aMatch[0]) : 0;
const bDays = bMatch ? parseInt(bMatch[0]) : 0;
return bDays - aDays;
});
return { active, expired };
});
const activeCerts = computed(() => categorized.value.active);
const expiredCerts = computed(() => categorized.value.expired);
const totalCerts = computed(() => allCertificates.value.length);
const expiringCount = computed(() => {
return activeCerts.value.filter(c => {
const days = parseInt(c.days_remaining);
return !isNaN(days) && days <= 30;
}).length;
});
const loadData = async () => {
loading.value = true;
try {
const res = await fetchCertificates();
if(res.success) {
allCertificates.value = res.data;
}
} catch(e) {
console.error(e);
} finally {
loading.value = false;
}
};
onMounted(() => {
loadData();
});
</script>
<style scoped>
.stats-info {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.stat-item {
background: var(--bg-card);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--text-heading);
line-height: 1.2;
}
.stat-label {
color: var(--text-muted);
font-size: 0.875rem;
margin-top: 0.5rem;
}
.section-divider td {
background-color: var(--bg-body);
font-weight: 600;
color: var(--text-muted);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0.75rem 1rem;
}
</style>

View File

@@ -0,0 +1,222 @@
<template>
<div class="stats-info mb-4">
<div class="stat-item">
<div class="stat-value">{{ formatBytes(stats.totalReceived) }}</div>
<div class="stat-label">Total Received</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ formatBytes(stats.totalSent) }}</div>
<div class="stat-label">Total Sent</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ stats.activeClients }}</div>
<div class="stat-label">Active Clients</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-3">
<div class="d-flex gap-3 align-items-center flex-wrap">
<div class="sort-btn-group">
<button class="sort-btn" :class="{ active: currentSort === 'received' }"
@click="currentSort = 'received'">Received</button>
<button class="sort-btn" :class="{ active: currentSort === 'sent' }"
@click="currentSort = 'sent'">Sent</button>
</div>
<div class="input-group input-group-sm" style="width: 250px;">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="Search client..." v-model="searchQuery">
</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="hideDisconnected" v-model="hideDisconnected">
<label class="form-check-label user-select-none text-muted" for="hideDisconnected">Hide Disconnected</label>
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center bg-transparent border-bottom">
<span><i class="fas fa-network-wired me-2"></i>Clients List</span>
<small class="text-muted">Updated: {{ lastUpdated }}</small>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Client Name</th>
<th>Real Address</th>
<th>Status</th>
<th @click="currentSort = 'received'" class="cursor-pointer" :class="{'active-sort': currentSort === 'received'}">
Received <i v-if="currentSort === 'received'" class="fas fa-sort-down ms-1"></i>
</th>
<th @click="currentSort = 'sent'" class="cursor-pointer" :class="{'active-sort': currentSort === 'sent'}">
Sent <i v-if="currentSort === 'sent'" class="fas fa-sort-down ms-1"></i>
</th>
<th>Down Speed</th>
<th>Up Speed</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
<tr v-if="loading && clients.length === 0">
<td colspan="8" class="text-center py-4 text-muted">Loading...</td>
</tr>
<tr v-else-if="filteredClients.length === 0">
<td colspan="8" class="text-center py-4 text-muted">No clients match your filter</td>
</tr>
<template v-else>
<template v-for="(group, index) in groupedClients" :key="index">
<tr class="section-divider">
<td colspan="8">
<i class="fas me-2 small" :class="group.status === 'Active' ? 'fa-circle text-success' : 'fa-circle text-danger'"></i>
{{ group.status }} Clients
</td>
</tr>
<tr v-for="c in group.items" :key="c.common_name">
<td>
<a @click="openHistory(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" :class="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">
<span v-if="c.status === 'Active'" :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 v-else class="text-muted opacity-25">-</span>
</td>
<td class="font-monospace small">
<span v-if="c.status === 'Active'" :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 v-else class="text-muted opacity-25">-</span>
</td>
<td class="small text-muted">{{ formatDate(c.last_activity) }}</td>
</tr>
</template>
</template>
</tbody>
</table>
</div>
</div>
<HistoryModal ref="historyModal" />
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useApi } from '../composables/useApi';
import { useFormatters } from '../composables/useFormatters';
import HistoryModal from '../components/HistoryModal.vue';
import { useAppConfig } from '../composables/useAppConfig';
const { fetchStats } = useApi();
const { formatBytes, formatRate, parseServerDate } = useFormatters();
const { config } = useAppConfig(); // To get refresh interval
const clients = ref([]);
const loading = ref(true);
const lastUpdated = ref('Updating...');
const searchQuery = ref('');
const hideDisconnected = ref(false);
const currentSort = ref('sent');
const historyModal = ref(null);
let intervalId = null;
const stats = computed(() => {
let totalReceived = 0;
let totalSent = 0;
let activeClients = 0;
clients.value.forEach(c => {
totalReceived += c.total_bytes_received || 0;
totalSent += c.total_bytes_sent || 0;
if (c.status === 'Active') activeClients++;
});
return { totalReceived, totalSent, activeClients };
});
const filteredClients = computed(() => {
return clients.value.filter(c => {
if (hideDisconnected.value && c.status !== 'Active') return false;
if (searchQuery.value && !c.common_name.toLowerCase().includes(searchQuery.value.toLowerCase().trim())) return false;
return true;
}).sort((a, b) => {
if (a.status === 'Active' && b.status !== 'Active') return -1;
if (a.status !== 'Active' && b.status === 'Active') return 1;
const valA = currentSort.value === 'received' ? a.total_bytes_received : a.total_bytes_sent;
const valB = currentSort.value === 'received' ? b.total_bytes_received : b.total_bytes_sent;
return valB - valA;
});
});
// Group for the divider headers
const groupedClients = computed(() => {
const groups = [];
let currentStatus = null;
let currentGroup = null;
filteredClients.value.forEach(c => {
if (c.status !== currentStatus) {
currentStatus = c.status;
currentGroup = { status: c.status, items: [] };
groups.push(currentGroup);
}
currentGroup.items.push(c);
});
return groups;
});
const loadData = async () => {
try {
const res = await fetchStats();
if (res.success) {
clients.value = res.data;
lastUpdated.value = new Date().toLocaleTimeString();
}
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const formatDate = (dateStr) => {
if (!dateStr || dateStr === 'N/A') return 'N/A';
const d = parseServerDate(dateStr);
return !isNaN(d) ? d.toLocaleString() : dateStr;
};
const openHistory = (name) => {
historyModal.value.open(name);
};
onMounted(() => {
loadData();
const refreshTime = config.value?.refresh_interval || 30000;
intervalId = setInterval(loadData, refreshTime);
});
onUnmounted(() => {
if (intervalId) clearInterval(intervalId);
});
</script>
<style scoped>
.cursor-pointer {
cursor: pointer;
}
</style>

7
UI/client/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})