mirror of
https://github.com/h44z/wg-portal
synced 2025-02-26 05:49:14 +00:00
feat: Added peers sorting on views (#302)
* Added peers sorting for Interface and Profile views * Use ip-address package to sort with IPv6 addresses only * Add RX/TX column and fix add-peer button title
This commit is contained in:
parent
6ffe1a90ae
commit
3196010a58
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@ -14,6 +14,7 @@
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootswatch": "^5.3.2",
|
||||
"flag-icons": "^7.1.0",
|
||||
"ip-address": "^9.0.5",
|
||||
"is-cidr": "^5.0.3",
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
@ -914,6 +915,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
|
||||
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-5.0.0.tgz",
|
||||
@ -962,6 +976,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.5",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
||||
@ -1117,6 +1137,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/super-regex": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/super-regex/-/super-regex-0.2.0.tgz",
|
||||
|
@ -14,6 +14,7 @@
|
||||
"bootstrap": "^5.3.2",
|
||||
"bootswatch": "^5.3.2",
|
||||
"flag-icons": "^7.1.0",
|
||||
"ip-address": "^9.0.5",
|
||||
"is-cidr": "^5.0.3",
|
||||
"is-ip": "^5.0.1",
|
||||
"pinia": "^2.1.7",
|
||||
|
@ -7,3 +7,11 @@ a.disabled {
|
||||
.text-wrap {
|
||||
overflow-break: anywhere;
|
||||
}
|
||||
|
||||
.asc::after {
|
||||
content: " ↑";
|
||||
}
|
||||
|
||||
.desc::after {
|
||||
content: " ↓";
|
||||
}
|
||||
|
20
frontend/src/helpers/utils.js
Normal file
20
frontend/src/helpers/utils.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { Address4, Address6 } from "ip-address"
|
||||
|
||||
export function ipToBigInt(ip) {
|
||||
// Check if it's an IPv4 address
|
||||
if (ip.includes(".")) {
|
||||
const addr = new Address4(ip)
|
||||
return addr.bigInteger()
|
||||
}
|
||||
|
||||
// Otherwise, assume it's an IPv6 address
|
||||
const addr = new Address6(ip)
|
||||
return addr.bigInteger()
|
||||
}
|
||||
|
||||
export function humanFileSize(size) {
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||
if (size === 0) return "0B"
|
||||
const i = parseInt(Math.floor(Math.log(size) / Math.log(1024)))
|
||||
return Math.round(size / Math.pow(1024, i), 2) + sizes[i]
|
||||
}
|
@ -4,6 +4,7 @@ import {notify} from "@kyvg/vue3-notification";
|
||||
import {interfaceStore} from "./interfaces";
|
||||
import {freshPeer, freshStats} from '@/helpers/models';
|
||||
import { base64_url_encode } from '@/helpers/encoding';
|
||||
import { ipToBigInt } from '@/helpers/utils';
|
||||
|
||||
const baseUrl = `/peer`
|
||||
|
||||
@ -21,6 +22,8 @@ export const peerStore = defineStore({
|
||||
pageOffset: 0,
|
||||
pages: [],
|
||||
fetching: false,
|
||||
sortKey: 'IsConnected', // Default sort key
|
||||
sortOrder: -1, // 1 for ascending, -1 for descending
|
||||
}),
|
||||
getters: {
|
||||
Find: (state) => {
|
||||
@ -39,8 +42,30 @@ export const peerStore = defineStore({
|
||||
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
|
||||
})
|
||||
},
|
||||
Sorted: (state) => {
|
||||
return state.Filtered.slice().sort((a, b) => {
|
||||
let aValue = a[state.sortKey];
|
||||
let bValue = b[state.sortKey];
|
||||
if (state.sortKey === 'Addresses') {
|
||||
aValue = aValue.length > 0 ? ipToBigInt(aValue[0]) : 0;
|
||||
bValue = bValue.length > 0 ? ipToBigInt(bValue[0]) : 0;
|
||||
}
|
||||
if (state.sortKey === 'IsConnected') {
|
||||
aValue = state.statsEnabled && state.stats[a.Identifier]?.IsConnected ? 1 : 0;
|
||||
bValue = state.statsEnabled && state.stats[b.Identifier]?.IsConnected ? 1 : 0;
|
||||
}
|
||||
if (state.sortKey === 'Traffic') {
|
||||
aValue = state.statsEnabled ? (state.stats[a.Identifier].BytesReceived + state.stats[a.Identifier].BytesTransmitted) : 0;
|
||||
bValue = state.statsEnabled ? (state.stats[b.Identifier].BytesReceived + state.stats[b.Identifier].BytesTransmitted) : 0;
|
||||
}
|
||||
let result = 0;
|
||||
if (aValue > bValue) result = 1;
|
||||
if (aValue < bValue) result = -1;
|
||||
return state.sortOrder === 1 ? result : -result;
|
||||
});
|
||||
},
|
||||
FilteredAndPaged: (state) => {
|
||||
return state.Filtered.slice(state.pageOffset, state.pageOffset + state.pageSize)
|
||||
return state.Sorted.slice(state.pageOffset, state.pageOffset + state.pageSize);
|
||||
},
|
||||
ConfigQrUrl: (state) => {
|
||||
return (id) => state.peers.find((p) => p.Identifier === id) ? apiWrapper.url(`${baseUrl}/config-qr/${base64_url_encode(id)}`) : ''
|
||||
|
@ -4,6 +4,7 @@ import {notify} from "@kyvg/vue3-notification";
|
||||
import {authStore} from "@/stores/auth";
|
||||
import { base64_url_encode } from '@/helpers/encoding';
|
||||
import {freshStats} from "@/helpers/models";
|
||||
import { ipToBigInt } from '@/helpers/utils';
|
||||
|
||||
const baseUrl = `/user`
|
||||
|
||||
@ -19,6 +20,8 @@ export const profileStore = defineStore({
|
||||
pageOffset: 0,
|
||||
pages: [],
|
||||
fetching: false,
|
||||
sortKey: 'IsConnected', // Default sort key
|
||||
sortOrder: -1, // 1 for ascending, -1 for descending
|
||||
}),
|
||||
getters: {
|
||||
FindPeers: (state) => {
|
||||
@ -35,8 +38,30 @@ export const profileStore = defineStore({
|
||||
return p.DisplayName.includes(state.filter) || p.Identifier.includes(state.filter)
|
||||
})
|
||||
},
|
||||
Sorted: (state) => {
|
||||
return state.FilteredPeers.slice().sort((a, b) => {
|
||||
let aValue = a[state.sortKey];
|
||||
let bValue = b[state.sortKey];
|
||||
if (state.sortKey === 'Addresses') {
|
||||
aValue = aValue.length > 0 ? ipToBigInt(aValue[0]) : 0;
|
||||
bValue = bValue.length > 0 ? ipToBigInt(bValue[0]) : 0;
|
||||
}
|
||||
if (state.sortKey === 'IsConnected') {
|
||||
aValue = state.statsEnabled && state.stats[a.Identifier]?.IsConnected ? 1 : 0;
|
||||
bValue = state.statsEnabled && state.stats[b.Identifier]?.IsConnected ? 1 : 0;
|
||||
}
|
||||
if (state.sortKey === 'Traffic') {
|
||||
aValue = state.statsEnabled ? (state.stats[a.Identifier].BytesReceived + state.stats[a.Identifier].BytesTransmitted) : 0;
|
||||
bValue = state.statsEnabled ? (state.stats[b.Identifier].BytesReceived + state.stats[b.Identifier].BytesTransmitted) : 0;
|
||||
}
|
||||
let result = 0;
|
||||
if (aValue > bValue) result = 1;
|
||||
if (aValue < bValue) result = -1;
|
||||
return state.sortOrder === 1 ? result : -result;
|
||||
});
|
||||
},
|
||||
FilteredAndPagedPeers: (state) => {
|
||||
return state.FilteredPeers.slice(state.pageOffset, state.pageOffset + state.pageSize)
|
||||
return state.Sorted.slice(state.pageOffset, state.pageOffset + state.pageSize);
|
||||
},
|
||||
isFetching: (state) => state.fetching,
|
||||
hasNextPage: (state) => state.pageOffset < (state.FilteredPeerCount - state.pageSize),
|
||||
|
@ -10,6 +10,7 @@ import {peerStore} from "@/stores/peers";
|
||||
import {interfaceStore} from "@/stores/interfaces";
|
||||
import {notify} from "@kyvg/vue3-notification";
|
||||
import {settingsStore} from "@/stores/settings";
|
||||
import {humanFileSize} from '@/helpers/utils';
|
||||
|
||||
const settings = settingsStore()
|
||||
const interfaces = interfaceStore()
|
||||
@ -21,6 +22,20 @@ const multiCreatePeerId = ref("")
|
||||
const editInterfaceId = ref("")
|
||||
const viewedInterfaceId = ref("")
|
||||
|
||||
const sortKey = ref("");
|
||||
const sortOrder = ref(1);
|
||||
|
||||
function sortBy(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortOrder.value = 1; // Default to ascending
|
||||
}
|
||||
peers.sortKey = sortKey.value;
|
||||
peers.sortOrder = sortOrder.value;
|
||||
}
|
||||
|
||||
function calculateInterfaceName(id, name) {
|
||||
let result = id
|
||||
if (name) {
|
||||
@ -314,11 +329,28 @@ onMounted(async () => {
|
||||
<input id="flexCheckDefault" class="form-check-input" :title="$t('general.select-all')" type="checkbox" value="">
|
||||
</th><!-- select -->
|
||||
<th scope="col"></th><!-- status -->
|
||||
<th scope="col">{{ $t('interfaces.table-heading.name') }}</th>
|
||||
<th scope="col">{{ $t('interfaces.table-heading.user') }}</th>
|
||||
<th scope="col">{{ $t('interfaces.table-heading.ip') }}</th>
|
||||
<th v-if="interfaces.GetSelected.Mode==='client'" scope="col">{{ $t('interfaces.table-heading.endpoint') }}</th>
|
||||
<th v-if="peers.hasStatistics" scope="col">{{ $t('interfaces.table-heading.status') }}</th>
|
||||
<th scope="col" @click="sortBy('DisplayName')">
|
||||
{{ $t("interfaces.table-heading.name") }}
|
||||
<i v-if="sortKey === 'DisplayName'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th scope="col" @click="sortBy('UserIdentifier')">
|
||||
{{ $t("interfaces.table-heading.user") }}
|
||||
<i v-if="sortKey === 'UserIdentifier'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th scope="col" @click="sortBy('Addresses')">
|
||||
{{ $t("interfaces.table-heading.ip") }}
|
||||
<i v-if="sortKey === 'Addresses'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th v-if="interfaces.GetSelected.Mode === 'client'" scope="col">
|
||||
{{ $t("interfaces.table-heading.endpoint") }}
|
||||
</th>
|
||||
<th v-if="peers.hasStatistics" scope="col" @click="sortBy('IsConnected')">
|
||||
{{ $t("interfaces.table-heading.status") }}
|
||||
<i v-if="sortKey === 'IsConnected'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th v-if="peers.hasStatistics" scope="col" @click="sortBy('Traffic')">RX/TX
|
||||
<i v-if="sortKey === 'Traffic'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th scope="col"></th><!-- Actions -->
|
||||
</tr>
|
||||
</thead>
|
||||
@ -345,6 +377,9 @@ onMounted(async () => {
|
||||
<span class="badge rounded-pill bg-light" :title="$t('interfaces.peer-not-connected')"><i class="fa-solid fa-link-slash"></i></span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="peers.hasStatistics" >
|
||||
<span class="text-center" >{{ humanFileSize(peers.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(peers.Statistics(peer.Identifier).BytesTransmitted) }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a href="#" :title="$t('interfaces.button-show-peer')" @click.prevent="viewedPeerId=peer.Identifier"><i class="fas fa-eye me-2"></i></a>
|
||||
<a href="#" :title="$t('interfaces.button-edit-peer')" @click.prevent="editPeerId=peer.Identifier"><i class="fas fa-cog"></i></a>
|
||||
|
@ -5,6 +5,7 @@ import { onMounted, ref } from "vue";
|
||||
import { profileStore } from "@/stores/profile";
|
||||
import PeerEditModal from "@/components/PeerEditModal.vue";
|
||||
import { settingsStore } from "@/stores/settings";
|
||||
import { humanFileSize } from "@/helpers/utils";
|
||||
|
||||
const settings = settingsStore()
|
||||
const profile = profileStore()
|
||||
@ -12,10 +13,25 @@ const profile = profileStore()
|
||||
const viewedPeerId = ref("")
|
||||
const editPeerId = ref("")
|
||||
|
||||
const sortKey = ref("");
|
||||
const sortOrder = ref(1);
|
||||
|
||||
function sortBy(key) {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value * -1; // Toggle sort order
|
||||
} else {
|
||||
sortKey.value = key;
|
||||
sortOrder.value = 1; // Default to ascending
|
||||
}
|
||||
profile.sortKey = sortKey.value;
|
||||
profile.sortOrder = sortOrder.value;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await profile.LoadUser()
|
||||
await profile.LoadPeers()
|
||||
await profile.LoadStats()
|
||||
await profile.calculatePages(); // Forces to show initial page number
|
||||
})
|
||||
|
||||
</script>
|
||||
@ -41,7 +57,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="col-12 col-lg-3 text-lg-end">
|
||||
<a v-if="settings.Setting('SelfProvisioning')" class="btn btn-primary ms-2" href="#"
|
||||
:title="$t('general.search.button-add-peer')" @click.prevent="editPeerId = '#NEW#'"><i
|
||||
:title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId = '#NEW#'"><i
|
||||
class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
@ -58,9 +74,21 @@ onMounted(async () => {
|
||||
value="">
|
||||
</th><!-- select -->
|
||||
<th scope="col"></th><!-- status -->
|
||||
<th scope="col">{{ $t('profile.table-heading.name') }}</th>
|
||||
<th scope="col">{{ $t('profile.table-heading.ip') }}</th>
|
||||
<th v-if="profile.hasStatistics" scope="col">{{ $t('profile.table-heading.stats') }}</th>
|
||||
<th scope="col" @click="sortBy('DisplayName')">
|
||||
{{ $t("profile.table-heading.name") }}
|
||||
<i v-if="sortKey === 'DisplayName'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th scope="col" @click="sortBy('Addresses')">
|
||||
{{ $t("profile.table-heading.ip") }}
|
||||
<i v-if="sortKey === 'Addresses'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th v-if="profile.hasStatistics" scope="col" @click="sortBy('IsConnected')">
|
||||
{{ $t("profile.table-heading.stats") }}
|
||||
<i v-if="sortKey === 'IsConnected'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th v-if="profile.hasStatistics" scope="col" @click="sortBy('Traffic')">RX/TX
|
||||
<i v-if="sortKey === 'Traffic'" :class="sortOrder === 1 ? 'asc' : 'desc'"></i>
|
||||
</th>
|
||||
<th scope="col">{{ $t('profile.table-heading.interface') }}</th>
|
||||
<th scope="col"></th><!-- Actions -->
|
||||
</tr>
|
||||
@ -90,6 +118,9 @@ onMounted(async () => {
|
||||
<span class="badge rounded-pill bg-light"><i class="fa-solid fa-link-slash"></i></span>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="profile.hasStatistics" >
|
||||
<span class="text-center" >{{ humanFileSize(profile.Statistics(peer.Identifier).BytesReceived) }} / {{ humanFileSize(profile.Statistics(peer.Identifier).BytesTransmitted) }}</span>
|
||||
</td>
|
||||
<td>{{ peer.InterfaceIdentifier }}</td>
|
||||
<td class="text-center">
|
||||
<a href="#" :title="$t('profile.button-show-peer')" @click.prevent="viewedPeerId = peer.Identifier"><i
|
||||
|
Loading…
Reference in New Issue
Block a user