diff --git a/src/components/standalone/openvpn_rw/ConnectionsHistory.vue b/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
index 8b252ea0e..e04aaf3d5 100644
--- a/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
+++ b/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
@@ -1,5 +1,5 @@
@@ -19,7 +19,11 @@ import {
type FilterOption
} from '@nethesis/vue-components'
import { getAxiosErrorMessage } from '@nethesis/vue-components'
-import { onMounted, ref, computed, watch } from 'vue'
+import { ref, computed, watch } from 'vue'
+import { useQuery, keepPreviousData } from '@tanstack/vue-query'
+import { useRouteQuery } from '@vueuse/router'
+import { refDebounced } from '@vueuse/core'
+import { faClockRotateLeft } from '@fortawesome/free-solid-svg-icons'
const { t } = useI18n()
@@ -34,134 +38,146 @@ export type ConnectionsRecord = {
virtualIpAddress: string
}
-const isLoading = ref(false)
-const connectionsRecords = ref([])
-const error = ref({
- notificationDescription: '',
- notificationDetails: '',
- notificationTitle: ''
-})
-const textFilter = ref('')
+type ConnectionsHistoryResponse = {
+ data: {
+ connections: ConnectionsRecord[]
+ current_page: number
+ last_page: number
+ per_page: number
+ results: number
+ total: number
+ filters: {
+ accounts: string[]
+ }
+ }
+}
const props = defineProps<{
instance: string
}>()
-type RangeFilterOptions = 'all' | 'last_3_months' | 'last_month' | 'last_week' | 'today'
+type RangeFilterOption = 'all' | 'last_3_months' | 'last_month' | 'last_week' | 'today'
-// filter time range to populate NeDropdownFilter timeRangeFilter options
-const timeRangeFilter = ref<[RangeFilterOptions, ...RangeFilterOptions[]]>(['today'])
-const timeRangeFilterOptions = ref([
- {
- id: 'today',
- label: t('standalone.openvpn_rw.history.today')
- },
- {
- id: 'last_week',
- label: t('standalone.openvpn_rw.history.last_week')
- },
- {
- id: 'last_month',
- label: t('standalone.openvpn_rw.history.last_month')
- },
- {
- id: 'last_3_months',
- label: t('standalone.openvpn_rw.history.last_3_months')
- },
- {
- id: 'all',
- label: t('standalone.openvpn_rw.history.all')
- }
-])
+const timeRangeFilterOptions: FilterOption[] = [
+ { id: 'today', label: t('standalone.openvpn_rw.history.today') },
+ { id: 'last_week', label: t('standalone.openvpn_rw.history.last_week') },
+ { id: 'last_month', label: t('standalone.openvpn_rw.history.last_month') },
+ { id: 'last_3_months', label: t('standalone.openvpn_rw.history.last_3_months') },
+ { id: 'all', label: t('standalone.openvpn_rw.history.all') }
+]
-// filter accounts to populate NeDropdownFilter accountsFilter options
-const accountsFilter = ref>([])
+// route-persisted filters
+const textFilter = useRouteQuery('q', '')
+const textFilterDebounced = refDebounced(textFilter, 500)
-// Computed property to get unique accounts within the selected time range
-const uniqueAccounts = computed(() => {
- const filteredRecords = applyFilterToConntrackRecords(
- connectionsRecords.value,
- '',
- timeRangeFilter.value[0],
- []
- )
- return Array.from(new Set(filteredRecords.map((record) => record.account))).sort((a, b) =>
- a.localeCompare(b)
- )
+const timeRangeFilter = useRouteQuery('time_range', 'all', {
+ transform: {
+ get: (val) => (val ? ([val] as RangeFilterOption[]) : ['all']),
+ set: (val) => val[0] ?? 'all'
+ }
})
-const accountFilterOptions = computed(() => {
- return uniqueAccounts.value.map((account) => ({
- id: account,
- label: account
- }))
+const accountsFilter = useRouteQuery('accounts', '', {
+ transform: {
+ get: (val) => (val ? val.split(',') : []),
+ set: (val) => val.join(',')
+ }
})
-// filter items based on timeRange and accountsFilter
-function applyFilterToConntrackRecords(
- records: ConnectionsRecord[],
- textFilter: string,
- timeRange: string,
- accountsFilter: string[]
-) {
- const lowerCaseFilter = textFilter.toLowerCase()
- const now = new Date()
- let startDate: Date | null = null
+const currentPage = ref(1)
+const perPage = ref(10)
- if (timeRange === 'today') {
- startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
- } else if (timeRange === 'last_week') {
- startDate = new Date(now)
- startDate.setDate(now.getDate() - 7)
- } else if (timeRange === 'last_month') {
- startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1)
- } else if (timeRange === 'last_3_months') {
- startDate = new Date(now)
- startDate.setMonth(now.getMonth() - 3)
- startDate.setDate(1) // Set to the start of the month
- } else if (timeRange === 'all') {
- startDate = null
+type SortableKeys =
+ | 'account'
+ | 'startTime'
+ | 'endTime'
+ | 'duration'
+ | 'virtualIpAddress'
+ | 'remoteIpAddress'
+ | 'bytesReceived'
+ | 'bytesSent'
+const sortKey = useRouteQuery('sort', 'startTime')
+const sortDescending = useRouteQuery('descending', 'true', {
+ transform: {
+ get: (val) => val == 'true',
+ set: (val) => val.toString()
}
+})
- return records.filter((connectionsRecord: ConnectionsRecord) => {
- // Assuming startTime is in seconds, convert to ms
- const recordDate = connectionsRecord.startTime
- ? new Date(connectionsRecord.startTime * 1000)
- : new Date(0)
+// reset page when filters change
+watch(textFilterDebounced, () => {
+ if (currentPage.value !== 1) {
+ currentPage.value = 1
+ }
+})
- const matchesTextFilter =
- connectionsRecord.account.toLowerCase().includes(lowerCaseFilter) ||
- connectionsRecord.remoteIpAddress.toLowerCase().includes(lowerCaseFilter) ||
- connectionsRecord.virtualIpAddress.toLowerCase().includes(lowerCaseFilter)
+watch(timeRangeFilter, () => {
+ if (currentPage.value !== 1) {
+ currentPage.value = 1
+ }
+})
- const matchesTimeRangeFilter = startDate ? recordDate >= startDate : true
+watch(accountsFilter, () => {
+ if (currentPage.value !== 1) {
+ currentPage.value = 1
+ }
+})
- const matchesAccountFilter =
- accountsFilter.length === 0 || accountsFilter.includes(connectionsRecord.account)
+watch(perPage, () => {
+ if (currentPage.value !== 1) {
+ currentPage.value = 1
+ }
+})
- return matchesTextFilter && matchesTimeRangeFilter && matchesAccountFilter
- })
-}
+const { data, isError, error, isPending, isSuccess } = useQuery({
+ queryKey: [
+ 'ovpnrw',
+ 'connection-history',
+ textFilterDebounced,
+ timeRangeFilter,
+ accountsFilter,
+ perPage,
+ currentPage,
+ sortKey,
+ sortDescending
+ ],
+ queryFn: async () =>
+ ubusCall('ns.ovpnrw', 'connection-history', {
+ instance: props.instance,
+ q: textFilterDebounced.value.toLowerCase(),
+ time_range: timeRangeFilter.value[0],
+ accounts: accountsFilter.value,
+ page: currentPage.value,
+ per_page: perPage.value,
+ sort_by: sortKey.value,
+ desc: sortDescending.value
+ }),
+ placeholderData: keepPreviousData,
+ select: (res) => res.data,
+ refetchOnReconnect: false,
+ refetchOnWindowFocus: false,
+ refetchInterval: false,
+ retry: false
+})
-// filter items
-const filteredItems = computed(() => {
- if (
- ['all', 'today', 'last_week', 'last_month', 'last_3_months'].includes(timeRangeFilter.value[0])
- ) {
- return applyFilterToConntrackRecords(
- connectionsRecords.value,
- textFilter.value,
- timeRangeFilter.value[0],
- accountsFilter.value
- )
- } else {
- return connectionsRecords.value
+watch(data, (newData) => {
+ if (newData != undefined && currentPage.value > newData.last_page) {
+ currentPage.value = newData.last_page
}
})
+const accountFilterOptions = computed(() => {
+ return (
+ data.value?.filters.accounts.map((account) => ({
+ id: account,
+ label: account
+ })) ?? []
+ )
+})
+
// clean error object
function cleanError() {
- error.value = {
+ downloadError.value = {
notificationTitle: '',
notificationDescription: '',
notificationDetails: ''
@@ -171,11 +187,17 @@ function cleanError() {
// clear filters
function clearFilters() {
textFilter.value = ''
- timeRangeFilter.value = ['today']
+ timeRangeFilter.value = ['all']
accountsFilter.value = []
}
// download all history from csv file
+const downloadError = ref({
+ notificationDescription: '',
+ notificationDetails: '',
+ notificationTitle: ''
+})
+
async function downloadAllHistory() {
cleanError()
try {
@@ -192,46 +214,16 @@ async function downloadAllHistory() {
link.href = fileURL
link.download = res.data.csv_path.replace('.csv', '') + '-' + Date.now().toString() + '.csv'
link.click()
-
await deleteFile(res.data.csv_path)
}
} catch (exception: any) {
- error.value.notificationTitle = t('standalone.openvpn_rw.history.cannot_download_history')
- error.value.notificationDescription = t(getAxiosErrorMessage(exception))
- error.value.notificationDetails = exception.toString()
- }
-}
-
-// fetch connection history
-async function fetchConnectionHistory() {
- error.value.notificationDescription = ''
- error.value.notificationDetails = ''
- try {
- isLoading.value = true
- connectionsRecords.value = (
- await ubusCall('ns.ovpnrw', 'connection-history', {
- instance: props.instance,
- time_interval: 'all'
- })
- ).data
- } catch (err: any) {
- error.value.notificationTitle = t('standalone.openvpn_rw.history.cannot_fetch_history')
- error.value.notificationDescription = t(getAxiosErrorMessage(err))
- error.value.notificationDetails = err.toString()
- } finally {
- isLoading.value = false
+ downloadError.value.notificationTitle = t(
+ 'standalone.openvpn_rw.history.cannot_download_history'
+ )
+ downloadError.value.notificationDescription = t(getAxiosErrorMessage(exception))
+ downloadError.value.notificationDetails = exception.toString()
}
}
-
-// fetch connection history on component mount
-onMounted(() => {
- fetchConnectionHistory()
-})
-
-// when timeRangeFilter changes, reset to [] accountsFilter
-watch(timeRangeFilter, () => {
- accountsFilter.value = []
-})
@@ -242,7 +234,7 @@ watch(timeRangeFilter, () => {
{
-
+
{
:options-filter-placeholder="t('standalone.openvpn_rw.history.filter_accounts')"
:more-options-hidden-label="t('ne_dropdown_filter.more_options_hidden')"
/>
-
+
{{ t('standalone.openvpn_rw.history.reset_filters') }}
-
- {{ error.notificationDetails }}
+
+ {{ downloadError.notificationDetails }}
-
-
-
-
-
-
-
-
+
+
+
{
+ currentPage = page
+ }
+ "
+ @select-page-size="
+ (size: number) => {
+ perPage = size
+ }
+ "
+ @sort="
+ ({ key, descending }: { key: string; descending: boolean }) => {
+ sortKey = key as typeof sortKey
+ sortDescending = descending
+ }
+ "
+ @clear-filters="clearFilters"
+ />
+
diff --git a/src/components/standalone/openvpn_rw/UserConnectionsTable.vue b/src/components/standalone/openvpn_rw/UserConnectionsTable.vue
index bbc86e3ae..dd1a3c50e 100644
--- a/src/components/standalone/openvpn_rw/UserConnectionsTable.vue
+++ b/src/components/standalone/openvpn_rw/UserConnectionsTable.vue
@@ -1,5 +1,5 @@
@@ -13,71 +13,39 @@ import {
NeTableRow,
NeTableCell,
NePaginator,
- useItemPagination,
formatDateLoc,
byteFormat1024,
- useSort,
- NeSortDropdown
+ NeSortDropdown,
+ NeEmptyState,
+ NeButton
} from '@nethesis/vue-components'
import type { ConnectionsRecord } from './ConnectionsHistory.vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-import { ref, computed } from 'vue'
import type { SortEvent } from '@nethesis/vue-components'
+import { faArrowDown, faArrowUp, faTable } from '@fortawesome/free-solid-svg-icons'
+
const { t } = useI18n()
const props = defineProps<{
connectionsRecords: ConnectionsRecord[]
+ currentPage: number
+ totalRows: number
+ pageSize: number
+ sortKey: string
+ sortDescending: boolean
}>()
-const pageSize = ref(10)
-
-const sortKey = ref('startTime')
-const sortDescending = ref(true)
-
-function compareIpAddresses(ip1: string, ip2: string): number {
- const octets1 = ip1.split('.').map(Number)
- const octets2 = ip2.split('.').map(Number)
-
- for (let i = 0; i < 4; i++) {
- if (octets1[i] !== octets2[i]) {
- return octets1[i]! - octets2[i]!
- }
- }
- return 0
-}
-
-// Custom sorting functions
-const sortFunctions = {
- endTime: (a: ConnectionsRecord, b: ConnectionsRecord) => {
- return (a.endTime ?? 0) - (b.endTime ?? 0)
- },
- duration: (a: ConnectionsRecord, b: ConnectionsRecord) => {
- return (a.duration ?? 0) - (b.duration ?? 0)
- },
- virtualIpAddress: (a: ConnectionsRecord, b: ConnectionsRecord) => {
- return compareIpAddresses(a.virtualIpAddress, b.virtualIpAddress)
- },
- remoteIpAddress: (a: ConnectionsRecord, b: ConnectionsRecord) => {
- return compareIpAddresses(a.remoteIpAddress, b.remoteIpAddress)
- }
-}
-
-const { sortedItems } = useSort(
- computed(() => props.connectionsRecords),
- sortKey,
- sortDescending,
- sortFunctions
-)
+const emit = defineEmits<{
+ (e: 'select-page', page: number): void
+ (e: 'select-page-size', size: number): void
+ (e: 'sort', payload: { key: string; descending: boolean }): void
+ (e: 'clear-filters'): void
+}>()
-const onSort = (payload: SortEvent) => {
- sortKey.value = payload.key as keyof ConnectionsRecord
- sortDescending.value = payload.descending
+function onSort(payload: SortEvent) {
+ emit('sort', { key: payload.key, descending: payload.descending })
}
-const { currentPage, paginatedItems } = useItemPagination(() => sortedItems.value, {
- itemsPerPage: pageSize
-})
-
// Format duration in seconds to human readable format
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600)
@@ -92,8 +60,9 @@ function formatDuration(seconds: number): string {
- {{
- t('standalone.openvpn_rw.history.account')
- }}
- {{
- t('standalone.openvpn_rw.history.start_time')
- }}
- {{
- t('standalone.openvpn_rw.history.end_time')
- }}
- {{
- t('standalone.openvpn_rw.history.duration')
- }}
- {{
- t('standalone.openvpn_rw.history.virtual_ip_address')
- }}
- {{
- t('standalone.openvpn_rw.history.remote_ip_address')
- }}
+ {{ t('standalone.openvpn_rw.history.account') }}
+ {{ t('standalone.openvpn_rw.history.start_time') }}
+ {{ t('standalone.openvpn_rw.history.end_time') }}
+ {{ t('standalone.openvpn_rw.history.duration') }}
+ {{ t('standalone.openvpn_rw.history.virtual_ip_address') }}
+ {{ t('standalone.openvpn_rw.history.remote_ip_address') }}
{{ t('standalone.openvpn_rw.history.received_sent') }}
-
-
+
+
{{ item.account }}
@@ -165,38 +153,42 @@ function formatDuration(seconds: number): string {
-
+
{{ item?.bytesReceived ? byteFormat1024(item.bytesReceived) : '-' }} /
{{ item?.bytesSent ? byteFormat1024(item.bytesSent) : '-' }}
-
+
+
+
+
+
+
+ {{ t('common.clear_filters') }}
+
+
+
+
+
{
- currentPage = page
- }
- "
- @select-page-size="
- (size: number) => {
- pageSize = size
- }
- "
+ @select-page="(page: number) => emit('select-page', page)"
+ @select-page-size="(size: number) => emit('select-page-size', size)"
/>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 803900f19..e19c77988 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -431,7 +431,8 @@
"cannot_regenerate_cert": "Cannot regenerate certificates",
"cannot_renew_server_cert": "Cannot renew server certificate",
"cannot_regenerate_all_certs": "Cannot regenerate all certificates",
- "cannot_retrieve_flows": "Cannot retrieve flows, please try again later"
+ "cannot_retrieve_flows": "Cannot retrieve flows, please try again later",
+ "cannot_retrieve_ovpn_rw_connection_history": "Cannot retrieve connection history, please try again later"
},
"ne_text_input": {
"show_password": "Show password",
@@ -1995,7 +1996,7 @@
"virtual_ip_address": "Virtual IP address",
"remote_ip_address": "Remote IP address",
"received_sent": "Received / Sent",
- "connection_history_description": "This page shows the history of connections made by users to the OpenVPN Road Warrior server. The connection history is stored in RAM and is lost when the unit is rebooted.",
+ "connection_history_description": "This page shows the history of connections made by users to the OpenVPN Road Warrior server. The connection history is stored in RAM and is lost when the unit is rebooted. If an external storage has been configured, the connection history will be persisted across reboots.",
"download_history": "Download server history",
"filter_change_suggestion": "Try changing the search filters",
"no_connections_found": "No connections found",
@@ -2013,7 +2014,8 @@
"reset_filter": "Reset filter",
"filter_accounts": "Filter accounts",
"cannot_download_history": "Cannot download history",
- "cannot_fetch_history": "Cannot fetch history"
+ "cannot_fetch_history": "Cannot fetch history",
+ "no_connections_found_description": "No connections found found for the current filters. Try changing the search filters or wait for new connections to be established."
},
"certificates_expiration": "Certificates expiration",
"certificate_expiration": "Certificate expiration",
diff --git a/src/i18n/it.json b/src/i18n/it.json
index 7e904db4a..7b854ca9b 100644
--- a/src/i18n/it.json
+++ b/src/i18n/it.json
@@ -429,7 +429,8 @@
"cannot_restart_ipsec": "Impossibile riavviare il servizio IPsec",
"cannot_regenerate_cert": "Impossibile rigenerare i certificati",
"cannot_renew_server_cert": "Impossibile rinnovare il certificato del server",
- "cannot_regenerate_all_certs": "Impossibile rigenerare tutti i certificati"
+ "cannot_regenerate_all_certs": "Impossibile rigenerare tutti i certificati",
+ "cannot_retrieve_ovpn_rw_connection_history": "Impossibile recuperare la cronologia delle connessioni, riprova più tardi"
},
"login": {
"sign_in": "Accedi",
@@ -1852,7 +1853,7 @@
"download_history": "Scarica la cronologia del server",
"received_sent": "Ricevuto / Inviato",
"no_connections_found": "Nessuna connessione trovata",
- "connection_history_description": "Questa pagina mostra la cronologia delle connessioni effettuate dagli utenti al server OpenVPN Road Warrior. La cronologia delle connessioni è memorizzata nella RAM e viene persa quando l'unità viene riavviata.",
+ "connection_history_description": "Questa pagina mostra la cronologia delle connessioni effettuate dagli utenti al server OpenVPN Road Warrior. La cronologia delle connessioni è memorizzata nella RAM e viene persa quando l'unità viene riavviata. Se è stato configurato uno storage esterno, la cronologia delle connessioni sarà mantenuta anche dopo il riavvio.",
"account": "Account",
"filter_change_suggestion": "Prova a cambiare i filtri di ricerca",
"last_week": "Settimana scorsa",
@@ -1866,7 +1867,8 @@
"last_month": "Mese precedente",
"reset_filter": "Ripristina filtri",
"all": "Qualsiasi",
- "reset_filters": "Ripristina filtri"
+ "reset_filters": "Ripristina filtri",
+ "no_connections_found_description": "Nessuna connessione trovata per i filtri attuali. Prova a cambiare i filtri di ricerca o attendi che vengano stabilite nuove connessioni."
},
"tabs": {
"road_warrior_server": "Server Road Warrior",