diff --git a/src/dashboard.py b/src/dashboard.py index ec57ef9d..dbcf671c 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -526,6 +526,13 @@ def API_getWireguardConfigurationRealtimeTraffic(): return ResponseObject(False, "Configuration does not exist", status_code=404) return ResponseObject(data=WireguardConfigurations[configurationName].getRealtimeTrafficUsage()) +@app.get(f'{APP_PREFIX}/api/getWireguardConfigurationPeersRealtimeSpeed') +def API_getWireguardConfigurationPeersRealtimeSpeed(): + configurationName = request.args.get('configurationName') + if configurationName is None or configurationName not in WireguardConfigurations.keys(): + return ResponseObject(False, "Configuration does not exist", status_code=404) + return ResponseObject(data=WireguardConfigurations[configurationName].getPeersRealtimeSpeed()) + @app.get(f'{APP_PREFIX}/api/getWireguardConfigurationBackup') def API_getWireguardConfigurationBackup(): configurationName = request.args.get('configurationName') diff --git a/src/modules/WireguardConfiguration.py b/src/modules/WireguardConfiguration.py index f1fdfe16..a57779aa 100644 --- a/src/modules/WireguardConfiguration.py +++ b/src/modules/WireguardConfiguration.py @@ -66,6 +66,8 @@ class WireguardConfiguration: self.configPath = os.path.join(self.__getProtocolPath(), f'{self.Name}.conf') self.engine: sqlalchemy.Engine = sqlalchemy.create_engine(ConnectionString("wgdashboard")) self.metadata: sqlalchemy.MetaData = sqlalchemy.MetaData() + self.__last_transfer: dict = {} + self.__last_transfer_time: float = 0 self.dbType = self.DashboardConfig.GetConfig("Database", "type")[1] if name is not None: @@ -1155,7 +1157,70 @@ class WireguardConfiguration: return { "sent": 0, "recv": 0 } else: return { "sent": 0, "recv": 0 } - + + def getPeersRealtimeSpeed(self): + """ + Calculate per-peer bandwidth speed (MB/s) by comparing two successive + 'wg show transfer' snapshots taken ~1 second apart. + Returns dict: { peer_id: { "name": str, "sent": float, "recv": float } } + """ + if not self.getStatus(): + return {} + + def _parse_transfer(raw: str) -> dict: + result = {} + for line in raw.strip().split("\n"): + parts = line.split("\t") + if len(parts) == 3: + peer_id = parts[0] + recv_bytes = int(parts[1]) + sent_bytes = int(parts[2]) + result[peer_id] = {"recv": recv_bytes, "sent": sent_bytes} + return result + + try: + snap1_raw = subprocess.check_output( + f"{self.Protocol} show {self.Name} transfer", + shell=True, stderr=subprocess.STDOUT + ).decode("UTF-8") + snap1 = _parse_transfer(snap1_raw) + + time.sleep(1) + + snap2_raw = subprocess.check_output( + f"{self.Protocol} show {self.Name} transfer", + shell=True, stderr=subprocess.STDOUT + ).decode("UTF-8") + snap2 = _parse_transfer(snap2_raw) + except subprocess.CalledProcessError: + return {} + + peers_speed = {} + for peer_id in snap2: + if peer_id in snap1: + delta_recv = snap2[peer_id]["recv"] - snap1[peer_id]["recv"] + delta_sent = snap2[peer_id]["sent"] - snap1[peer_id]["sent"] + # Convert bytes to MB/s + speed_recv = round(max(delta_recv, 0) / (1024 * 1024), 4) + speed_sent = round(max(delta_sent, 0) / (1024 * 1024), 4) + else: + speed_recv = 0 + speed_sent = 0 + + # Find peer name + peer_name = "" + found, p = self.searchPeer(peer_id) + if found: + peer_name = p.name or "" + + peers_speed[peer_id] = { + "name": peer_name, + "recv": speed_recv, + "sent": speed_sent + } + + return peers_speed + ''' Manager WireGuard Configuration Information ''' diff --git a/src/static/app/src/components/configurationComponents/peer.vue b/src/static/app/src/components/configurationComponents/peer.vue index b514463b..8ac37ad3 100644 --- a/src/static/app/src/components/configurationComponents/peer.vue +++ b/src/static/app/src/components/configurationComponents/peer.vue @@ -15,7 +15,7 @@ export default { PeerTagBadge, LocaleText, PeerSettingsDropdown }, props: { - Peer: Object, ConfigurationInfo: Object, order: Number, searchPeersLength: Number + Peer: Object, ConfigurationInfo: Object, order: Number, searchPeersLength: Number, peerSpeed: Object }, setup(){ const target = ref(null); @@ -58,7 +58,7 @@ export default { -
+
{{(Peer.cumu_receive + Peer.total_receive).toFixed(4)}} GB @@ -71,6 +71,13 @@ export default { {{getLatestHandshake}} ago + + + ↓{{peerSpeed.recv}} + ↑{{peerSpeed.sent}} + MB/s +
{ } await fetchPeerList() +// Per-Peer Realtime Speed ===================================== +const peerSpeeds = ref({}) +const fetchPeerSpeeds = async () => { + if (!configurationInfo.value.Status) return + await fetchGet("/api/getWireguardConfigurationPeersRealtimeSpeed", { + configurationName: route.params.id + }, (res) => { + if (res.status && res.data) { + peerSpeeds.value = res.data + } + }) +} + // Fetch Peer Interval ===================================== const fetchPeerListInterval = ref(undefined) +const fetchPeerSpeedInterval = ref(undefined) const setFetchPeerListInterval = () => { clearInterval(fetchPeerListInterval.value) + clearInterval(fetchPeerSpeedInterval.value) + const interval = parseInt(dashboardStore.Configuration.Server.dashboard_refresh_interval) fetchPeerListInterval.value = setInterval(async () => { await fetchPeerList() - }, parseInt(dashboardStore.Configuration.Server.dashboard_refresh_interval)) + }, interval) + fetchPeerSpeedInterval.value = setInterval(async () => { + await fetchPeerSpeeds() + }, interval) } setFetchPeerListInterval() onBeforeUnmount(() => { clearInterval(fetchPeerListInterval.value); + clearInterval(fetchPeerSpeedInterval.value); fetchPeerListInterval.value = undefined; + fetchPeerSpeedInterval.value = undefined; wireguardConfigurationStore.Filter.HiddenTags = [] }) @@ -414,10 +435,11 @@ watch(() => route.query.id, (newValue) => { :class="{'col-lg-6 col-xl-4': dashboardStore.Configuration.Server.dashboard_peer_list_display === 'grid'}" :key="peer.id" v-for="(peer, order) in searchPeers"> - { } }) } + +const fetchPeerSpeedInterval = ref(undefined) +const MAX_PEER_SPEED_POINTS = 30 +const fetchPeerSpeed = async () => { + await fetchGet("/api/getWireguardConfigurationPeersRealtimeSpeed", { + configurationName: route.params.id + }, (res) => { + if (!res.status || !res.data) return + const timestamp = dayjs().format("hh:mm:ss A") + currentPeerSpeed.value = res.data + + for (const [peerId, info] of Object.entries(res.data)) { + if (!peerSpeedHistory.value[peerId]) { + peerSpeedHistory.value[peerId] = { + name: info.name || peerId.substring(0, 8) + '...', + timestamps: [], + recv: [], + sent: [] + } + } + const h = peerSpeedHistory.value[peerId] + h.name = info.name || peerId.substring(0, 8) + '...' + h.timestamps.push(timestamp) + h.recv.push(info.recv) + h.sent.push(info.sent) + // Trim to max points + if (h.timestamps.length > MAX_PEER_SPEED_POINTS) { + h.timestamps.shift() + h.recv.shift() + h.sent.shift() + } + } + }) +} + const toggleFetchRealtimeTraffic = () => { clearInterval(fetchRealtimeTrafficInterval.value) + clearInterval(fetchPeerSpeedInterval.value) fetchRealtimeTrafficInterval.value = undefined; + fetchPeerSpeedInterval.value = undefined; if (props.configurationInfo.Status){ + const interval = parseInt(dashboardStore.Configuration.Server.dashboard_refresh_interval) fetchRealtimeTrafficInterval.value = setInterval(() => { fetchRealtimeTraffic() - }, parseInt(dashboardStore.Configuration.Server.dashboard_refresh_interval)) + }, interval) + fetchPeerSpeedInterval.value = setInterval(() => { + fetchPeerSpeed() + }, interval) } } @@ -98,8 +150,11 @@ watch(() => dashboardStore.Configuration.Server.dashboard_refresh_interval, () = onBeforeUnmount(() => { clearInterval(fetchRealtimeTrafficInterval.value) + clearInterval(fetchPeerSpeedInterval.value) fetchRealtimeTrafficInterval.value = undefined; + fetchPeerSpeedInterval.value = undefined; }) + const peersDataUsageChartData = computed(() => { let data = props.configurationPeers.filter(x => (x.cumu_data + x.total_data) > 0) @@ -122,6 +177,90 @@ const peersDataUsageChartData = computed(() => { }] } }) + +// Per-peer current speed bar chart +const peerSpeedBarData = computed(() => { + const entries = Object.entries(currentPeerSpeed.value) + if (entries.length === 0) return { labels: [], datasets: [] } + + const labels = entries.map(([id, info]) => info.name || id.substring(0, 8) + '...') + const recvData = entries.map(([id, info]) => info.recv) + const sentData = entries.map(([id, info]) => info.sent) + + return { + labels, + datasets: [ + { + label: GetLocale('Received'), + data: recvData, + backgroundColor: '#0d6efd90', + borderColor: '#0d6efd', + borderWidth: 1, + }, + { + label: GetLocale('Sent'), + data: sentData, + backgroundColor: '#19875490', + borderColor: '#198754', + borderWidth: 1, + } + ] + } +}) + +// Per-peer speed line chart - Received +const peerSpeedRecvLineData = computed(() => { + const peerIds = Object.keys(peerSpeedHistory.value) + if (peerIds.length === 0) return { labels: [], datasets: [] } + + // Use timestamps from the first peer (all should be in sync) + const firstPeer = peerSpeedHistory.value[peerIds[0]] + const labels = firstPeer ? [...firstPeer.timestamps] : [] + + const datasets = peerIds.map((peerId, idx) => { + const h = peerSpeedHistory.value[peerId] + const color = peerSpeedColors[idx % peerSpeedColors.length] + return { + label: h.name, + data: [...h.recv], + borderColor: color, + backgroundColor: color + '40', + fill: false, + tension: 0.2, + pointRadius: 1, + borderWidth: 2, + } + }) + + return { labels, datasets } +}) + +// Per-peer speed line chart - Sent +const peerSpeedSentLineData = computed(() => { + const peerIds = Object.keys(peerSpeedHistory.value) + if (peerIds.length === 0) return { labels: [], datasets: [] } + + const firstPeer = peerSpeedHistory.value[peerIds[0]] + const labels = firstPeer ? [...firstPeer.timestamps] : [] + + const datasets = peerIds.map((peerId, idx) => { + const h = peerSpeedHistory.value[peerId] + const color = peerSpeedColors[idx % peerSpeedColors.length] + return { + label: h.name, + data: [...h.sent], + borderColor: color, + backgroundColor: color + '40', + fill: false, + tension: 0.2, + pointRadius: 1, + borderWidth: 2, + } + }) + + return { labels, datasets } +}) + const peersRealtimeSentData = computed(() => { return { labels: [...historySentData.value.timestamp], @@ -188,6 +327,47 @@ const peersDataUsageChartOption = computed(() => { } } }) + +const peerSpeedBarOption = computed(() => { + return { + responsive: true, + plugins: { + legend: { + display: true, + position: 'top', + labels: { boxWidth: 12 } + }, + tooltip: { + callbacks: { + label: (tooltipItem) => { + return `${tooltipItem.dataset.label}: ${tooltipItem.formattedValue} MB/s` + } + } + } + }, + scales: { + x: { + ticks: { + display: false, + }, + grid: { + display: false + }, + }, + y:{ + ticks: { + callback: (val) => { + return `${Math.round((val + Number.EPSILON) * 1000) / 1000} MB/s` + } + }, + grid: { + display: false + }, + } + } + } +}) + const realtimePeersChartOption = computed(() => { return { responsive: true, @@ -225,6 +405,57 @@ const realtimePeersChartOption = computed(() => { } } }) + +const perPeerLineChartOption = computed(() => { + return { + responsive: true, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + boxWidth: 12, + font: { size: 10 } + } + }, + tooltip: { + callbacks: { + label: (tooltipItem) => { + return `${tooltipItem.dataset.label}: ${tooltipItem.formattedValue} MB/s` + } + } + } + }, + scales: { + x: { + ticks: { + display: false, + }, + grid: { + display: true + }, + }, + y: { + ticks: { + callback: (val) => { + return `${Math.round((val + Number.EPSILON) * 1000) / 1000} MB/s` + } + }, + grid: { + display: true + }, + } + } + } +}) + +const hasPeerSpeedData = computed(() => { + return Object.keys(currentPeerSpeed.value).length > 0 +})