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 = configparser.ConfigParser()
|
||||||
self.config.read(config_file)
|
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.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_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):
|
def get_db_connection(self):
|
||||||
"""Get a database connection"""
|
"""Get a database connection"""
|
||||||
@@ -90,13 +92,42 @@ class OpenVPNAPI:
|
|||||||
def get_certificates_info(self):
|
def get_certificates_info(self):
|
||||||
cert_path = Path(self.certificates_path)
|
cert_path = Path(self.certificates_path)
|
||||||
if not cert_path.exists(): return []
|
if not cert_path.exists(): return []
|
||||||
|
|
||||||
cert_files = []
|
cert_files = []
|
||||||
for ext in self.cert_extensions:
|
for ext in self.cert_extensions:
|
||||||
cert_files.extend(cert_path.rglob(f'*.{ext.strip()}'))
|
cert_files.extend(cert_path.rglob(f'*.{ext.strip()}'))
|
||||||
|
|
||||||
|
current_valid_files = set()
|
||||||
cert_data = []
|
cert_data = []
|
||||||
for cert_file in cert_files:
|
|
||||||
data = self.extract_cert_info(str(cert_file))
|
for cert_file_path in cert_files:
|
||||||
if data: cert_data.append(data)
|
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
|
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