From 7cade8c3d217479f20f32fd4b6dc200a03e5dc2f Mon Sep 17 00:00:00 2001 From: Andrea Leardini Date: Tue, 19 Nov 2024 08:59:23 +0100 Subject: [PATCH] feat(monitoring): add latency & ping delivery rate charts (#436) --- src/components/charts/BasicBarChart.vue | 2 +- src/components/charts/BasicPieChart.vue | 15 +- src/components/charts/TimeLineChart.vue | 151 +++++++++++++++ .../monitoring/ConnectivityMonitor.vue | 91 ++++++++- src/composables/useLatencyAndQualityReport.ts | 174 ++++++++++++++++++ src/i18n/en/translation.json | 15 +- 6 files changed, 437 insertions(+), 11 deletions(-) create mode 100644 src/components/charts/TimeLineChart.vue create mode 100644 src/composables/useLatencyAndQualityReport.ts diff --git a/src/components/charts/BasicBarChart.vue b/src/components/charts/BasicBarChart.vue index 244db10b0..7bdb8869d 100644 --- a/src/components/charts/BasicBarChart.vue +++ b/src/components/charts/BasicBarChart.vue @@ -31,7 +31,7 @@ const props = withDefaults( showValuesOnBars?: boolean byteFormat?: boolean }>(), - { isHorizontal: false, showLegend: false, showValuesOnBars: true, byteFormat: false } + { height: '', isHorizontal: false, showLegend: false, showValuesOnBars: true, byteFormat: false } ) const options: any = { diff --git a/src/components/charts/BasicPieChart.vue b/src/components/charts/BasicPieChart.vue index 3894f9f77..005ed8ac2 100644 --- a/src/components/charts/BasicPieChart.vue +++ b/src/components/charts/BasicPieChart.vue @@ -14,12 +14,15 @@ import { byteFormat1024 } from '@nethesis/vue-components' const themeStore = useThemeStore() -const props = defineProps<{ - labels: string[] - datasets: any[] - height?: string - byteFormat?: boolean -}>() +const props = withDefaults( + defineProps<{ + labels: string[] + datasets: any[] + height?: string + byteFormat?: boolean + }>(), + { height: '', byteFormat: false } +) const options: any = { responsive: true, diff --git a/src/components/charts/TimeLineChart.vue b/src/components/charts/TimeLineChart.vue new file mode 100644 index 000000000..ba50140cd --- /dev/null +++ b/src/components/charts/TimeLineChart.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/components/standalone/monitoring/ConnectivityMonitor.vue b/src/components/standalone/monitoring/ConnectivityMonitor.vue index e4fe0b005..99a46048c 100644 --- a/src/components/standalone/monitoring/ConnectivityMonitor.vue +++ b/src/components/standalone/monitoring/ConnectivityMonitor.vue @@ -7,6 +7,7 @@ import { ubusCall } from '@/lib/standalone/ubus' import { getAxiosErrorMessage, + NeButton, NeCard, NeEmptyState, NeInlineNotification, @@ -15,13 +16,17 @@ import { import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import WanEventsCard from './connectivity/WanEventsCard.vue' -import InterfaceTrafficCard from './connectivity/InterfaceTrafficCard.vue' import { isEmpty } from 'lodash-es' import type { Policy } from '@/composables/useMwan' import WanConnectionsCard from './connectivity/WanConnectionsCard.vue' import { useNetworkDevices } from '@/composables/useNetworkDevices' import { getIpv4Addresses, getIpv6Addresses, getName, isDeviceUp } from '@/lib/standalone/network' import { useUciNetworkConfig } from '@/composables/useUciNetworkConfig' +import InterfaceTrafficCard from './connectivity/InterfaceTrafficCard.vue' +import { useLatencyAndQualityReport } from '@/composables/useLatencyAndQualityReport' +import TimeLineChart from '@/components/charts/TimeLineChart.vue' +import { useRouter } from 'vue-router' +import { getStandaloneRoutePrefix } from '@/lib/router' export type Wan = { iface: string @@ -46,11 +51,19 @@ const { errorNetworkConfig, errorNetworkConfigDetails } = useUciNetworkConfig() +const router = useRouter() const wans = ref([]) const mwanEvents = ref>({}) const mwanPolicies = ref([]) +const { + latencyAndQualityCharts, + loadingLatencyAndQualityReport, + errorLatencyAndQualityReport, + errorLatencyAndQualityReportDetails +} = useLatencyAndQualityReport() + let loading = ref({ listWans: false, getMwanReport: false, @@ -269,6 +282,19 @@ async function getMwanPolicies() { {{ errorNetworkConfigDetails }} + + + +
diff --git a/src/composables/useLatencyAndQualityReport.ts b/src/composables/useLatencyAndQualityReport.ts new file mode 100644 index 000000000..5fa613f85 --- /dev/null +++ b/src/composables/useLatencyAndQualityReport.ts @@ -0,0 +1,174 @@ +// Copyright (C) 2024 Nethesis S.r.l. +// SPDX-License-Identifier: GPL-3.0-or-later + +import { onMounted, onUnmounted, ref } from 'vue' +import { ubusCall } from '@/lib/standalone/ubus' +import { useI18n } from 'vue-i18n' +import { getAxiosErrorMessage } from '@nethesis/vue-components' +import { useThemeStore } from '@/stores/theme' +import { AMBER_500, AMBER_600, CYAN_500, CYAN_600, EMERALD_500, EMERALD_600 } from '@/lib/color' + +export type LatencyOrQualityChart = { + pingHost: string + type: 'latency' | 'quality' + labels: number[] // timestamps + datasets: LatencyOrQualityDataset[] +} + +type LatencyAndQualityData = { + latency: ChartLabelsAndData + quality: ChartLabelsAndData +} + +type ChartLabelsAndData = { + labels: string[] + data: number[][] +} + +interface LatencyOrQualityDataset { + label: string + borderColor: string + backgroundColor: string + data: number[] + borderWidth: number + radius: number +} + +export function useLatencyAndQualityReport() { + // random refresh interval between 20 and 30 seconds + const REFRESH_INTERVAL = 20000 + Math.random() * 10 * 1000 + const { t } = useI18n() + const themeStore = useThemeStore() + const intervalId = ref(0) + const latencyAndQualityCharts = ref([]) + const loadingLatencyAndQualityReport = ref(true) + const errorLatencyAndQualityReport = ref('') + const errorLatencyAndQualityReportDetails = ref('') + + onMounted(() => { + fetchLatencyAndQualityReport() + + // periodically reload data + intervalId.value = setInterval(fetchLatencyAndQualityReport, REFRESH_INTERVAL) + }) + + onUnmounted(() => { + if (intervalId.value) { + clearInterval(intervalId.value) + } + }) + + function buildQualityChart(latencyAndQualityData: LatencyAndQualityData, pingHost: string) { + // convert timestamp to milliseconds + const chartLabels = latencyAndQualityData.quality.data.map((d: number[]) => d[0] * 1000) + const qualityData = latencyAndQualityData.quality.data.map((d: number[]) => d[1]) + + const chartDatasets = [ + { + label: t('standalone.real_time_monitor.packet_delivery_rate'), + borderColor: themeStore.isLight ? CYAN_600 : CYAN_500, + backgroundColor: themeStore.isLight ? CYAN_600 : CYAN_500, + data: qualityData, + borderWidth: 1, + radius: 0 + } + ] + + const qualityChart = { + pingHost, + type: 'quality', + labels: chartLabels, + datasets: chartDatasets + } as LatencyOrQualityChart + return qualityChart + } + + function buildLatencyChart(latencyAndQualityData: LatencyAndQualityData, pingHost: string) { + // convert timestamp to milliseconds + const chartLabels = latencyAndQualityData.latency.data.map((d: number[]) => d[0] * 1000) + // show latency in milliseconds with one decimal + const minLatencyData = latencyAndQualityData.latency.data.map((d: number[]) => + d[1] ? parseFloat(d[1].toFixed(1)) : null + ) + const maxLatencyData = latencyAndQualityData.latency.data.map((d: number[]) => + d[2] ? parseFloat(d[2].toFixed(1)) : null + ) + const avgLatencyData = latencyAndQualityData.latency.data.map((d: number[]) => + d[3] ? parseFloat(d[3].toFixed(1)) : null + ) + + const chartDatasets = [ + { + label: t('standalone.real_time_monitor.min_latency'), + borderColor: themeStore.isLight ? EMERALD_600 : EMERALD_500, + backgroundColor: themeStore.isLight ? EMERALD_600 : EMERALD_500, + data: minLatencyData, + borderWidth: 1, + radius: 0 + }, + { + label: t('standalone.real_time_monitor.max_latency'), + borderColor: themeStore.isLight ? AMBER_600 : AMBER_500, + backgroundColor: themeStore.isLight ? AMBER_600 : AMBER_500, + data: maxLatencyData, + borderWidth: 1, + radius: 0 + }, + { + label: t('standalone.real_time_monitor.avg_latency'), + borderColor: themeStore.isLight ? CYAN_600 : CYAN_500, + backgroundColor: themeStore.isLight ? CYAN_600 : CYAN_500, + data: avgLatencyData, + borderWidth: 1, + radius: 0 + } + ] + + const latencyChart = { + pingHost, + type: 'latency', + labels: chartLabels, + datasets: chartDatasets + } as LatencyOrQualityChart + return latencyChart + } + + async function fetchLatencyAndQualityReport() { + errorLatencyAndQualityReport.value = '' + errorLatencyAndQualityReportDetails.value = '' + + try { + const res = await ubusCall('ns.report', 'latency-and-quality-report') + latencyAndQualityCharts.value = [] + + for (const [pingHost, latencyAndQualityData] of Object.entries(res.data) as [ + string, + LatencyAndQualityData + ][]) { + if (latencyAndQualityData.latency.data.length > 0) { + const latencyChart = buildLatencyChart(latencyAndQualityData, pingHost) + latencyAndQualityCharts.value.push(latencyChart) + } + + if (latencyAndQualityData.quality.data.length > 0) { + const qualityChart = buildQualityChart(latencyAndQualityData, pingHost) + latencyAndQualityCharts.value.push(qualityChart) + } + } + } catch (err: any) { + console.error(err) + errorLatencyAndQualityReport.value = t(getAxiosErrorMessage(err)) + errorLatencyAndQualityReportDetails.value = err.toString() + } finally { + loadingLatencyAndQualityReport.value = false + } + } + + return { + latencyAndQualityCharts, + fetchLatencyAndQualityReport, + loadingLatencyAndQualityReport, + errorLatencyAndQualityReport, + errorLatencyAndQualityReportDetails + } +} diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 2845535e3..75b0264a0 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -385,7 +385,8 @@ "cannot_retrieve_client_traffic_by_hour": "Cannot retrieve client traffic by hour", "cannot_retrieve_talkers_list": "Cannot retrieve talkers list", "invalid_number": "Invalid number", - "network_conflict": "This name is already in use by another network interface" + "network_conflict": "This name is already in use by another network interface", + "cannot_retrieve_latency_and_quality_report": "Cannot retrieve latency and quality report" }, "ne_text_input": { "show_password": "Show password", @@ -1953,11 +1954,19 @@ "no_events_message": "No events, all good", "view_all_on_grafana": "View all on Grafana", "no_vpn_network_configured": "No VPN network configured", - "blocklist": "Blocklist" + "blocklist": "Blocklist", + "min_latency": "Min latency", + "max_latency": "Max latency", + "avg_latency": "Avg latency", + "packet_delivery_rate": "Packet delivery rate", + "ping_host_latency": "Latency to {pingHost}", + "ping_host_packet_delivery_rate": "Packet delivery rate to {pingHost}", + "latency_and_packet_delivery_rate": "Latency and packet delivery rate", + "no_hosts_configured_for_monitoring": "No hosts configured for monitoring" }, "ping_latency_monitor": { "title": "Ping latency monitor", - "description": "Set up the monitoring tool to assess round-trip time and packet loss by sending ping messages to network hosts. This tool is used for monitoring network connectivity quality. You can add one or more hosts to monitor. It's also possible to add IP addresses in a VPN to assess tunnel quality.", + "description": "Measure round-trip time and packet delivery rates by pinging network hosts. Add one or more hosts, including VPN IPs to monitor tunnel quality. Latency and packet delivery charts are available under Monitoring > Real Time Monitor > Connectivity.", "add_host": "Add host", "host_to_monitor": "Hosts to monitor" },