From 0c1af09a9c0838bc068d15f292b39e7d9f62c584 Mon Sep 17 00:00:00 2001 From: Yash Chauhan Date: Sun, 21 Jun 2026 23:29:42 +0530 Subject: [PATCH 1/2] [feature] Support dynamic mobile signal metrics for multiple interfaces --- .dockerignore | 8 +++ docker-compose.yml | 2 + docs/user/metrics.rst | 62 +++++++++++++----- openwisp_monitoring/device/tests/test_api.py | 65 +++++++++++++++++++ openwisp_monitoring/device/writer.py | 24 +++++-- .../monitoring/configuration.py | 6 +- .../monitoring/tests/test_models.py | 5 +- 7 files changed, 146 insertions(+), 26 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6ae394ae2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +venv/ +.git/ +.github/ +.tox/ +htmlcov/ +build/ +dist/ +*.egg-info/ diff --git a/docker-compose.yml b/docker-compose.yml index 785592d6c..86d6d17e4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: dockerfile: Dockerfile ports: - "8000:8000" + volumes: + - .:/opt/openwisp depends_on: - influxdb - redis diff --git a/docs/user/metrics.rst b/docs/user/metrics.rst index 00bb55764..a51e2d0a9 100644 --- a/docs/user/metrics.rst +++ b/docs/user/metrics.rst @@ -207,15 +207,25 @@ Mobile Signal Strength :target: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/docs/docs/signal-strength.png :align: center -================== ===================================== -**collection**: :doc:`OpenWrt Monitoring Agent - ` -**measurement**: ``signal_strength`` +================== ========================================================================== +**collection**: :doc:`OpenWrt Monitoring Agent ` +**measurement**: ``signal`` **type**: ``float`` **fields**: ``signal_strength``, ``signal_power`` +**tags**: .. code-block:: python + + { + "organization_id": "", + "ifname": "", + # optional + "location_id": "", + "floorplan_id": "", + } **configuration**: ``signal_strength`` **charts**: ``signal_strength`` -================== ===================================== +================== ========================================================================== + +If a device has multiple mobile interfaces, a separate chart will be created for each interface, with its name appended to the chart title (e.g. ``Signal Strength (RSSI): mobile0``). .. _mobile_signal_quality: @@ -226,15 +236,25 @@ Mobile Signal Quality :target: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/docs/docs/signal-quality.png :align: center -================== ====================================== -**collection**: :doc:`OpenWrt Monitoring Agent - ` -**measurement**: ``signal_quality`` +================== ========================================================================== +**collection**: :doc:`OpenWrt Monitoring Agent ` +**measurement**: ``signal`` **type**: ``float`` -**fields**: ``signal_quality``, ``signal_quality`` +**fields**: ``signal_quality``, ``snr`` +**tags**: .. code-block:: python + + { + "organization_id": "", + "ifname": "", + # optional + "location_id": "", + "floorplan_id": "", + } **configuration**: ``signal_quality`` **charts**: ``signal_quality`` -================== ====================================== +================== ========================================================================== + +If a device has multiple mobile interfaces, a separate chart will be created for each interface, with its name appended to the chart title (e.g. ``Signal Quality (RSRQ): mobile0``). .. _mobile_access_technology_in_use: @@ -245,15 +265,25 @@ Mobile Access Technology in Use :target: https://raw.githubusercontent.com/openwisp/openwisp-monitoring/docs/docs/access-technology.png :align: center -================== ================================== -**collection**: :doc:`OpenWrt Monitoring Agent - ` -**measurement**: ``access_tech`` +================== ========================================================================== +**collection**: :doc:`OpenWrt Monitoring Agent ` +**measurement**: ``signal`` **type**: ``int`` **fields**: ``access_tech`` +**tags**: .. code-block:: python + + { + "organization_id": "", + "ifname": "", + # optional + "location_id": "", + "floorplan_id": "", + } **configuration**: ``access_tech`` **charts**: ``access_tech`` -================== ================================== +================== ========================================================================== + +If a device has multiple mobile interfaces, a separate chart will be created for each interface, with its name appended to the chart title (e.g. ``Access Technology: mobile0``). .. _iperf3: diff --git a/openwisp_monitoring/device/tests/test_api.py b/openwisp_monitoring/device/tests/test_api.py index 93616580c..454b912cd 100644 --- a/openwisp_monitoring/device/tests/test_api.py +++ b/openwisp_monitoring/device/tests/test_api.py @@ -1008,6 +1008,71 @@ def test_mobile_charts(self): ) self.assertEqual(self.chart_queryset.count(), charts_count + 3) + @tag("flaky_with_udp_writes") + def test_multiple_mobile_charts(self): + org = self._create_org() + device = self._create_device(organization=org) + charts_count = self.chart_queryset.count() + data = { + "type": "DeviceMonitoring", + "interfaces": [ + { + "name": "mobile0", + "mac": "00:00:00:00:00:00", + "mtu": 1900, + "multicast": True, + "txqueuelen": 1000, + "type": "modem-manager", + "up": True, + "mobile": { + "connection_status": "connected", + "imei": "300000001234567", + "manufacturer": "Sierra Wireless, Incorporated", + "model": "MC7430", + "operator_code": "50502", + "operator_name": "YES OPTUS", + "power_status": "on", + "signal": { + "lte": {"rsrp": -75, "rsrq": -8, "rssi": -51, "snr": 13}, + }, + }, + }, + { + "name": "mobile1", + "mac": "00:00:00:00:00:01", + "mtu": 1900, + "multicast": True, + "txqueuelen": 1000, + "type": "modem-manager", + "up": True, + "mobile": { + "connection_status": "connected", + "imei": "300000001234568", + "manufacturer": "Sierra Wireless, Incorporated", + "model": "MC7430", + "operator_code": "50502", + "operator_name": "YES OPTUS", + "power_status": "on", + "signal": { + "lte": {"rsrp": -80, "rsrq": -10, "rssi": -55, "snr": 11}, + }, + }, + }, + ], + } + self._post_data(device.id, device.key, data) + response = self.client.get(self._url(device.pk.hex, device.key)) + self.assertEqual(response.status_code, 200) + charts = response.data["charts"] + self.assertEqual(self.chart_queryset.count(), charts_count + 6) + chart_titles = [chart["title"] for chart in charts] + self.assertIn("Signal Strength (RSSI): mobile0", chart_titles) + self.assertIn("Signal Strength (RSSI): mobile1", chart_titles) + self.assertIn("Signal Quality (RSRQ): mobile0", chart_titles) + self.assertIn("Signal Quality (RSRQ): mobile1", chart_titles) + self.assertIn("Access Technology: mobile0", chart_titles) + self.assertIn("Access Technology: mobile1", chart_titles) + # This test reads chart summaries immediately after posting data. That is # unreliable with UDP writes. @tag("flaky_with_udp_writes") diff --git a/openwisp_monitoring/device/writer.py b/openwisp_monitoring/device/writer.py index 50744e622..6be5ea838 100644 --- a/openwisp_monitoring/device/writer.py +++ b/openwisp_monitoring/device/writer.py @@ -77,7 +77,13 @@ def write(self, data, time=None, current=False): ifname = interface["name"] if "mobile" in interface: self._write_mobile_signal( - interface, ifname, ct, self.device_data.pk, current, time=time + interface, + ifname, + ct, + self.device_data.pk, + current, + time=time, + extra_tags=device_extra_tags, ) ifstats = interface.get("statistics", {}) # Explicitly stated None to avoid skipping in case the stats are zero @@ -192,7 +198,9 @@ def _get_mobile_signal_type(self, signal): if tech in signal: return tech - def _write_mobile_signal(self, interface, ifname, ct, pk, current=False, time=None): + def _write_mobile_signal( + self, interface, ifname, ct, pk, current=False, time=None, extra_tags=None + ): access_type = self._get_mobile_signal_type(interface["mobile"].get("signal")) if not access_type: return @@ -210,13 +218,15 @@ def _write_mobile_signal(self, interface, ifname, ct, pk, current=False, time=No if signal_strength is not None: signal_strength = float(signal_strength) if signal_strength is not None or signal_power is not None: + name = f"{ifname} signal strength" metric, created = Metric._get_or_create( object_id=self.device_data.pk, content_type_id=ct.id, configuration="signal_strength", - name="signal strength", + name=name, key="signal", main_tags={"ifname": Metric._makekey(ifname)}, + extra_tags=extra_tags, ) self._append_metric_data( metric, signal_strength, current, time=time, extra_values=extra_values @@ -239,13 +249,15 @@ def _write_mobile_signal(self, interface, ifname, ct, pk, current=False, time=No if signal_quality is not None: signal_quality = float(signal_quality) if snr is not None or signal_quality is not None: + name = f"{ifname} signal quality" metric, created = Metric._get_or_create( object_id=self.device_data.pk, content_type_id=ct.id, configuration="signal_quality", - name="signal quality", + name=name, key="signal", main_tags={"ifname": Metric._makekey(ifname)}, + extra_tags=extra_tags, ) self._append_metric_data( metric, signal_quality, current, time=time, extra_values=extra_values @@ -253,13 +265,15 @@ def _write_mobile_signal(self, interface, ifname, ct, pk, current=False, time=No if created: self._create_signal_quality_chart(metric) # create access technology chart + name = f"{ifname} access technology" metric, created = Metric._get_or_create( object_id=self.device_data.pk, content_type_id=ct.id, configuration="access_tech", - name="access technology", + name=name, key="signal", main_tags={"ifname": Metric._makekey(ifname)}, + extra_tags=extra_tags, ) self._append_metric_data( metric, diff --git a/openwisp_monitoring/monitoring/configuration.py b/openwisp_monitoring/monitoring/configuration.py index 5a76dd37c..d7ce2b2da 100644 --- a/openwisp_monitoring/monitoring/configuration.py +++ b/openwisp_monitoring/monitoring/configuration.py @@ -572,7 +572,7 @@ def _get_access_tech(): "type": "scatter", "fill": "none", "yaxis": {"zeroline": False}, - "title": _("Signal Strength (RSSI)"), + "title": _("Signal Strength (RSSI): {ifname}"), "colors": (DEFAULT_COLORS[3], DEFAULT_COLORS[0]), "description": _( "Signal Strength (RSSI) and Signal Power (RSRP), measured in dBm." @@ -598,7 +598,7 @@ def _get_access_tech(): "type": "scatter", "fill": "none", "yaxis": {"zeroline": False}, - "title": _("Signal Quality (RSRQ)"), + "title": _("Signal Quality (RSRQ): {ifname}"), "colors": (DEFAULT_COLORS[3], DEFAULT_COLORS[0]), "description": _( _( @@ -623,7 +623,7 @@ def _get_access_tech(): "charts": { "access_tech": { "type": "bar", - "title": _("Access Technology"), + "title": _("Access Technology: {ifname}"), "description": _( _( "Shows the access technology (LTE, UTMS, CDMA1x, etc.) " diff --git a/openwisp_monitoring/monitoring/tests/test_models.py b/openwisp_monitoring/monitoring/tests/test_models.py index e1a72c8c5..c715426b4 100644 --- a/openwisp_monitoring/monitoring/tests/test_models.py +++ b/openwisp_monitoring/monitoring/tests/test_models.py @@ -337,14 +337,15 @@ def test_metric_pre_write_signals_emitted(self): def test_metric_post_write_signals_emitted(self): om = self._create_object_metric() + now = timezone.now() with catch_signal(post_metric_write) as handler: - om.write(3, current=True, time=start_time) + om.write(3, current=True, time=now) handler.assert_called_once_with( sender=Metric, metric=om, values={om.field_name: 3}, signal=post_metric_write, - time=start_time.isoformat(), + time=now.isoformat(), current=True, ) From b09e2f6ecc63b430a67f40dd971e787b0b1616a3 Mon Sep 17 00:00:00 2001 From: Yash Chauhan Date: Tue, 23 Jun 2026 21:50:28 +0530 Subject: [PATCH 2/2] [fix] Add ifname filter to signal metric queries and data migration for metric names #820 --- docker-compose.yml | 2 - .../db/backends/influxdb/queries.py | 6 +- .../0014_update_signal_metric_names.py | 82 +++++++++++++++++++ .../0006_update_signal_metric_names.py | 72 ++++++++++++++++ 4 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 openwisp_monitoring/monitoring/migrations/0014_update_signal_metric_names.py create mode 100644 tests/openwisp2/sample_monitoring/migrations/0006_update_signal_metric_names.py diff --git a/docker-compose.yml b/docker-compose.yml index 86d6d17e4..785592d6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,6 @@ services: dockerfile: Dockerfile ports: - "8000:8000" - volumes: - - .:/opt/openwisp depends_on: - influxdb - redis diff --git a/openwisp_monitoring/db/backends/influxdb/queries.py b/openwisp_monitoring/db/backends/influxdb/queries.py index 1a5885c4d..7f53ea40b 100644 --- a/openwisp_monitoring/db/backends/influxdb/queries.py +++ b/openwisp_monitoring/db/backends/influxdb/queries.py @@ -80,7 +80,7 @@ "SELECT ROUND(MEAN(signal_strength)) AS signal_strength, " "ROUND(MEAN(signal_power)) AS signal_power FROM {key} WHERE " "time >= '{time}' {end_date} AND content_type = '{content_type}' AND " - "object_id = '{object_id}' GROUP BY time(1d)" + "object_id = '{object_id}' AND ifname = '{ifname}' GROUP BY time(1d)" ) }, "signal_quality": { @@ -88,14 +88,14 @@ "SELECT ROUND(MEAN(signal_quality)) AS signal_quality, " "ROUND(MEAN(snr)) AS signal_to_noise_ratio FROM {key} WHERE " "time >= '{time}' {end_date} AND content_type = '{content_type}' AND " - "object_id = '{object_id}' GROUP BY time(1d)" + "object_id = '{object_id}' AND ifname = '{ifname}' GROUP BY time(1d)" ) }, "access_tech": { "influxdb": ( "SELECT MODE(access_tech) AS access_tech FROM {key} WHERE " "time >= '{time}' {end_date} AND content_type = '{content_type}' AND " - "object_id = '{object_id}' GROUP BY time(1d)" + "object_id = '{object_id}' AND ifname = '{ifname}' GROUP BY time(1d)" ) }, "bandwidth": { diff --git a/openwisp_monitoring/monitoring/migrations/0014_update_signal_metric_names.py b/openwisp_monitoring/monitoring/migrations/0014_update_signal_metric_names.py new file mode 100644 index 000000000..c05bcb854 --- /dev/null +++ b/openwisp_monitoring/monitoring/migrations/0014_update_signal_metric_names.py @@ -0,0 +1,82 @@ +# Manually created +# Updates signal metric names to include the interface name (ifname) prefix, +# matching the new naming convention introduced by writer.py which now uses +# "{ifname} signal strength", "{ifname} signal quality", "{ifname} access technology" +# instead of the old static names "signal strength", "signal quality", "access technology". + +import logging + +from django.db import migrations + +CHUNK_SIZE = 1000 + +logger = logging.getLogger(__name__) + +SIGNAL_CONFIGURATIONS = { + "signal_strength": "signal strength", + "signal_quality": "signal quality", + "access_tech": "access technology", +} + + +def forward_migration(apps, schema_editor): + """ + Update old signal metric names to include the interface name. + e.g. "signal strength" → "mobile0 signal strength" + """ + Metric = apps.get_model("monitoring", "Metric") + updated_metrics = [] + for configuration, old_suffix in SIGNAL_CONFIGURATIONS.items(): + metric_qs = Metric.objects.filter( + configuration=configuration, + name=old_suffix, + ) + for metric in metric_qs.iterator(chunk_size=CHUNK_SIZE): + ifname = metric.main_tags.get("ifname", "") + if ifname: + metric.name = f"{ifname} {old_suffix}" + updated_metrics.append(metric) + if len(updated_metrics) >= CHUNK_SIZE: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + logger.info( + f"Bulk updated {len(updated_metrics)} signal metric names." + ) + updated_metrics = [] + if updated_metrics: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + logger.info(f"Bulk updated {len(updated_metrics)} signal metric names.") + logger.info("Signal metric name migration (forward) completed.") + + +def reverse_migration(apps, schema_editor): + """ + Revert signal metric names back to the old static names. + e.g. "mobile0 signal strength" → "signal strength" + """ + Metric = apps.get_model("monitoring", "Metric") + updated_metrics = [] + for configuration, old_suffix in SIGNAL_CONFIGURATIONS.items(): + metric_qs = Metric.objects.filter(configuration=configuration) + for metric in metric_qs.iterator(chunk_size=CHUNK_SIZE): + ifname = metric.main_tags.get("ifname", "") + expected_new_name = f"{ifname} {old_suffix}" if ifname else old_suffix + if metric.name == expected_new_name: + metric.name = old_suffix + updated_metrics.append(metric) + if len(updated_metrics) >= CHUNK_SIZE: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + updated_metrics = [] + if updated_metrics: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + logger.info("Signal metric name migration (reverse) completed.") + + +class Migration(migrations.Migration): + dependencies = [("monitoring", "0013_replace_jsonfield_with_django_builtin")] + + operations = [ + migrations.RunPython( + forward_migration, + reverse_code=reverse_migration, + ) + ] diff --git a/tests/openwisp2/sample_monitoring/migrations/0006_update_signal_metric_names.py b/tests/openwisp2/sample_monitoring/migrations/0006_update_signal_metric_names.py new file mode 100644 index 000000000..eb62ac930 --- /dev/null +++ b/tests/openwisp2/sample_monitoring/migrations/0006_update_signal_metric_names.py @@ -0,0 +1,72 @@ +# Manually created +# Updates signal metric names to include the interface name (ifname) prefix +# for the sample_monitoring app (mirrors 0014 in the main monitoring app). + +import logging + +from django.db import migrations + +CHUNK_SIZE = 1000 + +logger = logging.getLogger(__name__) + +SIGNAL_CONFIGURATIONS = { + "signal_strength": "signal strength", + "signal_quality": "signal quality", + "access_tech": "access technology", +} + + +def forward_migration(apps, schema_editor): + Metric = apps.get_model("sample_monitoring", "Metric") + updated_metrics = [] + for configuration, old_suffix in SIGNAL_CONFIGURATIONS.items(): + metric_qs = Metric.objects.filter( + configuration=configuration, + name=old_suffix, + ) + for metric in metric_qs.iterator(chunk_size=CHUNK_SIZE): + ifname = metric.main_tags.get("ifname", "") + if ifname: + metric.name = f"{ifname} {old_suffix}" + updated_metrics.append(metric) + if len(updated_metrics) >= CHUNK_SIZE: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + updated_metrics = [] + if updated_metrics: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + logger.info( + "Signal metric name migration (forward) completed for sample_monitoring." + ) + + +def reverse_migration(apps, schema_editor): + Metric = apps.get_model("sample_monitoring", "Metric") + updated_metrics = [] + for configuration, old_suffix in SIGNAL_CONFIGURATIONS.items(): + metric_qs = Metric.objects.filter(configuration=configuration) + for metric in metric_qs.iterator(chunk_size=CHUNK_SIZE): + ifname = metric.main_tags.get("ifname", "") + expected_new_name = f"{ifname} {old_suffix}" if ifname else old_suffix + if metric.name == expected_new_name: + metric.name = old_suffix + updated_metrics.append(metric) + if len(updated_metrics) >= CHUNK_SIZE: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + updated_metrics = [] + if updated_metrics: + Metric.objects.bulk_update(updated_metrics, fields=["name"]) + logger.info( + "Signal metric name migration (reverse) completed for sample_monitoring." + ) + + +class Migration(migrations.Migration): + dependencies = [("sample_monitoring", "0005_replace_jsonfield_with_django_builtin")] + + operations = [ + migrations.RunPython( + forward_migration, + reverse_code=reverse_migration, + ) + ]