move from PHP to VUE, improved Certificate listning
This commit is contained in:
@@ -26,7 +26,9 @@ class OpenVPNAPI:
|
||||
self.config = configparser.ConfigParser()
|
||||
self.config.read(config_file)
|
||||
self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs')
|
||||
self.certificates_path = self.config.get('certificates', 'certificates_path', fallback='/etc/openvpn/certs')
|
||||
self.cert_extensions = self.config.get('certificates', 'certificate_extensions', fallback='crt,pem,key').split(',')
|
||||
self._cert_cache = {} # Cache structure: {filepath: {'mtime': float, 'data': dict}}
|
||||
|
||||
def get_db_connection(self):
|
||||
"""Get a database connection"""
|
||||
@@ -90,13 +92,42 @@ class OpenVPNAPI:
|
||||
def get_certificates_info(self):
|
||||
cert_path = Path(self.certificates_path)
|
||||
if not cert_path.exists(): return []
|
||||
|
||||
cert_files = []
|
||||
for ext in self.cert_extensions:
|
||||
cert_files.extend(cert_path.rglob(f'*.{ext.strip()}'))
|
||||
|
||||
current_valid_files = set()
|
||||
cert_data = []
|
||||
for cert_file in cert_files:
|
||||
data = self.extract_cert_info(str(cert_file))
|
||||
if data: cert_data.append(data)
|
||||
|
||||
for cert_file_path in cert_files:
|
||||
cert_file = str(cert_file_path)
|
||||
current_valid_files.add(cert_file)
|
||||
|
||||
try:
|
||||
mtime = os.path.getmtime(cert_file)
|
||||
|
||||
# Check cache
|
||||
cached = self._cert_cache.get(cert_file)
|
||||
if cached and cached['mtime'] == mtime:
|
||||
cert_data.append(cached['data'])
|
||||
else:
|
||||
# Parse and update cache
|
||||
parsed_data = self.extract_cert_info(cert_file)
|
||||
if parsed_data:
|
||||
self._cert_cache[cert_file] = {
|
||||
'mtime': mtime,
|
||||
'data': parsed_data
|
||||
}
|
||||
cert_data.append(parsed_data)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Prune cache for deleted files
|
||||
for cached_file in list(self._cert_cache.keys()):
|
||||
if cached_file not in current_valid_files:
|
||||
del self._cert_cache[cached_file]
|
||||
|
||||
return cert_data
|
||||
# -----------------------------------------------------------
|
||||
|
||||
|
||||
158
README.md
Normal file
158
README.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# OpenVPN Monitor UI & API
|
||||
|
||||
A modern, reactive dashboard for monitoring OpenVPN server status, traffic history, and certificate validity. Built with Vue.js 3 and Python (Flask).
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
* **Backend**: Python 3.9+ (`pip`, `venv`)
|
||||
* **Frontend**: Node.js 18+ (for building only), any Web Server (Nginx/Apache) for production.
|
||||
|
||||
### 1. Backend Setup
|
||||
Run the API and Data Gatherer.
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update && sudo apt install python3-venv python3-pip
|
||||
|
||||
# Alpine
|
||||
apk add python3 py3-pip
|
||||
|
||||
# Setup
|
||||
cd /path/to/app/APP
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run (Manual testing)
|
||||
python3 openvpn_api_v3.py &
|
||||
python3 openvpn_gatherer_v3.py &
|
||||
```
|
||||
|
||||
### 2. Frontend Setup
|
||||
Build the SPA and deploy to your web server.
|
||||
|
||||
```bash
|
||||
cd /path/to/app/UI/client
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Deploy (Example)
|
||||
sudo cp -r dist/* /var/www/html/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Service Configuration
|
||||
|
||||
### Debian / Ubuntu (Systemd)
|
||||
Create service files in `/etc/systemd/system/`.
|
||||
|
||||
**1. API Service (`/etc/systemd/system/ovpmon-api.service`)**
|
||||
```ini
|
||||
[Unit]
|
||||
Description=OpenVPN Monitor API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
WorkingDirectory=/opt/ovpmon/APP
|
||||
ExecStart=/opt/ovpmon/APP/venv/bin/python3 openvpn_api_v3.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**2. Gatherer Service (`/etc/systemd/system/ovpmon-gatherer.service`)**
|
||||
```ini
|
||||
[Unit]
|
||||
Description=OpenVPN Monitor Data Gatherer
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=root
|
||||
WorkingDirectory=/opt/ovpmon/APP
|
||||
ExecStart=/opt/ovpmon/APP/venv/bin/python3 openvpn_gatherer_v3.py
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Enable & Start:**
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now ovpmon-api ovpmon-gatherer
|
||||
```
|
||||
|
||||
### Alpine Linux (OpenRC)
|
||||
For Alpine, create scripts in `/etc/init.d/` (e.g., `ovpmon-api`) using `openrc-run`.
|
||||
```bash
|
||||
#!/sbin/openrc-run
|
||||
description="OpenVPN Monitor API"
|
||||
command="/opt/ovpmon/APP/venv/bin/python3"
|
||||
command_args="/opt/ovpmon/APP/openvpn_api_v3.py"
|
||||
directory="/opt/ovpmon/APP"
|
||||
command_background=true
|
||||
pidfile="/run/ovpmon-api.pid"
|
||||
```
|
||||
Make executable (`chmod +x`) and start: `rc-service ovpmon-api start`.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 Web Server Configuration
|
||||
|
||||
**Recommendation: Nginx** is preferred for its performance and simple SPA configuration (`try_files`).
|
||||
|
||||
### Nginx Config
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name vpn-monitor.local;
|
||||
root /var/www/html;
|
||||
index index.html;
|
||||
|
||||
# SPA Fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests (Optional, if not exposing 5001 directly)
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:5001;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Apache Config
|
||||
Ensure `mod_rewrite` is enabled. The project includes a `.htaccess` file for routing.
|
||||
**VirtualHost Config:**
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
DocumentRoot "/var/www/html"
|
||||
<Directory "/var/www/html">
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All # CRITICAL for .htaccess
|
||||
Require all granted
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API Reference
|
||||
|
||||
**Base URL:** `http://<server-ip>:5001/api/v1`
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| **GET** | `/stats` | Current status of all clients (Real-time). |
|
||||
| **GET** | `/stats/system` | Server-wide totals (Total traffic, active count). |
|
||||
| **GET** | `/stats/<common_name>` | Detailed client stats + History. Params: `range` (24h, 7d), `resolution`. |
|
||||
| **GET** | `/certificates` | List of all certificates with expiration status. **Cached (Fast)**. |
|
||||
| **GET** | `/analytics` | Dashboard data (Trends, Traffic distribution, Top clients). |
|
||||
| **GET** | `/health` | API Health check. |
|
||||
|
||||
---
|
||||
*Generated by Antigravity Agent*
|
||||
24
UI/client/.gitignore
vendored
Normal file
24
UI/client/.gitignore
vendored
Normal 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
3
UI/client/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
UI/client/README.md
Normal file
5
UI/client/README.md
Normal 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
16
UI/client/index.html
Normal 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
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
23
UI/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
UI/client/public/.htaccess
Normal file
8
UI/client/public/.htaccess
Normal 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>
|
||||
6
UI/client/public/config.json
Normal file
6
UI/client/public/config.json
Normal 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
|
||||
}
|
||||
1
UI/client/public/vite.svg
Normal file
1
UI/client/public/vite.svg
Normal 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
65
UI/client/src/App.vue
Normal 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>
|
||||
639
UI/client/src/assets/main.css
Normal file
639
UI/client/src/assets/main.css
Normal 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;
|
||||
}
|
||||
}
|
||||
252
UI/client/src/components/HistoryModal.vue
Normal file
252
UI/client/src/components/HistoryModal.vue
Normal 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>
|
||||
57
UI/client/src/composables/useApi.js
Normal file
57
UI/client/src/composables/useApi.js
Normal 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
|
||||
};
|
||||
}
|
||||
28
UI/client/src/composables/useAppConfig.js
Normal file
28
UI/client/src/composables/useAppConfig.js
Normal 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
|
||||
};
|
||||
}
|
||||
29
UI/client/src/composables/useFormatters.js
Normal file
29
UI/client/src/composables/useFormatters.js
Normal 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
15
UI/client/src/main.js
Normal 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');
|
||||
27
UI/client/src/router/index.js
Normal file
27
UI/client/src/router/index.js
Normal 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;
|
||||
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>
|
||||
289
UI/client/src/views/Certificates.vue
Normal file
289
UI/client/src/views/Certificates.vue
Normal 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>
|
||||
222
UI/client/src/views/Clients.vue
Normal file
222
UI/client/src/views/Clients.vue
Normal 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
7
UI/client/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Reference in New Issue
Block a user