From bcad46bd1b5fbc50fee9ad722aeb91a34369c924 Mon Sep 17 00:00:00 2001
From: Matteo Di Lorenzi
Date: Thu, 12 Mar 2026 15:11:29 +0100
Subject: [PATCH 1/2] feat(connections-history): enhance connection history
management with tanstack
---
.../openvpn_rw/ConnectionsHistory.vue | 322 +++++++++---------
.../openvpn_rw/UserConnectionsTable.vue | 134 ++++----
src/i18n/en.json | 8 +-
src/i18n/it.json | 8 +-
4 files changed, 224 insertions(+), 248 deletions(-)
diff --git a/src/components/standalone/openvpn_rw/ConnectionsHistory.vue b/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
index 8b252ea0e..07ba6cc3b 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,138 @@ 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 +179,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 +206,14 @@ 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()
+ 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
-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
- }
-}
-
-// fetch connection history on component mount
-onMounted(() => {
- fetchConnectionHistory()
-})
-
-// when timeRangeFilter changes, reset to [] accountsFilter
-watch(timeRangeFilter, () => {
- accountsFilter.value = []
-})
@@ -242,7 +224,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..b19cf68af 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.received_sent') }}
-
-
+
+
{{ item.account }}
@@ -166,37 +136,49 @@ 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",
From 9c629f8b02b0212ee4f1b57d39c0c12e122f2b8b Mon Sep 17 00:00:00 2001
From: Matteo Di Lorenzi
Date: Thu, 12 Mar 2026 15:14:50 +0100
Subject: [PATCH 2/2] refactor(connections-history): improve code readability
and formatting
---
.../openvpn_rw/ConnectionsHistory.vue | 37 +++++--
.../openvpn_rw/UserConnectionsTable.vue | 96 ++++++++++---------
2 files changed, 83 insertions(+), 50 deletions(-)
diff --git a/src/components/standalone/openvpn_rw/ConnectionsHistory.vue b/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
index 07ba6cc3b..e04aaf3d5 100644
--- a/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
+++ b/src/components/standalone/openvpn_rw/ConnectionsHistory.vue
@@ -44,8 +44,8 @@ type ConnectionsHistoryResponse = {
current_page: number
last_page: number
per_page: number
- results: number,
- total: number,
+ results: number
+ total: number
filters: {
accounts: string[]
}
@@ -87,7 +87,15 @@ const accountsFilter = useRouteQuery('accounts', '', {
const currentPage = ref(1)
const perPage = ref(10)
-type SortableKeys = 'account' | 'startTime' | 'endTime' | 'duration' | 'virtualIpAddress' | 'remoteIpAddress' | 'bytesReceived' | 'bytesSent'
+type SortableKeys =
+ | 'account'
+ | 'startTime'
+ | 'endTime'
+ | 'duration'
+ | 'virtualIpAddress'
+ | 'remoteIpAddress'
+ | 'bytesReceived'
+ | 'bytesSent'
const sortKey = useRouteQuery('sort', 'startTime')
const sortDescending = useRouteQuery('descending', 'true', {
transform: {
@@ -209,7 +217,9 @@ async function downloadAllHistory() {
await deleteFile(res.data.csv_path)
}
} catch (exception: any) {
- downloadError.value.notificationTitle = t('standalone.openvpn_rw.history.cannot_download_history')
+ downloadError.value.notificationTitle = t(
+ 'standalone.openvpn_rw.history.cannot_download_history'
+ )
downloadError.value.notificationDescription = t(getAxiosErrorMessage(exception))
downloadError.value.notificationDetails = exception.toString()
}
@@ -301,9 +311,22 @@ async function downloadAllHistory() {
:page-size="perPage"
:sort-key="sortKey"
:sort-descending="sortDescending"
- @select-page="(page: number) => { currentPage = page }"
- @select-page-size="(size: number) => { perPage = size }"
- @sort="({ key, descending }: { key: string; descending: boolean }) => { sortKey = key as typeof sortKey; sortDescending = descending }"
+ @select-page="
+ (page: number) => {
+ 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"
/>
- {{
- 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') }}
@@ -135,38 +153,30 @@ function formatDuration(seconds: number): string {
-
+
{{ item?.bytesReceived ? byteFormat1024(item.bytesReceived) : '-' }} /
{{ item?.bytesSent ? byteFormat1024(item.bytesSent) : '-' }}
-
+
-
-
-
-
- {{ t('common.clear_filters') }}
-
-
-
-
-
+
+
+
+
+ {{ t('common.clear_filters') }}
+
+
+
+
+