new awesome build
This commit is contained in:
179
APP_UI/src/App.vue
Normal file
179
APP_UI/src/App.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<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="app-wrapper" :class="{ 'mobile-nav-active': isSidebarOpen, 'sidebar-compact': isCompact, 'no-sidebar': !isAuthenticated }">
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="mobile-overlay" @click="isSidebarOpen = false" v-if="isSidebarOpen && isAuthenticated"></div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'compact': isCompact }">
|
||||
<div class="sidebar-header">
|
||||
<img src="./assets/logo.svg" alt="OpenVPN" class="sidebar-brand-icon">
|
||||
<span class="brand-text">VPN Controller</span>
|
||||
|
||||
<button class="btn btn-link text-muted d-md-none ms-auto" @click="isSidebarOpen = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-menu">
|
||||
|
||||
<!-- SECTION: MONITORING -->
|
||||
<div class="nav-section-header">MONITORING</div>
|
||||
<p></p>
|
||||
<router-link to="/" class="nav-link" active-class="active" title="Analytics">
|
||||
<i class="fas fa-chart-line"></i> <span>Analytics</span>
|
||||
</router-link>
|
||||
<router-link to="/clients" class="nav-link" active-class="active" title="Clients">
|
||||
<i class="fas fa-network-wired"></i> <span>Clients</span>
|
||||
</router-link>
|
||||
|
||||
<!-- SECTION: CONFIGURATION -->
|
||||
<div class="nav-section-header mt-3">CONFIGURATION</div>
|
||||
<p></p>
|
||||
<router-link to="/config/pki" class="nav-link" active-class="active" title="PKI">
|
||||
<i class="fas fa-fingerprint"></i> <span>PKI</span>
|
||||
</router-link>
|
||||
<router-link to="/config/vpn" class="nav-link" active-class="active" title="VPN">
|
||||
<i class="fas fa-server"></i> <span>VPN</span>
|
||||
</router-link>
|
||||
|
||||
<!-- SECTION: MANAGEMENT -->
|
||||
<div class="nav-section-header mt-3">MANAGEMENT</div>
|
||||
<p></p>
|
||||
<router-link to="/certificates" class="nav-link" active-class="active" title="Certificates">
|
||||
<i class="fas fa-certificate"></i> <span>Certificates</span>
|
||||
</router-link>
|
||||
<router-link to="/server" class="nav-link" active-class="active" title="Server Process">
|
||||
<i class="fas fa-terminal"></i> <span>Server Process</span>
|
||||
</router-link>
|
||||
|
||||
<!-- SECTION: ACCOUNT -->
|
||||
<div class="nav-section-header mt-3">ACCOUNT</div>
|
||||
<p></p>
|
||||
<router-link to="/account" class="nav-link" active-class="active" title="Account Settings">
|
||||
<i class="fas fa-user-cog"></i> <span>Account Settings</span>
|
||||
</router-link>
|
||||
|
||||
<!-- SETTINGS (General/App) -->
|
||||
<div class="mt-auto"></div>
|
||||
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn btn-link toggle-btn d-none d-md-block w-100" @click="toggleSidebar" title="Toggle Sidebar">
|
||||
<i class="fas" :class="isCompact ? 'fa-chevron-right' : 'fa-chevron-left'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Top Navbar -->
|
||||
<header class="top-navbar" v-if="isAuthenticated">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button class="btn-header d-md-none" @click="isSidebarOpen = !isSidebarOpen">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="page-title">
|
||||
<!-- Dynamic Title Could Go Here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="navbar-actions">
|
||||
<span class="header-timezone d-none d-lg-inline-flex">
|
||||
<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>
|
||||
<button class="btn-header me-2" @click="refreshPage" title="Refresh">
|
||||
<i class="fas fa-sync-alt" id="refreshIcon"></i>
|
||||
</button>
|
||||
|
||||
<div class="header-divider d-none d-md-block"></div>
|
||||
|
||||
<div class="user-profile ms-2">
|
||||
<div class="user-avatar-small bg-primary text-white">
|
||||
{{ username[0]?.toUpperCase() || 'A' }}
|
||||
</div>
|
||||
<div class="user-meta d-none d-md-block">
|
||||
<span class="username">{{ username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-header btn-logout ms-2" @click="handleLogout" title="Logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="content-wrapper">
|
||||
<router-view :key="$route.fullPath + '-' + refreshKey"></router-view>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, computed } from 'vue';
|
||||
import { useAppConfig } from './composables/useAppConfig';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const { loadConfig, isLoaded } = useAppConfig();
|
||||
const timezoneAbbr = ref(new Date().toLocaleTimeString('en-us',{timeZoneName:'short'}).split(' ')[2] || 'UTC');
|
||||
const isDark = ref(false);
|
||||
const refreshKey = ref(0);
|
||||
const isSidebarOpen = ref(false);
|
||||
const isCompact = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isAuthenticated = computed(() => route.name !== 'Login');
|
||||
const username = ref(localStorage.getItem('ovpmon_user') || 'Admin');
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('ovpmon_token');
|
||||
localStorage.removeItem('ovpmon_user');
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value;
|
||||
const theme = isDark.value ? 'dark' : 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isCompact.value = !isCompact.value;
|
||||
localStorage.setItem('sidebarCompact', isCompact.value);
|
||||
};
|
||||
|
||||
const refreshPage = () => {
|
||||
refreshKey.value++;
|
||||
};
|
||||
|
||||
// Close sidebar on route change
|
||||
watch(() => route.path, () => {
|
||||
isSidebarOpen.value = false;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadConfig();
|
||||
|
||||
// Init Theme
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
isDark.value = savedTheme === 'dark';
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
|
||||
// Init Sidebar
|
||||
const savedCompact = localStorage.getItem('sidebarCompact');
|
||||
isCompact.value = savedCompact === 'true';
|
||||
});
|
||||
</script>
|
||||
4
APP_UI/src/assets/logo.svg
Normal file
4
APP_UI/src/assets/logo.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M78.8326 58.0426C78.8765 48.6428 71.4389 40.699 61.5235 39.5555C51.6081 38.4119 42.3519 44.4304 39.982 53.5619C37.6121 62.6934 42.8784 72.049 52.2379 75.3346L44.6584 113.322H73.7264L66.2799 75.2595C73.8362 72.5045 78.8207 65.6679 78.8326 58.0426Z" fill="white"/>
|
||||
<path d="M118.249 57.7933C117.583 25.6833 91.3083 0 59.1246 0C26.9408 0 0.665851 25.6833 3.09067e-08 57.7933C-0.000675955 78.6062 11.0875 97.8498 29.1129 108.319L32.9103 83.2079C27.0781 76.837 23.8452 68.5192 23.8473 59.8902C24.3594 40.8148 40.0026 25.6167 59.1246 25.6167C78.2465 25.6167 93.8897 40.8148 94.4018 59.8902C94.405 68.5881 91.1238 76.9678 85.2123 83.3594L88.9843 108.395C107.107 97.9657 118.266 78.67 118.249 57.7933Z" fill="#ED7F22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 828 B |
4
APP_UI/src/assets/logo_dark.svg
Normal file
4
APP_UI/src/assets/logo_dark.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="119" height="119" viewBox="0 0 119 119" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M78.8326 58.0426C78.8765 48.6428 71.4389 40.699 61.5235 39.5555C51.6081 38.4119 42.3519 44.4304 39.982 53.5619C37.6121 62.6934 42.8784 72.049 52.2379 75.3346L44.6584 113.322H73.7264L66.2799 75.2595C73.8362 72.5045 78.8207 65.6679 78.8326 58.0426Z" fill="#1A3967"/>
|
||||
<path d="M118.249 57.7933C117.583 25.6833 91.3083 0 59.1246 0C26.9408 0 0.665851 25.6833 3.09067e-08 57.7933C-0.000675955 78.6062 11.0875 97.8498 29.1129 108.319L32.9103 83.2079C27.0781 76.837 23.8452 68.5192 23.8473 59.8902C24.3594 40.8148 40.0026 25.6167 59.1246 25.6167C78.2465 25.6167 93.8897 40.8148 94.4018 59.8902C94.405 68.5881 91.1238 76.9678 85.2123 83.3594L88.9843 108.395C107.107 97.9657 118.266 78.67 118.249 57.7933Z" fill="#ED7F22"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 830 B |
1819
APP_UI/src/assets/main.css
Normal file
1819
APP_UI/src/assets/main.css
Normal file
File diff suppressed because it is too large
Load Diff
76
APP_UI/src/components/BaseModal.vue
Normal file
76
APP_UI/src/components/BaseModal.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal fade" :id="id" tabindex="-1" aria-hidden="true" ref="modalRef">
|
||||
<div class="modal-dialog modal-dialog-centered" :class="sizeClass">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<slot name="header">
|
||||
<h5 class="modal-title fw-bold" style="color: var(--text-heading);">{{ title }}</h5>
|
||||
</slot>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Modal } from 'bootstrap';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: '' // 'modal-sm', 'modal-lg', 'modal-xl'
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'opened', 'closed']);
|
||||
|
||||
const modalRef = ref(null);
|
||||
let bsModal = null;
|
||||
const sizeClass = props.size || '';
|
||||
|
||||
const show = () => {
|
||||
bsModal?.show();
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
bsModal?.hide();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
bsModal = new Modal(modalRef.value);
|
||||
|
||||
modalRef.value.addEventListener('shown.bs.modal', () => {
|
||||
emit('opened');
|
||||
});
|
||||
|
||||
modalRef.value.addEventListener('hidden.bs.modal', () => {
|
||||
emit('closed');
|
||||
emit('close');
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bsModal?.dispose();
|
||||
});
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
|
||||
49
APP_UI/src/components/ConfirmModal.vue
Normal file
49
APP_UI/src/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<BaseModal id="confirmModal" :title="title" ref="modal">
|
||||
<template #body>
|
||||
<p class="text-main">{{ message }}</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button v-if="showCancel" type="button" class="btn-action btn-action-secondary" @click="close">Cancel</button>
|
||||
<button type="button" class="btn-action" :class="confirmBtnClass" @click="confirm">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
const modal = ref(null);
|
||||
|
||||
const title = ref('');
|
||||
const message = ref('');
|
||||
const confirmText = ref('Confirm');
|
||||
const confirmBtnClass = ref('btn-action-danger');
|
||||
const showCancel = ref(true);
|
||||
const data = ref(null);
|
||||
|
||||
const open = (opts) => {
|
||||
title.value = opts.title || 'Are you sure?';
|
||||
message.value = opts.message || '';
|
||||
confirmText.value = opts.confirmText || 'Confirm';
|
||||
confirmBtnClass.value = opts.confirmBtnClass || 'btn-action-danger';
|
||||
showCancel.value = opts.showCancel !== false;
|
||||
data.value = opts.data || null;
|
||||
modal.value.show();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
modal.value.hide();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm', data.value);
|
||||
close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
249
APP_UI/src/components/HistoryModal.vue
Normal file
249
APP_UI/src/components/HistoryModal.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<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 text-primary"></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="24h">Last 24 Hours (15m agg)</option>
|
||||
<option value="7d">Last 7 Days (1h agg)</option>
|
||||
<option value="30d">Last 30 Days (6h 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="d-flex align-items-center">
|
||||
<div class="toggle-wrapper me-2">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="vizToggle" v-model="isSpeedMode" @change="renderChart">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="text-main user-select-none" style="cursor: pointer;" 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: '#1652B8', // OpenVPN Blue
|
||||
backgroundColor: 'rgba(22, 82, 184, 0.15)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps',
|
||||
data: dataTx,
|
||||
borderColor: '#EC7C31', // OpenVPN Orange
|
||||
backgroundColor: 'rgba(236, 124, 49, 0.15)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
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 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>
|
||||
52
APP_UI/src/components/NewClientModal.vue
Normal file
52
APP_UI/src/components/NewClientModal.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<BaseModal id="newClientModal" title="Create New Client" ref="modal">
|
||||
<template #body>
|
||||
<div class="mb-3">
|
||||
<label for="clientName" class="form-label small text-muted text-uppercase fw-bold">Client Name</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
||||
<input type="text" class="form-control" id="clientName" v-model="clientName" placeholder="e.g. laptop-user" @keyup.enter="confirm" ref="inputRef">
|
||||
</div>
|
||||
<div class="form-text mt-2 text-muted small">
|
||||
<i class="fas fa-info-circle me-1"></i> Use only alphanumeric characters, dashes, or underscores.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="btn-action btn-action-secondary" @click="close">Cancel</button>
|
||||
<button type="button" class="btn-action btn-action-save" @click="confirm" :disabled="!clientName">
|
||||
Create Client
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const emit = defineEmits(['create']);
|
||||
const modal = ref(null);
|
||||
const clientName = ref('');
|
||||
const inputRef = ref(null);
|
||||
|
||||
const open = () => {
|
||||
clientName.value = '';
|
||||
modal.value.show();
|
||||
setTimeout(() => inputRef.value?.focus(), 500);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
modal.value.hide();
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (clientName.value) {
|
||||
emit('create', clientName.value);
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
73
APP_UI/src/composables/useApi.js
Normal file
73
APP_UI/src/composables/useApi.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import axios from 'axios';
|
||||
import { useAppConfig } from './useAppConfig';
|
||||
|
||||
// Singleton instances
|
||||
const apiClient = axios.create();
|
||||
const profilesApiClient = axios.create();
|
||||
|
||||
// Helper to get base URLs
|
||||
const getBaseUrl = (config) => config?.api_base_url || 'http://localhost:5001/api/v1';
|
||||
const getProfilesBaseUrl = (config) => config?.profiles_api_base_url || 'http://localhost:8000';
|
||||
|
||||
// Add interceptors to handle Auth and Dynamic Base URLs
|
||||
const setupInterceptors = (instance, getBase) => {
|
||||
instance.interceptors.request.use((reqConfig) => {
|
||||
const { config } = useAppConfig();
|
||||
reqConfig.baseURL = getBase(config.value);
|
||||
|
||||
const token = localStorage.getItem('ovpmon_token');
|
||||
if (token) {
|
||||
reqConfig.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return reqConfig;
|
||||
});
|
||||
|
||||
instance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('ovpmon_token');
|
||||
localStorage.removeItem('ovpmon_user');
|
||||
// Redirecting using window.location for absolute refresh
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
setupInterceptors(apiClient, getBaseUrl);
|
||||
setupInterceptors(profilesApiClient, getProfilesBaseUrl);
|
||||
|
||||
export function useApi() {
|
||||
const fetchStats = async () => {
|
||||
const res = await apiClient.get('/stats');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const fetchClientHistory = async (clientId, range) => {
|
||||
const res = await apiClient.get(`/stats/${clientId}`, { params: { range } });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const fetchAnalytics = async (range) => {
|
||||
const res = await apiClient.get('/analytics', { params: { range } });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const fetchCertificates = async () => {
|
||||
const res = await apiClient.get('/certificates');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
return {
|
||||
apiClient,
|
||||
profilesApiClient,
|
||||
fetchStats,
|
||||
fetchClientHistory,
|
||||
fetchAnalytics,
|
||||
fetchCertificates
|
||||
};
|
||||
}
|
||||
28
APP_UI/src/composables/useAppConfig.js
Normal file
28
APP_UI/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
|
||||
};
|
||||
}
|
||||
43
APP_UI/src/composables/useFormatters.js
Normal file
43
APP_UI/src/composables/useFormatters.js
Normal file
@@ -0,0 +1,43 @@
|
||||
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;
|
||||
// Handle ISO strings with Z
|
||||
if (dateStr.endsWith('Z')) return new Date(dateStr);
|
||||
|
||||
// Assume format YYYY-MM-DD HH:MM:SS (standard backend output)
|
||||
const [datePart, timePart] = dateStr.split(' ');
|
||||
if (!datePart || !timePart) {
|
||||
// Fallback for other formats
|
||||
let isoStr = dateStr.replace(' ', 'T');
|
||||
if (!isoStr.endsWith('Z') && !isoStr.includes('+')) {
|
||||
isoStr += 'Z';
|
||||
}
|
||||
return new Date(isoStr);
|
||||
}
|
||||
|
||||
const [y, m, d] = datePart.split('-').map(Number);
|
||||
const [h, min, s] = timePart.split(':').map(Number);
|
||||
|
||||
// Construct Date in UTC
|
||||
return new Date(Date.UTC(y, m - 1, d, h, min, s !== undefined ? s : 0));
|
||||
}
|
||||
|
||||
return {
|
||||
formatBytes,
|
||||
formatRate,
|
||||
parseServerDate
|
||||
};
|
||||
}
|
||||
32
APP_UI/src/main.js
Normal file
32
APP_UI/src/main.js
Normal file
@@ -0,0 +1,32 @@
|
||||
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';
|
||||
import axios from 'axios';
|
||||
|
||||
// Global Axios Defaults (No Base URL - handled by useApi.js)
|
||||
// We keep global error handling for 401s as a fallback
|
||||
axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem('ovpmon_token');
|
||||
localStorage.removeItem('ovpmon_user');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
67
APP_UI/src/router/index.js
Normal file
67
APP_UI/src/router/index.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import Clients from '../views/Clients.vue';
|
||||
import Login from '../views/Login.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Analytics',
|
||||
component: () => import('../views/Analytics.vue')
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
name: 'Clients',
|
||||
component: Clients
|
||||
},
|
||||
{
|
||||
path: '/certificates',
|
||||
name: 'Certificates',
|
||||
component: () => import('../views/Certificates.vue')
|
||||
},
|
||||
|
||||
{
|
||||
path: '/config/pki',
|
||||
name: 'PKIConfig',
|
||||
component: () => import('../views/PKIConfig.vue')
|
||||
},
|
||||
{
|
||||
path: '/config/vpn',
|
||||
name: 'VPNConfig',
|
||||
component: () => import('../views/VPNConfig.vue')
|
||||
},
|
||||
{
|
||||
path: '/server',
|
||||
name: 'ServerManagement',
|
||||
component: () => import('../views/ServerManagement.vue')
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
component: () => import('../views/Account.vue')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login,
|
||||
meta: { public: true }
|
||||
}
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isAuthenticated = !!localStorage.getItem('ovpmon_token');
|
||||
|
||||
if (!to.meta.public && !isAuthenticated) {
|
||||
next({ name: 'Login' });
|
||||
} else if (to.name === 'Login' && isAuthenticated) {
|
||||
next({ name: 'Analytics' });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
345
APP_UI/src/views/Account.vue
Normal file
345
APP_UI/src/views/Account.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="user-account-container mx-auto" style="max-width: 1000px;">
|
||||
<div class="row g-4">
|
||||
<!-- Security Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-user-shield me-2"></i>Security Settings</span>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center py-4 d-flex flex-column align-items-center">
|
||||
<div class="card-interior-icon">
|
||||
<i class="fas fa-user-check fa-3x text-primary"></i>
|
||||
</div>
|
||||
<div class="card-interior-title">
|
||||
<h4 class="m-0 text-heading">Update Credentials</h4>
|
||||
</div>
|
||||
<div class="card-interior-description">
|
||||
<p class="text-muted m-0">Maintain your account security by updating your password regularly to prevent unauthorized access.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="showPwModal">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card p-0 h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-key me-2"></i>Two-Factor Authentication</span>
|
||||
<span v-if="isEnabled" class="status-badge status-valid">
|
||||
<i class="fas fa-check-circle me-1"></i>Active
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<div v-if="!isEnabled">
|
||||
<div v-if="!step2" class="text-center py-4 d-flex flex-column align-items-center">
|
||||
<div class="card-interior-icon">
|
||||
<i class="fas fa-shield-alt fa-3x text-primary"></i>
|
||||
</div>
|
||||
<div class="card-interior-title">
|
||||
<h4 class="m-0 text-heading">Secure Your Account</h4>
|
||||
</div>
|
||||
<div class="card-interior-description">
|
||||
<p class="text-muted m-0">Protect your OpenVPN Monitor dashboard with second-layer security using Google Authenticator or any TOTP app.</p>
|
||||
</div>
|
||||
<button class="btn btn-action btn-action-primary py-2 fw-bold btn-account-action" @click="initSetup" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Start Setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="setup-steps fade-in">
|
||||
<div class="step-item mb-5 text-center">
|
||||
<h5 class="mb-3 text-heading">1. Scan the QR Code</h5>
|
||||
<p class="text-muted small mb-4">Open your authenticator app and scan this code or manually enter the secret.</p>
|
||||
|
||||
<div class="qr-setup-container mx-auto">
|
||||
<div class="qr-code-wrapper p-3 d-inline-block rounded shadow-sm bg-white">
|
||||
<qrcode-vue :value="setupData.uri" :size="150" level="H" :background="'#ffffff'" foreground="#24292f" />
|
||||
</div>
|
||||
|
||||
<div class="secret-box mt-3">
|
||||
<div class="small fw-bold text-muted mb-2">Manual Secret:</div>
|
||||
<div class="user-select-all font-monospace p-2 secret-value-box rounded small d-inline-block w-100">
|
||||
{{ setupData.secret }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-item text-center">
|
||||
<h5 class="mb-3 text-heading">2. Verify Connection</h5>
|
||||
<div class="verification-group mx-auto" style="max-width: 300px;">
|
||||
<div class="input-group mb-3 shadow-sm rounded">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control text-center fw-bold"
|
||||
v-model="otp"
|
||||
placeholder="000000"
|
||||
maxlength="6"
|
||||
>
|
||||
<button class="btn btn-action btn-action-primary px-4 fw-bold" @click="verifyAndEnable" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-4 d-flex flex-column align-items-center">
|
||||
<div class="card-interior-icon">
|
||||
<i class="fas fa-key fa-3x text-primary"></i>
|
||||
</div>
|
||||
<div class="card-interior-title">
|
||||
<h4 class="m-0 text-heading">2FA is Enabled</h4>
|
||||
</div>
|
||||
<div class="card-interior-description">
|
||||
<p class="text-muted m-0">Your dashboard is now protected with two-factor authentication.</p>
|
||||
</div>
|
||||
<button class="btn btn-action btn-action-danger py-2 fw-bold btn-account-action" @click="disableConfirm">
|
||||
<i class="fas fa-ban me-2"></i>Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerts/Notifications Modal -->
|
||||
<ConfirmModal ref="confirmModal" />
|
||||
|
||||
<!-- Action Confirmations Modal -->
|
||||
<ConfirmModal ref="disable2FAModal" @confirm="handleDisable2FA" />
|
||||
|
||||
<!-- Password Change Modal -->
|
||||
<BaseModal id="passwordChangeModal" title="Change Account Password" ref="pwModal">
|
||||
<template #body>
|
||||
<form @submit.prevent="handleChangePassword" id="pwForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="passwordForm.current_password"
|
||||
placeholder="Enter current password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="passwordForm.new_password"
|
||||
placeholder="Min. 8 characters"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted text-uppercase">Confirm New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="passwordForm.confirm_password"
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button type="button" class="btn-action btn-action-secondary" @click="pwModal.hide()">Cancel</button>
|
||||
<button type="submit" form="pwForm" class="btn-action btn-action-save" :disabled="pwLoading">
|
||||
<span v-if="pwLoading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Update Password
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
import { useApi } from '../composables/useApi';
|
||||
import QrcodeVue from 'qrcode.vue';
|
||||
import Swal from 'sweetalert2';
|
||||
import BaseModal from '../components/BaseModal.vue';
|
||||
import ConfirmModal from '../components/ConfirmModal.vue';
|
||||
|
||||
const { apiClient } = useApi();
|
||||
|
||||
// UI Refs
|
||||
const pwModal = ref(null);
|
||||
const confirmModal = ref(null);
|
||||
const disable2FAModal = ref(null);
|
||||
|
||||
// Password Logic
|
||||
const pwLoading = ref(false);
|
||||
const passwordForm = reactive({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
});
|
||||
|
||||
const showPwModal = () => {
|
||||
passwordForm.current_password = '';
|
||||
passwordForm.new_password = '';
|
||||
passwordForm.confirm_password = '';
|
||||
pwModal.value.show();
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (passwordForm.new_password !== passwordForm.confirm_password) {
|
||||
Swal.fire({
|
||||
title: 'Error!',
|
||||
text: 'New passwords do not match',
|
||||
icon: 'error',
|
||||
confirmButtonColor: '#EC7C31'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pwLoading.value = true;
|
||||
try {
|
||||
await apiClient.post('../auth/change-password', {
|
||||
current_password: passwordForm.current_password,
|
||||
new_password: passwordForm.new_password
|
||||
});
|
||||
|
||||
pwModal.value.hide();
|
||||
Swal.fire({
|
||||
title: 'Success!',
|
||||
text: 'Password updated successfully.',
|
||||
icon: 'success',
|
||||
confirmButtonColor: '#1652B8'
|
||||
});
|
||||
} catch (err) {
|
||||
Swal.fire({
|
||||
title: 'Failed',
|
||||
text: err.response?.data?.error || 'Failed to update password',
|
||||
icon: 'error',
|
||||
confirmButtonColor: '#cf222e'
|
||||
});
|
||||
} finally {
|
||||
pwLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 2FA Logic
|
||||
const isEnabled = ref(false);
|
||||
const step2 = ref(false);
|
||||
const loading = ref(false);
|
||||
const otp = ref('');
|
||||
const setupData = ref({ secret: '', uri: '' });
|
||||
const isDark = ref(document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
|
||||
const fetchUserStatus = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.get('/user/me');
|
||||
isEnabled.value = res.data.is_2fa_enabled;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user status:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserStatus();
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'data-theme') {
|
||||
isDark.value = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true });
|
||||
});
|
||||
|
||||
const initSetup = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await apiClient.post('../auth/setup-2fa');
|
||||
setupData.value = res.data;
|
||||
step2.value = true;
|
||||
} catch (err) {
|
||||
Swal.fire('Error', 'Failed to initialize 2FA setup.', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAndEnable = async () => {
|
||||
if (!otp.value || otp.value.length !== 6) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await apiClient.post('../auth/enable-2fa', {
|
||||
secret: setupData.value.secret,
|
||||
otp: otp.value
|
||||
});
|
||||
isEnabled.value = true;
|
||||
confirmModal.value.open({
|
||||
title: 'Success!',
|
||||
message: 'Two-factor authentication has been enabled successfully. Your dashboard is now more secure.',
|
||||
confirmText: 'Great!',
|
||||
confirmBtnClass: 'btn-action-save',
|
||||
showCancel: false
|
||||
});
|
||||
} catch (err) {
|
||||
confirmModal.value.open({
|
||||
title: 'Verification Failed',
|
||||
message: err.response?.data?.error || 'The code you entered is invalid. Please check your authenticator app and try again.',
|
||||
confirmText: 'Try Again',
|
||||
confirmBtnClass: 'btn-action-danger',
|
||||
showCancel: false
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const disableConfirm = () => {
|
||||
disable2FAModal.value.open({
|
||||
title: 'Disable 2FA?',
|
||||
message: 'This will reduce your account security by removing the second-layer protection from your dashboard.',
|
||||
confirmText: 'Yes, disable',
|
||||
confirmBtnClass: 'btn-action-danger'
|
||||
});
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
try {
|
||||
await apiClient.post('../auth/disable-2fa');
|
||||
|
||||
isEnabled.value = false;
|
||||
step2.value = false;
|
||||
confirmModal.value.open({
|
||||
title: '2FA Disabled',
|
||||
message: 'Two-factor authentication has been disabled. Your account is now using standard password protection.',
|
||||
confirmText: 'OK',
|
||||
confirmBtnClass: 'btn-action-primary',
|
||||
showCancel: false
|
||||
});
|
||||
} catch (err) {
|
||||
confirmModal.value.open({
|
||||
title: 'Error',
|
||||
message: err.response?.data?.error || 'Failed to disable two-factor authentication. Please try again.',
|
||||
confirmText: 'OK',
|
||||
confirmBtnClass: 'btn-action-danger',
|
||||
showCancel: false
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
442
APP_UI/src/views/Analytics.vue
Normal file
442
APP_UI/src/views/Analytics.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<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="d-flex align-items-center">
|
||||
<div class="toggle-wrapper me-2">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="vizToggle" v-model="isSpeedMode" @change="renderMainChart">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="small fw-bold user-select-none" style="color: var(--text-heading); cursor: pointer;" for="vizToggle">
|
||||
Speed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="chart-container">
|
||||
<canvas ref="mainChartRef"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-trophy me-2"></i>TOP-10 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 + '%', backgroundColor: '#EC7C31' }"></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 status-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:#1652B8"></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:#EC7C31"></span>Upload</div>
|
||||
<div class="h5 mb-0" style="color: var(--text-heading);">{{ kpi.totalSentString }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
import { useApi } from '../composables/useApi';
|
||||
import { useFormatters } from '../composables/useFormatters';
|
||||
import { useAppConfig } from '../composables/useAppConfig';
|
||||
|
||||
const { fetchAnalytics, fetchCertificates } = useApi();
|
||||
const { formatBytes, parseServerDate } = useFormatters();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
// Refs
|
||||
const mainChartRef = ref(null);
|
||||
const pieChartRef = ref(null);
|
||||
let mainChartInstance = null;
|
||||
let pieChartInstance = null;
|
||||
|
||||
// State
|
||||
const loading = reactive({ analytics: true, certs: true });
|
||||
const range = ref('24h');
|
||||
const isSpeedMode = ref(false);
|
||||
|
||||
const kpi = reactive({
|
||||
concurrentUsers: '-',
|
||||
totalTraffic: 0,
|
||||
expiringCerts: '-',
|
||||
totalReceived: 0,
|
||||
totalSent: 0,
|
||||
totalReceivedString: '-',
|
||||
totalSentString: '-'
|
||||
});
|
||||
|
||||
const topClients = ref([]);
|
||||
const expiringCertsList = ref([]);
|
||||
let cachedHistory = null;
|
||||
|
||||
// Helpers
|
||||
const MAX_CHART_POINTS = 96;
|
||||
|
||||
const loadAnalytics = async () => {
|
||||
loading.analytics = true;
|
||||
try {
|
||||
const res = await fetchAnalytics(range.value);
|
||||
if (res.success) {
|
||||
proccessData(res.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.analytics = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadCerts = async () => {
|
||||
loading.certs = true;
|
||||
try {
|
||||
const res = await fetchCertificates();
|
||||
if(res.success) {
|
||||
const now = new Date();
|
||||
const warningThreshold = new Date();
|
||||
warningThreshold.setDate(now.getDate() + 45);
|
||||
|
||||
let count = 0;
|
||||
const list = [];
|
||||
|
||||
res.data.forEach(cert => {
|
||||
if (cert.status === 'revoked') return;
|
||||
const expDate = new Date(cert.expiration_date); // Assuming API returns ISO or parsable date
|
||||
|
||||
if (expDate <= warningThreshold) {
|
||||
count++;
|
||||
const diffTime = Math.abs(expDate - now);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
list.push({
|
||||
...cert,
|
||||
days_left: diffDays
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
kpi.expiringCerts = count;
|
||||
expiringCertsList.value = list.sort((a,b) => a.days_left - b.days_left);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.certs = false;
|
||||
}
|
||||
};
|
||||
|
||||
const proccessData = (data) => {
|
||||
kpi.concurrentUsers = data.max_concurrent_24h || 0;
|
||||
|
||||
// Traffic Distribution
|
||||
const dist = data.traffic_distribution || { rx: 0, tx: 0 };
|
||||
kpi.totalReceived = Number(dist.rx || 0);
|
||||
kpi.totalSent = Number(dist.tx || 0);
|
||||
kpi.totalTraffic = kpi.totalReceived + kpi.totalSent;
|
||||
|
||||
kpi.totalReceivedString = formatBytes(kpi.totalReceived);
|
||||
kpi.totalSentString = formatBytes(kpi.totalSent);
|
||||
|
||||
// Top Clients
|
||||
topClients.value = (data.top_clients_24h || []).map(c => ({
|
||||
name: c.common_name,
|
||||
total: Number(c.total_traffic || 0),
|
||||
percent: kpi.totalTraffic > 0 ? ((Number(c.total_traffic) / kpi.totalTraffic) * 100).toFixed(1) : 0
|
||||
}));
|
||||
|
||||
cachedHistory = data.global_history_24h || [];
|
||||
|
||||
renderMainChart();
|
||||
renderPieChart();
|
||||
};
|
||||
|
||||
const renderMainChart = () => {
|
||||
if (!mainChartRef.value) return;
|
||||
if (mainChartInstance) mainChartInstance.destroy();
|
||||
|
||||
const downsampled = downsampleData(cachedHistory, MAX_CHART_POINTS);
|
||||
const labels = [];
|
||||
const dataRx = [];
|
||||
const dataTx = [];
|
||||
|
||||
downsampled.forEach(point => {
|
||||
const d = parseServerDate(point.timestamp);
|
||||
let label = '';
|
||||
if(range.value.includes('h') || range.value === '1d') {
|
||||
label = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} else {
|
||||
label = d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' +
|
||||
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
labels.push(label);
|
||||
|
||||
if (!isSpeedMode.value) {
|
||||
dataRx.push(point.bytes_received / (1024 * 1024)); // MB
|
||||
dataTx.push(point.bytes_sent / (1024 * 1024)); // MB
|
||||
} else {
|
||||
dataRx.push(point.rx_rate_mbps || 0);
|
||||
dataTx.push(point.tx_rate_mbps || 0);
|
||||
}
|
||||
});
|
||||
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const gridColor = isDark ? 'rgba(240, 246, 252, 0.1)' : 'rgba(0,0,0,0.05)';
|
||||
const textColor = isDark ? '#8b949e' : '#6c757d';
|
||||
|
||||
const ctx = mainChartRef.value.getContext('2d');
|
||||
mainChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Received (MB)' : 'RX Mbps',
|
||||
data: dataRx,
|
||||
borderColor: '#1652B8', // OpenVPN Blue
|
||||
backgroundColor: 'rgba(22, 82, 184, 0.15)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: !isSpeedMode.value ? 'Sent (MB)' : 'TX Mbps',
|
||||
data: dataTx,
|
||||
borderColor: '#EC7C31', // Brand Orange
|
||||
backgroundColor: 'rgba(236, 124, 49, 0.15)',
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
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: ['#1652B8', '#EC7C31'],
|
||||
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>
|
||||
331
APP_UI/src/views/Certificates.vue
Normal file
331
APP_UI/src/views/Certificates.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<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 / Revoked</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>
|
||||
<button class="btn-action btn-action-save btn-sm" @click="showNewClientModal">
|
||||
<i class="fas fa-plus me-1"></i> New Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="toggle-wrapper me-2">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="hideExpired" v-model="hideExpired">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="user-select-none text-muted" for="hideExpired" style="cursor: pointer;">Hide Expired/Revoked</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="certificatesCard">
|
||||
<div class="card-header d-flex justify-content-between align-items-center bg-transparent">
|
||||
<span><i class="fas fa-certificate me-2"></i>Certificates List</span>
|
||||
<div>
|
||||
<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> Revoked/Expired
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client Name</th>
|
||||
<th>Type</th> <!-- Default to Client -->
|
||||
<th>Validity Not After</th>
|
||||
<th>Days Remaining</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="6" 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 profiles...</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="activeCerts.length === 0 && expiredCerts.length === 0">
|
||||
<td colspan="6" 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="6">Active Certificates ({{ activeCerts.length }})</td>
|
||||
</tr>
|
||||
<tr v-for="cert in activeCerts" :key="cert.id + '_active'">
|
||||
<td>
|
||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ cert.username }}</div>
|
||||
<div class="certificate-file text-muted small">{{ cert.file_path || 'N/A' }}</div>
|
||||
</td>
|
||||
<td><span class="status-badge status-client">Client</span></td>
|
||||
<td>{{ formatDate(cert.expiration_date) }}</td>
|
||||
<td class="fw-semibold" :class="getDaysClass(cert.days_remaining)">
|
||||
{{ cert.days_remaining }}
|
||||
</td>
|
||||
<td v-html="getStatusBadgeHTML(cert)"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-link text-warning p-0 me-2" title="Download Config" @click="downloadConfig(cert)">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert)">
|
||||
<i class="fas fa-ban"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expired/Revoked Section -->
|
||||
<template v-if="!hideExpired && expiredCerts.length > 0">
|
||||
<tr class="section-divider">
|
||||
<td colspan="6">Expired / Revoked ({{ expiredCerts.length }})</td>
|
||||
</tr>
|
||||
<tr v-for="cert in expiredCerts" :key="cert.id + '_expired'">
|
||||
<td>
|
||||
<div class="fw-semibold" style="color: var(--text-heading);">{{ cert.username }}</div>
|
||||
<div class="certificate-file text-muted small">{{ cert.file_path || 'N/A' }}</div>
|
||||
</td>
|
||||
<td><span class="status-badge status-client">Client</span></td>
|
||||
<td>{{ formatDate(cert.expiration_date) }}</td>
|
||||
<td class="fw-semibold text-muted">
|
||||
{{ cert.days_remaining }}
|
||||
</td>
|
||||
<td v-html="getStatusBadgeHTML(cert)"></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" title="Revoke" @click="revokeClient(cert)" :disabled="cert.is_revoked">
|
||||
<i class="fas fa-ban"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewClientModal ref="newClientModal" @create="createClient" />
|
||||
<ConfirmModal ref="confirmModal" @confirm="confirmRevoke" />
|
||||
|
||||
<div v-if="toastMessage" class="toast-message show" :class="'toast-' + toastType" style="position: fixed; bottom: 20px; right: 20px; z-index: 9999;">
|
||||
<i class="fas" :class="toastType === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useApi } from '../composables/useApi';
|
||||
import NewClientModal from '../components/NewClientModal.vue';
|
||||
import ConfirmModal from '../components/ConfirmModal.vue';
|
||||
|
||||
const { profilesApiClient } = useApi();
|
||||
|
||||
const loading = ref(true);
|
||||
const allProfiles = ref([]);
|
||||
const searchQuery = ref('');
|
||||
const hideExpired = ref(false);
|
||||
|
||||
const newClientModal = ref(null);
|
||||
const confirmModal = ref(null);
|
||||
const toastMessage = ref('');
|
||||
const toastType = ref('success');
|
||||
|
||||
const showToast = (msg, type = 'success') => {
|
||||
toastMessage.value = msg;
|
||||
toastType.value = type;
|
||||
setTimeout(() => toastMessage.value = '', 3000);
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'N/A';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
if(isNaN(d)) return dateStr;
|
||||
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString();
|
||||
} catch(e) { return dateStr; }
|
||||
};
|
||||
|
||||
const getDaysClass = (days) => {
|
||||
if (days === null || days === undefined) return '';
|
||||
if (days <= 0) return 'text-danger';
|
||||
if (days <= 30) return 'text-warning';
|
||||
return 'text-success';
|
||||
};
|
||||
|
||||
const getStatusBadgeHTML = (cert) => {
|
||||
if (cert.is_revoked) {
|
||||
return '<span class="status-badge status-expired"><i class="fas fa-ban me-1"></i>Revoked</span>';
|
||||
}
|
||||
if (cert.is_expired) {
|
||||
return '<span class="status-badge status-expired"><i class="fas fa-times-circle me-1"></i>Expired</span>';
|
||||
}
|
||||
|
||||
// Days check logic for expiring soon warning
|
||||
if (cert.days_remaining !== null && cert.days_remaining <= 30) {
|
||||
return '<span class="status-badge status-warning"><i class="fas fa-exclamation-triangle me-1"></i>Expiring Soon</span>';
|
||||
}
|
||||
|
||||
return '<span class="status-badge status-valid"><i class="fas fa-check-circle me-1"></i>Active</span>';
|
||||
};
|
||||
|
||||
|
||||
// Data Processing
|
||||
const filteredData = computed(() => {
|
||||
let data = allProfiles.value;
|
||||
if (searchQuery.value) {
|
||||
const term = searchQuery.value.toLowerCase();
|
||||
data = data.filter(c => {
|
||||
const username = (c.username || '').toLowerCase();
|
||||
return username.includes(term);
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
const categorized = computed(() => {
|
||||
const active = [];
|
||||
const expired = [];
|
||||
|
||||
filteredData.value.forEach(cert => {
|
||||
// Categorize based on revoked or expired flags
|
||||
if (cert.is_revoked || cert.is_expired) {
|
||||
expired.push(cert);
|
||||
} else {
|
||||
active.push(cert);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort logic
|
||||
active.sort((a,b) => (a.days_remaining || 9999) - (b.days_remaining || 9999));
|
||||
expired.sort((a,b) => (b.days_remaining || -9999) - (a.days_remaining || -9999));
|
||||
|
||||
return { active, expired };
|
||||
});
|
||||
|
||||
const activeCerts = computed(() => categorized.value.active);
|
||||
const expiredCerts = computed(() => categorized.value.expired);
|
||||
const totalCerts = computed(() => allProfiles.value.length);
|
||||
const expiringCount = computed(() => {
|
||||
return activeCerts.value.filter(c => {
|
||||
return c.days_remaining !== null && c.days_remaining <= 30;
|
||||
}).length;
|
||||
});
|
||||
|
||||
const loadCerts = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await profilesApiClient.get('/profiles');
|
||||
allProfiles.value = response.data || [];
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
showToast('Failed to load profiles', 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const showNewClientModal = () => {
|
||||
newClientModal.value.open();
|
||||
};
|
||||
|
||||
const createClient = async (username) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await profilesApiClient.post('/profiles', { username });
|
||||
showToast('Client created successfully.', 'success');
|
||||
loadCerts();
|
||||
} catch(e) {
|
||||
showToast(e.response?.data?.detail || e.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadConfig = async (cert) => {
|
||||
if (!cert.id) return;
|
||||
try {
|
||||
const res = await profilesApiClient.get(`/profiles/${cert.id}/download`, { responseType: 'blob' });
|
||||
|
||||
const contentDisposition = res.headers['content-disposition'];
|
||||
let filename = `${cert.username}.ovpn`;
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
||||
if (match && match[1]) filename = match[1];
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch(e) {
|
||||
showToast('Could not download config: ' + (e.response?.data?.detail || e.message), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const revokeClient = (cert) => {
|
||||
if (cert.is_revoked) return;
|
||||
confirmModal.value.open({
|
||||
title: 'Revoke Certificate?',
|
||||
message: `Are you sure you want to revoke access for ${cert.username}? This action cannot be undone.`,
|
||||
confirmText: 'Yes, Revoke',
|
||||
confirmBtnClass: 'btn-action-danger',
|
||||
data: cert
|
||||
});
|
||||
};
|
||||
|
||||
const confirmRevoke = async (cert) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await profilesApiClient.delete(`/profiles/${cert.id}`);
|
||||
showToast(`${cert.username} has been revoked.`, 'success');
|
||||
loadCerts();
|
||||
} catch(e) {
|
||||
showToast(e.response?.data?.detail || e.message, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadCerts();
|
||||
});
|
||||
</script>
|
||||
|
||||
222
APP_UI/src/views/Clients.vue
Normal file
222
APP_UI/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="d-flex align-items-center">
|
||||
<div class="toggle-wrapper me-2">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="hideDisconnected" v-model="hideDisconnected">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="user-select-none text-muted" for="hideDisconnected" style="cursor: pointer;">Hide Disconnected</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<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-warning 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, apiClient } = useApi();
|
||||
const { formatBytes, formatRate, parseServerDate } = useFormatters();
|
||||
const { config } = useAppConfig();
|
||||
|
||||
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>
|
||||
|
||||
161
APP_UI/src/views/Login.vue
Normal file
161
APP_UI/src/views/Login.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="brand-logo">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<h1>OpenVPN Monitor</h1>
|
||||
<p class="text-muted">Secure Access Control</p>
|
||||
</div>
|
||||
|
||||
<div class="login-body">
|
||||
<!-- Standard Login -->
|
||||
<form v-if="!requires2FA" @submit.prevent="handleLogin">
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="form.username"
|
||||
placeholder="Enter username"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-lock"></i></span>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="form.password"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger fade show mb-4">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-bold" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 2FA Verification -->
|
||||
<form v-else @submit.prevent="handleVerify2FA">
|
||||
<div class="text-center mb-4">
|
||||
<i class="fas fa-key fa-3x text-warning mb-3"></i>
|
||||
<h3>Two-Factor Authentication</h3>
|
||||
<p class="text-muted">Enter the 6-digit code from your authenticator app.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-lg text-center fw-bold"
|
||||
v-model="otp"
|
||||
placeholder="000 000"
|
||||
maxlength="6"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger fade show mb-4">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-warning w-100 py-2 fw-bold" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Verify & Continue
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-link w-100 mt-2 text-muted" @click="resetLogin">
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer text-center mt-4">
|
||||
<p class="small text-muted">© 2026 Admin Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useApi } from '../composables/useApi';
|
||||
|
||||
const router = useRouter();
|
||||
const { apiClient } = useApi();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const requires2FA = ref(false);
|
||||
const tempToken = ref('');
|
||||
const otp = ref('');
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
// Auth routes are under /api/auth, while apiClient defaults to /api/v1
|
||||
// We use a relative path with '..' to go up to /api
|
||||
const response = await apiClient.post('../auth/login', form);
|
||||
if (response.data.requires_2fa) {
|
||||
requires2FA.value = true;
|
||||
tempToken.value = response.data.temp_token;
|
||||
} else {
|
||||
localStorage.setItem('ovpmon_token', response.data.token);
|
||||
localStorage.setItem('ovpmon_user', response.data.username);
|
||||
router.push('/');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login Error:', err);
|
||||
error.value = err.response?.data?.error || 'Login failed. Please check your connection.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await apiClient.post('../auth/verify-2fa', {
|
||||
temp_token: tempToken.value,
|
||||
otp: otp.value
|
||||
});
|
||||
localStorage.setItem('ovpmon_token', response.data.token);
|
||||
localStorage.setItem('ovpmon_user', response.data.username);
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Verification failed.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetLogin = () => {
|
||||
requires2FA.value = false;
|
||||
tempToken.value = '';
|
||||
otp.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
251
APP_UI/src/views/Login.vue.bak
Normal file
251
APP_UI/src/views/Login.vue.bak
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="brand-logo">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
</div>
|
||||
<h1>OpenVPN Monitor</h1>
|
||||
<p class="text-muted">Secure Access Control</p>
|
||||
</div>
|
||||
|
||||
<div class="login-body">
|
||||
<!-- Standard Login -->
|
||||
<form v-if="!requires2FA" @submit.prevent="handleLogin">
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Username</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-user"></i></span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="form.username"
|
||||
placeholder="Enter username"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-lock"></i></span>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
v-model="form.password"
|
||||
placeholder="Enter password"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger fade show mb-4">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2 fw-bold" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 2FA Verification -->
|
||||
<form v-else @submit.prevent="handleVerify2FA">
|
||||
<div class="text-center mb-4">
|
||||
<i class="fas fa-key fa-3x text-warning mb-3"></i>
|
||||
<h3>Two-Factor Authentication</h3>
|
||||
<p class="text-muted">Enter the 6-digit code from your authenticator app.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-lg text-center fw-bold"
|
||||
v-model="otp"
|
||||
placeholder="000 000"
|
||||
maxlength="6"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger fade show mb-4">
|
||||
<i class="fas fa-exclamation-circle me-2"></i>{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-warning w-100 py-2 fw-bold" :disabled="loading">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Verify & Continue
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-link w-100 mt-2 text-muted" @click="resetLogin">
|
||||
Back to login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="login-footer text-center mt-4">
|
||||
<p class="small text-muted">© 2026 Admin Dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useApi } from '../composables/useApi';
|
||||
|
||||
const router = useRouter();
|
||||
const { apiClient } = useApi();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const requires2FA = ref(false);
|
||||
const tempToken = ref('');
|
||||
const otp = ref('');
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
// Auth routes are under /api/auth, while apiClient defaults to /api/v1
|
||||
// We use a relative path with '..' to go up to /api
|
||||
const response = await apiClient.post('../auth/login', form);
|
||||
if (response.data.requires_2fa) {
|
||||
requires2FA.value = true;
|
||||
tempToken.value = response.data.temp_token;
|
||||
} else {
|
||||
localStorage.setItem('ovpmon_token', response.data.token);
|
||||
localStorage.setItem('ovpmon_user', response.data.username);
|
||||
router.push('/');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login Error:', err);
|
||||
error.value = err.response?.data?.error || 'Login failed. Please check your connection.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async () => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const response = await apiClient.post('../auth/verify-2fa', {
|
||||
temp_token: tempToken.value,
|
||||
otp: otp.value
|
||||
});
|
||||
localStorage.setItem('ovpmon_token', response.data.token);
|
||||
localStorage.setItem('ovpmon_user', response.data.username);
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Verification failed.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetLogin = () => {
|
||||
requires2FA.value = false;
|
||||
tempToken.value = '';
|
||||
otp.value = '';
|
||||
error.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-body);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--bg-card);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
border-radius: 12px;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
background: var(--bg-element);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: var(--bg-input);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #1652B8;
|
||||
border-color: #1652B8;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1449a5;
|
||||
border-color: #1449a5;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #EC7C31;
|
||||
border-color: #EC7C31;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background-color: #d66d2a;
|
||||
border-color: #d66d2a;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
292
APP_UI/src/views/PKIConfig.vue
Normal file
292
APP_UI/src/views/PKIConfig.vue
Normal file
@@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div class="config-wrapper">
|
||||
<div class="config-card">
|
||||
<div class="config-header">
|
||||
<h1>PKI Infrastructure Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
|
||||
<i :class="['fas', message.icon]"></i> {{ message.text }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSave">
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">FQDN: CA Cert CN</label>
|
||||
<input type="text" class="config-input" v-model="form.fqdn_ca">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">FQDN: Server Cert CN</label>
|
||||
<input type="text" class="config-input" v-model="form.fqdn_server">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col" style="max-width: 50%;">
|
||||
<label class="config-label">EASYRSA DN</label>
|
||||
<div class="select-container">
|
||||
<select class="config-select" v-model="form.easyrsa_dn">
|
||||
<option value="cn_only">CN_ONLY</option>
|
||||
<option value="org">ORG</option>
|
||||
</select>
|
||||
<div class="select-arrow">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col" style="max-width: 50%;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Country</label>
|
||||
<input type="text" class="config-input" v-model="form.easyrsa_req_country">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Province / State</label>
|
||||
<input type="text" class="config-input" v-model="form.easyrsa_req_province">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">City</label>
|
||||
<input type="text" class="config-input" v-model="form.easyrsa_req_city">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Organization</label>
|
||||
<input type="text" class="config-input" v-model="form.easyrsa_req_org">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">E-mail</label>
|
||||
<input type="email" class="config-input" v-model="form.easyrsa_req_email">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Organization Unit</label>
|
||||
<input type="text" class="config-input" v-model="form.easyrsa_req_ou">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Key Size Length (Bits)</label>
|
||||
<div class="select-container">
|
||||
<select class="config-select" v-model="form.easyrsa_key_size">
|
||||
<option value="2048">2048</option>
|
||||
<option value="4096">4096</option>
|
||||
</select>
|
||||
<div class="select-arrow">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">CA Expire (Days)</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.easyrsa_ca_expire">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_ca_expire++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_ca_expire--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Cert Expire (Days)</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.easyrsa_cert_expire">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_expire++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_expire--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Cert Renew (Days)</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.easyrsa_cert_renew">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_renew++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_cert_renew--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">CRL Valid (Days)</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.easyrsa_crl_days">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_crl_days++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.easyrsa_crl_days--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Batch</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.easyrsa_batch">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
|
||||
<div class="btn-action-group">
|
||||
<button type="button" class="btn-action btn-action-save" @click="handleSave" :disabled="isLoading">
|
||||
<i class="fas fa-save"></i>
|
||||
Save PKI
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-action-primary" @click="handleInit" :disabled="isLoading">
|
||||
<i v-if="isLoading && action==='init'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-play-circle"></i>
|
||||
Init PKI
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-action-danger" @click="handleClear" :disabled="isLoading">
|
||||
<i v-if="isLoading && action==='clear'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-trash-alt"></i>
|
||||
Clear PKI
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue';
|
||||
|
||||
import { useApi } from '../composables/useApi';
|
||||
|
||||
const { profilesApiClient } = useApi();
|
||||
|
||||
// State
|
||||
const form = reactive({
|
||||
fqdn_ca: '',
|
||||
fqdn_server: '',
|
||||
easyrsa_dn: 'cn_only',
|
||||
easyrsa_req_country: '',
|
||||
easyrsa_req_province: '',
|
||||
easyrsa_req_city: '',
|
||||
easyrsa_req_org: '',
|
||||
easyrsa_req_email: '',
|
||||
easyrsa_req_ou: '',
|
||||
easyrsa_key_size: 2048,
|
||||
easyrsa_ca_expire: null,
|
||||
easyrsa_cert_expire: null,
|
||||
easyrsa_cert_renew: null,
|
||||
easyrsa_crl_days: null,
|
||||
easyrsa_batch: true
|
||||
});
|
||||
|
||||
const message = reactive({
|
||||
text: '',
|
||||
type: 'success', // success, warning, error (mapped to css classes)
|
||||
icon: 'fa-check-circle'
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const action = ref(''); // 'save', 'init', 'clear'
|
||||
|
||||
// Methods
|
||||
const showMessage = (text, type = 'success') => {
|
||||
message.text = text;
|
||||
message.type = type;
|
||||
message.icon = type === 'success' ? 'fa-check-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-exclamation-circle';
|
||||
|
||||
// Auto hide
|
||||
setTimeout(() => {
|
||||
message.text = '';
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// API Fetch Wrapper
|
||||
// API Wrapper removed, using profilesApiClient directly
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await profilesApiClient.get('/config', { params: { section: 'pki' } });
|
||||
const payload = res.data;
|
||||
const data = payload.pki ? payload.pki : payload;
|
||||
|
||||
// Populate form
|
||||
Object.keys(data).forEach(key => {
|
||||
if (form.hasOwnProperty(key)) {
|
||||
form[key] = data[key];
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showMessage('Failed to load PKI config from Management API.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
isLoading.value = true;
|
||||
action.value = 'save';
|
||||
try {
|
||||
await profilesApiClient.put('/config/pki', form);
|
||||
showMessage('Configuration saved successfully!', 'success');
|
||||
} catch (err) {
|
||||
showMessage(`Error saving: ${err.message}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleInit = async () => {
|
||||
if (!confirm('Are you sure you want to Initialize system? This will start the PKI initialization process.')) return;
|
||||
|
||||
isLoading.value = true;
|
||||
action.value = 'init';
|
||||
try {
|
||||
const res = await profilesApiClient.post('/system/init');
|
||||
const data = res.data;
|
||||
showMessage(data.message || 'System initialized successfully!', 'success');
|
||||
} catch (err) {
|
||||
showMessage(`Error initializing: ${err.message}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('Are you sure you want to CLEAR PKI? This will delete all certificates and cannot be undone.')) return;
|
||||
|
||||
isLoading.value = true;
|
||||
action.value = 'clear';
|
||||
try {
|
||||
const res = await profilesApiClient.delete('/system/pki');
|
||||
const data = res.data;
|
||||
showMessage(data.message || 'PKI cleared successfully!', 'warning');
|
||||
} catch (err) {
|
||||
showMessage(`Error clearing PKI: ${err.message}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
169
APP_UI/src/views/ServerManagement.vue
Normal file
169
APP_UI/src/views/ServerManagement.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="config-wrapper">
|
||||
<div class="config-card">
|
||||
<div class="config-header">
|
||||
<h1>Server Process Management</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
|
||||
<i :class="['fas', message.icon]"></i> {{ message.text }}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<!-- User requested specific order: CPU (full), Status, PID, Memory, Uptime -->
|
||||
<div class="stats-grid mb-4">
|
||||
<!-- CPU Usage (Full Width) -->
|
||||
<div class="stat-item full-width cpu-progress">
|
||||
<label class="stat-label d-flex justify-content-between">
|
||||
<span>CPU Usage</span>
|
||||
<span>{{ stats.cpu_percent }}%</span>
|
||||
</label>
|
||||
<div class="progress">
|
||||
<div class="progress-bar progress-bar-brand" role="progressbar"
|
||||
:style="{ width: Math.min(stats.cpu_percent, 100) + '%' }"
|
||||
:aria-valuenow="stats.cpu_percent" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="stat-item">
|
||||
<label class="stat-label">Status</label>
|
||||
<div class="d-flex align-items-center">
|
||||
<span :class="['status-badge', 'status-' + (stats.status === 'running' ? 'active' : 'revoked')]">
|
||||
{{ stats.status.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PID -->
|
||||
<div class="stat-item">
|
||||
<label class="stat-label">Process ID</label>
|
||||
<div class="stat-value">{{ stats.pid || 'N/A' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Memory -->
|
||||
<div class="stat-item">
|
||||
<label class="stat-label">Memory (RSS)</label>
|
||||
<div class="stat-value">{{ stats.memory_mb }} MB</div>
|
||||
</div>
|
||||
|
||||
<!-- Uptime -->
|
||||
<div class="stat-item">
|
||||
<label class="stat-label">Uptime</label>
|
||||
<div class="stat-value monospace">{{ stats.uptime || 'N/A' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="btn-action-group justify-content-center">
|
||||
<!-- Start -->
|
||||
<button type="button" class="btn-action btn-action-primary"
|
||||
@click="handleControl('start')"
|
||||
:disabled="isLoading || stats.status === 'running'">
|
||||
<i v-if="isLoading && action==='start'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-play"></i>
|
||||
Start Service
|
||||
</button>
|
||||
|
||||
<!-- Restart -->
|
||||
<button type="button" class="btn-action btn-action-save"
|
||||
@click="handleControl('restart')"
|
||||
:disabled="isLoading">
|
||||
<i v-if="isLoading && action==='restart'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-sync-alt"></i>
|
||||
Restart Service
|
||||
</button>
|
||||
|
||||
<!-- Stop -->
|
||||
<button type="button" class="btn-action btn-action-danger"
|
||||
@click="handleControl('stop')"
|
||||
:disabled="isLoading || stats.status !== 'running'">
|
||||
<i v-if="isLoading && action==='stop'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-stop"></i>
|
||||
Stop Service
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue';
|
||||
import { useApi } from '../composables/useApi';
|
||||
|
||||
const { profilesApiClient } = useApi();
|
||||
|
||||
// State
|
||||
const stats = reactive({
|
||||
status: 'unknown',
|
||||
pid: null,
|
||||
cpu_percent: 0.0,
|
||||
memory_mb: 0.0,
|
||||
uptime: null
|
||||
});
|
||||
|
||||
const message = reactive({
|
||||
text: '',
|
||||
type: 'success',
|
||||
icon: 'fa-check-circle'
|
||||
});
|
||||
|
||||
const isLoading = ref(false);
|
||||
const action = ref(''); // 'start', 'stop', 'restart'
|
||||
let pollInterval = null;
|
||||
|
||||
// Methods
|
||||
const showMessage = (text, type = 'success') => {
|
||||
message.text = text;
|
||||
message.type = type;
|
||||
message.icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
|
||||
|
||||
setTimeout(() => {
|
||||
message.text = '';
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await profilesApiClient.get('/server/process/stats');
|
||||
Object.assign(stats, res.data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch stats:", err);
|
||||
// Don't show error toast on every poll failure to avoid spam
|
||||
}
|
||||
};
|
||||
|
||||
const handleControl = async (cmd) => {
|
||||
if (!confirm(`Are you sure you want to ${cmd.toUpperCase()} the OpenVPN service?`)) return;
|
||||
|
||||
isLoading.value = true;
|
||||
action.value = cmd;
|
||||
|
||||
try {
|
||||
const res = await profilesApiClient.post(`/server/process/${cmd}`);
|
||||
showMessage(res.data.message || `Service ${cmd} successful`, 'success');
|
||||
|
||||
// Refresh stats immediately after action
|
||||
setTimeout(fetchStats, 1000);
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.detail || err.message;
|
||||
showMessage(`Failed to ${cmd}: ${errMsg}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats();
|
||||
// Poll every 5 seconds
|
||||
pollInterval = setInterval(fetchStats, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
396
APP_UI/src/views/VPNConfig.vue
Normal file
396
APP_UI/src/views/VPNConfig.vue
Normal file
@@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<div class="config-wrapper">
|
||||
<div class="config-card">
|
||||
<div class="config-header">
|
||||
<h1>Server Infrastructure Configuration</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="message.text" :class="['toast-message', 'toast-' + message.type, 'show']">
|
||||
<i :class="['fas', message.icon]"></i> {{ message.text }}
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSave">
|
||||
<!-- Basic Network -->
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Tunnel Protocol</label>
|
||||
<div class="select-container">
|
||||
<select class="config-select" v-model="form.protocol">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
<div class="select-arrow">
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Tunnel Port</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.port">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.port++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.port--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">VPN Subnet (CIDR Format)</label>
|
||||
<input type="text" class="config-input" v-model="form.vpn_network" placeholder="172.20.1.0/24">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Public IP</label>
|
||||
<input type="text" class="config-input" v-model="form.public_ip">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Tunnel MTU</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.tun_mtu" placeholder="0 for default">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.tun_mtu++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.tun_mtu--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">MSS FIX</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.mssfix" placeholder="0 for default">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.mssfix++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.mssfix--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<!-- Split Tunnel -->
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Enable Split Tunnel (Default FULL)</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="isSplitTunnel">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isSplitTunnel" class="nested-box fade-in">
|
||||
<div class="config-row mb-2">
|
||||
<div class="config-col">
|
||||
<label class="nested-label">Split Routes (CIDR Format)</label>
|
||||
|
||||
<div class="list-container">
|
||||
<div v-for="(route, index) in form.split_routes" :key="index" class="config-input-group mb-2">
|
||||
<input type="text" class="config-input" v-model="form.split_routes[index]" placeholder="10.0.0.0/24">
|
||||
<button type="button" class="btn-icon-sm" @click="removeRoute(index)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-dashed text-primary" @click="addRoute">
|
||||
<i class="fas fa-plus text-primary"></i> Add Route
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<!-- DNS -->
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Enable User-Defined DNS Servers</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.user_defined_dns">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.user_defined_dns" class="nested-box fade-in">
|
||||
<div class="config-row mb-2">
|
||||
<div class="config-col">
|
||||
<label class="nested-label">DNS Servers</label>
|
||||
<div class="list-container">
|
||||
<div v-for="(dns, index) in form.dns_servers" :key="index" class="config-input-group mb-2">
|
||||
<input type="text" class="config-input" v-model="form.dns_servers[index]" placeholder="8.8.8.8">
|
||||
<button type="button" class="btn-icon-sm" @click="removeDns(index)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-dashed text-primary" @click="addDns">
|
||||
<i class="fas fa-plus text-primary"></i> Add DNS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<!-- Advanced Flags -->
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Duplicate CN</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.duplicate_cn">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Client-to-Client</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.client_to_client">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">CRL Verify</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.crl_verify">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Enable Connection Scripts</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.user_defined_cdscripts">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.user_defined_cdscripts" class="nested-box fade-in">
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Path to Connect Script</label>
|
||||
<input type="text" class="config-input" v-model="form.connect_script">
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row mt-3">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Path to Disconnect Script</label>
|
||||
<input type="text" class="config-input" v-model="form.disconnect_script">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-row mt-4">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Enable Management Interface</label>
|
||||
<div class="toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" v-model="form.management_interface">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.management_interface" class="nested-box fade-in">
|
||||
<div class="config-row">
|
||||
<div class="config-col">
|
||||
<label class="config-label">Listener Address</label>
|
||||
<input type="text" class="config-input" v-model="form.management_interface_address" placeholder="127.0.0.1">
|
||||
</div>
|
||||
<div class="config-col">
|
||||
<label class="config-label">Port</label>
|
||||
<div class="number-input-container">
|
||||
<input type="number" class="config-input" v-model.number="form.management_port" placeholder="7505">
|
||||
<div class="number-input-controls">
|
||||
<button type="button" class="number-input-btn" @click="form.management_port++"><i class="fas fa-chevron-up"></i></button>
|
||||
<button type="button" class="number-input-btn" @click="form.management_port--"><i class="fas fa-chevron-down"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-divider"></div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="btn-action-group">
|
||||
<button type="submit" class="btn-action btn-action-save" :disabled="isLoading">
|
||||
<i v-if="isLoading && action==='save'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-save"></i>
|
||||
Save Server Configuration
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-action-primary" @click="handleApply" :disabled="isLoading">
|
||||
<i v-if="isLoading && action==='apply'" class="fas fa-spinner fa-spin"></i>
|
||||
<i v-else class="fas fa-rocket"></i>
|
||||
Apply Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, watch } from 'vue';
|
||||
import { useApi } from '../composables/useApi';
|
||||
|
||||
const { profilesApiClient } = useApi();
|
||||
|
||||
// State
|
||||
const form = reactive({
|
||||
protocol: 'udp',
|
||||
port: 1194,
|
||||
vpn_network: '',
|
||||
public_ip: '',
|
||||
tun_mtu: 0,
|
||||
mssfix: 0,
|
||||
tunnel_type: 'FULL',
|
||||
split_routes: [],
|
||||
user_defined_dns: false,
|
||||
dns_servers: [],
|
||||
duplicate_cn: false,
|
||||
client_to_client: false,
|
||||
crl_verify: false,
|
||||
user_defined_cdscripts: false,
|
||||
connect_script: '',
|
||||
disconnect_script: '',
|
||||
management_interface: false,
|
||||
management_interface_address: '',
|
||||
management_port: 7505
|
||||
});
|
||||
|
||||
const isSplitTunnel = ref(false);
|
||||
|
||||
const message = reactive({
|
||||
text: '',
|
||||
type: 'success',
|
||||
icon: 'fa-check-circle'
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const action = ref('');
|
||||
|
||||
// Watchers
|
||||
watch(isSplitTunnel, (val) => {
|
||||
form.tunnel_type = val ? 'SPLIT' : 'FULL';
|
||||
});
|
||||
|
||||
// Helpers
|
||||
function maskToCidr(mask) {
|
||||
if (!mask) return '';
|
||||
const parts = mask.split('.');
|
||||
let bits = 0;
|
||||
const map = {255:8, 254:7, 252:6, 248:5, 240:4, 224:3, 192:2, 128:1, 0:0};
|
||||
for(let p of parts) bits += map[parseInt(p)] || 0;
|
||||
return bits;
|
||||
}
|
||||
|
||||
// Methods
|
||||
const showMessage = (text, type = 'success') => {
|
||||
message.text = text;
|
||||
message.type = type;
|
||||
message.icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle';
|
||||
setTimeout(() => message.text = '', 5000);
|
||||
};
|
||||
|
||||
const addRoute = () => form.split_routes.push('');
|
||||
const removeRoute = (idx) => form.split_routes.splice(idx, 1);
|
||||
const addDns = () => form.dns_servers.push('');
|
||||
const removeDns = (idx) => form.dns_servers.splice(idx, 1);
|
||||
|
||||
// API Wrapper removed, using profilesApiClient directly
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await profilesApiClient.get('/config', { params: { section: 'server' } });
|
||||
const payload = res.data;
|
||||
const data = payload.server ? payload.server : payload;
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
if (form.hasOwnProperty(key)) {
|
||||
if (key === 'tunnel_type') {
|
||||
isSplitTunnel.value = data[key] === 'SPLIT';
|
||||
}
|
||||
form[key] = data[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (data.vpn_network && data.vpn_netmask) {
|
||||
const cidrSuffix = maskToCidr(data.vpn_netmask);
|
||||
if (cidrSuffix) {
|
||||
form.vpn_network = `${data.vpn_network}/${cidrSuffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showMessage('Failed to load Server config from Management API.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
isLoading.value = true;
|
||||
action.value = 'save';
|
||||
|
||||
const payload = { ...form };
|
||||
payload.split_routes = payload.split_routes.filter(r => r && r.trim() !== '');
|
||||
payload.dns_servers = payload.dns_servers.filter(d => d && d.trim() !== '');
|
||||
|
||||
if (payload.vpn_network.includes('/')) {
|
||||
const [ip, suffix] = payload.vpn_network.split('/');
|
||||
payload.vpn_network = ip;
|
||||
}
|
||||
|
||||
try {
|
||||
await profilesApiClient.put('/config/server', payload);
|
||||
showMessage('Server configuration saved!', 'success');
|
||||
} catch (err) {
|
||||
showMessage(`Error saving: ${err.message}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = async () => {
|
||||
isLoading.value = true;
|
||||
action.value = 'apply';
|
||||
try {
|
||||
const res = await profilesApiClient.post('/server/configure');
|
||||
const data = res.data;
|
||||
showMessage(data.message || 'Configuration applied!', 'success');
|
||||
} catch (err) {
|
||||
showMessage(`Error applying: ${err.message}`, 'error');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
action.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user