new awesome build
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user