From f41f5fa1aa2fa45e1c60e9c0f0176340430d71f0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 9 May 2026 16:56:19 -0500 Subject: [PATCH] feat(insights): AP frequency-change recommendation rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a wireless AP has a strong same-channel neighbor (RSSI >= -75 dBm) and a measurably cleaner alternative channel exists in the same band, the system now emits an ap_frequency_change insight with a specific recommended channel and the top interfering BSSIDs as evidence. - New schema wireless_neighbor_scans (append-only) storing observed neighboring APs per device: bssid, ssid, channel, frequency_mhz, channel_width_mhz, rssi_dbm, is_own_fleet, seen_at - New columns on snmp_devices: current_channel, current_frequency_mhz, current_channel_width_mhz, last_radio_seen_at — populated by vendor SNMP profiles for APs that expose wireless OIDs - Towerops.Recommendations.Rules.FrequencyChange — pure rule that scores each candidate channel using sum-of-linear-power over the last 24h of neighbor scans, recommends the cleanest if it beats the current by at least 6 dB. Critical urgency when current score >= 100. - Towerops.Workers.RecommendationsRunWorker — hourly Oban cron, iterates organizations and runs all rules, uses insert_insight_if_new/1 for idempotent upsert - LiveView /insights renders a purple frequency-change card with current vs recommended channel, dB-cleaner badge, and a top-offender list (BSSID, SSID, RSSI, own-fleet flag) - LLM enrichment automatically applies to the new insight type Also fixes two pre-existing flaky tests that surfaced in CI: - test/towerops_web/live/agent_live_test.exs:567 — now sets an explicit Req.Test transport_error stub for ReleaseChecker and uses Req.Test.allow so the LiveView process inherits it. Previously relied on absence of a stub, which leaked across tests and produced different behavior under full-suite parallel runs. - test/towerops_web/telemetry_test.exs:140 — relaxes the "no log" check to refute the specific messages the function under test would emit, rather than asserting an empty capture_log/1 buffer (which catches stray logs from concurrent tests, e.g. Postgrex sandbox disconnects). --- CHANGELOG.txt | 28 +++ config/runtime.exs | 2 + lib/towerops/preseem/insight.ex | 2 +- .../recommendations/rules/frequency_change.ex | 225 ++++++++++++++++++ lib/towerops/snmp/device.ex | 17 +- lib/towerops/snmp/wireless_neighbor_scan.ex | 59 +++++ .../workers/recommendations_run_worker.ex | 51 ++++ .../live/insights_live/index.html.heex | 63 +++++ ...9214247_create_wireless_neighbor_scans.exs | 31 +++ ...14358_add_radio_fields_to_snmp_devices.exs | 14 ++ priv/static/changelog.txt | 3 +- .../rules/frequency_change_test.exs | 183 ++++++++++++++ .../snmp/device_radio_fields_test.exs | 58 +++++ .../snmp/wireless_neighbor_scan_test.exs | 117 +++++++++ .../recommendations_run_worker_test.exs | 84 +++++++ test/towerops_web/live/agent_live_test.exs | 13 + .../live/insights_live_events_test.exs | 51 ++++ test/towerops_web/telemetry_test.exs | 8 +- 18 files changed, 1005 insertions(+), 4 deletions(-) create mode 100644 lib/towerops/recommendations/rules/frequency_change.ex create mode 100644 lib/towerops/snmp/wireless_neighbor_scan.ex create mode 100644 lib/towerops/workers/recommendations_run_worker.ex create mode 100644 priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs create mode 100644 priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs create mode 100644 test/towerops/recommendations/rules/frequency_change_test.exs create mode 100644 test/towerops/snmp/device_radio_fields_test.exs create mode 100644 test/towerops/snmp/wireless_neighbor_scan_test.exs create mode 100644 test/towerops/workers/recommendations_run_worker_test.exs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 22d648d7..2a836704 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,31 @@ +2026-05-09 +feat(insights): AP frequency-change recommendation rule + Schema: new wireless_neighbor_scans table (migration 20260509214247) + storing observed neighboring APs per device with bssid, ssid, channel, + frequency_mhz, channel_width_mhz, rssi_dbm, is_own_fleet, seen_at. + Indexed on (device_id, seen_at), (device_id, channel), bssid, + (organization_id, seen_at). + Schema: snmp_devices gains current_channel, current_frequency_mhz, + current_channel_width_mhz, last_radio_seen_at (migration + 20260509214358) for AP-level radio state. + New rule Towerops.Recommendations.Rules.FrequencyChange — pure + function evaluate(org_id) that emits ap_frequency_change insights + when an AP's current channel has a strong same-channel neighbor + (RSSI ≥ -75 dBm) AND a candidate channel is at least 6 dB cleaner. + Score = Σ 10^((rssi+95)/10) over scans in last 24h. Critical when + current score ≥ 100. Picks recommended channel from per-band + candidate lists (5GHz UNII or 2.4GHz 1/6/11). Top offenders sorted + by RSSI desc, capped at 5. + New cron Towerops.Workers.RecommendationsRunWorker (hourly) iterates + organizations and runs all rules; uses insert_insight_if_new/1 so + re-runs are idempotent on (device_id, type, status="active"). + UI: insights LiveView (/insights) renders a purple ap_frequency_change + card with current vs recommended channel, dB-cleaner badge, and the + top-offender list (BSSID, SSID, RSSI, own-fleet badge). + Insight.@valid_types extended with ap_frequency_change. + LLM enrichment automatically applies to the new type via the existing + Phase 1 worker — no extra wiring. + 2026-05-09 feat(insights): LLM-powered insight enrichment Schema: added llm_summary, recommended_action, llm_model, llm_enriched_at diff --git a/config/runtime.exs b/config/runtime.exs index 71545722..51f32212 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -260,6 +260,8 @@ if config_env() == :prod do {"*/5 * * * *", Towerops.Workers.WirelessInsightWorker}, # Enrich active insights with LLM-generated summaries every 5 minutes {"*/5 * * * *", Towerops.Workers.InsightLlmEnrichmentWorker}, + # Run multi-source recommendation rules hourly + {"0 * * * *", Towerops.Workers.RecommendationsRunWorker}, # Gaiia reconciliation insights nightly at 4:30 AM {"30 4 * * *", Towerops.Workers.GaiiaInsightWorker}, # Backhaul capacity utilization insights every 15 minutes diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index bdf04d98..3c860b27 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap) + @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change) @valid_urgencies ~w(critical warning info) @valid_statuses ~w(active dismissed resolved) @valid_channels ~w(proactive contextual passive) diff --git a/lib/towerops/recommendations/rules/frequency_change.ex b/lib/towerops/recommendations/rules/frequency_change.ex new file mode 100644 index 00000000..a80bc407 --- /dev/null +++ b/lib/towerops/recommendations/rules/frequency_change.ex @@ -0,0 +1,225 @@ +defmodule Towerops.Recommendations.Rules.FrequencyChange do + @moduledoc """ + Recommendation rule that detects access points whose current channel + has strong same-channel interference and a measurably better + alternative within the same band. + + Inputs: + - `snmp_devices` rows with a non-nil `current_channel` + - `wireless_neighbor_scans` rows from the last 24h + + Output: + - List of `%{}` attribute maps suitable for + `Towerops.Preseem.Insights.insert_insight_if_new/1`. Each insight has + type `"ap_frequency_change"` and metadata containing the current + channel, recommended channel, interference scores, and the top + offending neighbors observed on the current channel. + + Scoring: + score(channel) = Σ 10^((rssi + 95) / 10) + over neighbor scans observed on `channel` in the last 24h. Higher + score = more interference. We recommend the candidate channel with + the lowest score, provided the improvement is at least 6 dB. + + This rule is deterministic and side-effect free — the worker is + responsible for upserting the resulting insights. + """ + + import Ecto.Query + + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.WirelessNeighborScan + + @lookback_seconds 24 * 3600 + + # Channel candidate lists per band. Conservative defaults — operators + # can refine per-deployment later. + @candidates_5ghz [36, 40, 44, 48, 149, 153, 157, 161, 165] + @candidates_24ghz [1, 6, 11] + + # An interference contributor is "strong" enough to consider for an + # offender list when its RSSI is at or above this threshold. + @strong_offender_threshold_dbm -75 + + # Don't recommend a switch unless the alternative is at least this much + # quieter than the current channel (in dB-equivalent score units). + @min_improvement_db 6.0 + + # Score above this puts the recommendation at "critical" urgency. + @critical_score 100.0 + + @max_offenders 5 + + @type insight_attrs :: %{ + required(:organization_id) => Ecto.UUID.t(), + required(:device_id) => Ecto.UUID.t(), + required(:type) => String.t(), + required(:urgency) => String.t(), + required(:channel) => String.t(), + required(:source) => String.t(), + required(:title) => String.t(), + required(:metadata) => map() + } + + @doc """ + Evaluate the rule for an organization and return a list of insight + attribute maps. Empty list when nothing should be recommended. + """ + @spec evaluate(Ecto.UUID.t()) :: [insight_attrs()] + def evaluate(organization_id) when is_binary(organization_id) do + cutoff = + DateTime.utc_now() + |> DateTime.add(-@lookback_seconds, :second) + |> DateTime.truncate(:second) + + organization_id + |> load_aps() + |> Enum.map(&evaluate_ap(&1, organization_id, cutoff)) + |> Enum.reject(&is_nil/1) + end + + defp load_aps(organization_id) do + Repo.all( + from(s in SnmpDevice, + join: d in assoc(s, :device), + where: d.organization_id == ^organization_id and not is_nil(s.current_channel), + select: {d, s} + ) + ) + end + + defp evaluate_ap({device, snmp}, organization_id, cutoff) do + scans = neighbor_scans_for(device.id, cutoff) + + if has_strong_offender?(scans, snmp.current_channel) do + build_insight(device, snmp, scans, organization_id) + end + end + + defp neighbor_scans_for(device_id, cutoff) do + Repo.all( + from(s in WirelessNeighborScan, + where: s.device_id == ^device_id and s.seen_at >= ^cutoff, + order_by: [desc: s.rssi_dbm] + ) + ) + end + + defp has_strong_offender?(scans, current_channel) do + Enum.any?(scans, fn s -> + s.channel == current_channel and not is_nil(s.rssi_dbm) and + s.rssi_dbm >= @strong_offender_threshold_dbm + end) + end + + defp build_insight(device, snmp, scans, organization_id) do + candidates = candidates_for(snmp.current_frequency_mhz, snmp.current_channel) + scored = score_channels(scans, candidates) + + current_score = Map.get(scored, snmp.current_channel, 0.0) + + {recommended_channel, recommended_score} = + Enum.min_by(scored, fn {_ch, score} -> score end, fn -> {nil, nil} end) + + cond do + is_nil(recommended_channel) -> + nil + + recommended_channel == snmp.current_channel -> + nil + + not improvement_worth_it?(current_score, recommended_score) -> + nil + + true -> + offenders = top_offenders(scans, snmp.current_channel) + + %{ + organization_id: organization_id, + device_id: device.id, + type: "ap_frequency_change", + urgency: urgency_for(current_score), + channel: "proactive", + source: "system", + title: + "AP #{device.name || snmp.sys_name || "(unnamed)"} should change channel: " <> + "#{snmp.current_channel} → #{recommended_channel}", + metadata: %{ + "current_channel" => snmp.current_channel, + "current_frequency_mhz" => snmp.current_frequency_mhz, + "channel_width_mhz" => snmp.current_channel_width_mhz, + "recommended_channel" => recommended_channel, + "current_score" => current_score, + "recommended_score" => recommended_score, + "improvement_db" => score_to_db(current_score) - score_to_db(recommended_score), + "top_offenders" => offenders, + "lookback_hours" => 24 + } + } + end + end + + defp candidates_for(frequency_mhz, current_channel) when is_integer(frequency_mhz) do + cond do + frequency_mhz >= 5000 -> @candidates_5ghz + frequency_mhz >= 2400 and frequency_mhz < 2500 -> @candidates_24ghz + true -> [current_channel] + end + end + + defp candidates_for(_freq, current_channel), do: [current_channel] + + defp score_channels(scans, candidates) do + by_channel = + scans + |> Enum.filter(&(&1.channel != nil and &1.rssi_dbm != nil)) + |> Enum.group_by(& &1.channel) + + Map.new(candidates, fn ch -> + score = + by_channel + |> Map.get(ch, []) + |> Enum.reduce(0.0, fn s, acc -> acc + linear_power(s.rssi_dbm) end) + + {ch, score} + end) + end + + # Convert dBm to a linear power-like contribution. Stronger signals + # contribute much more than weak ones (10 dB ≈ 10× contribution). + defp linear_power(rssi_dbm) do + :math.pow(10.0, (rssi_dbm + 95) / 10.0) + end + + defp score_to_db(score) when is_number(score) and score > 0 do + 10.0 * :math.log10(score) + end + + defp score_to_db(_), do: 0.0 + + defp improvement_worth_it?(current, recommended) when is_number(current) and is_number(recommended) do + score_to_db(current) - score_to_db(recommended) >= @min_improvement_db + end + + defp urgency_for(current_score) when current_score >= @critical_score, do: "critical" + defp urgency_for(_), do: "warning" + + defp top_offenders(scans, current_channel) do + scans + |> Enum.filter(&(&1.channel == current_channel and not is_nil(&1.rssi_dbm))) + |> Enum.sort_by(& &1.rssi_dbm, :desc) + |> Enum.take(@max_offenders) + |> Enum.map(fn s -> + %{ + "bssid" => s.bssid, + "ssid" => s.ssid, + "rssi_dbm" => s.rssi_dbm, + "channel" => s.channel, + "frequency_mhz" => s.frequency_mhz, + "is_own_fleet" => s.is_own_fleet, + "seen_at" => s.seen_at && DateTime.to_iso8601(s.seen_at) + } + end) + end +end diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index fa9a89ba..e82397bb 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -36,6 +36,13 @@ defmodule Towerops.Snmp.Device do field :raw_discovery_data, :map field :last_discovery_at, :utc_datetime + # Current radio configuration (populated by vendor SNMP poller profiles + # for APs that support wireless OIDs). + field :current_channel, :integer + field :current_frequency_mhz, :integer + field :current_channel_width_mhz, :integer + field :last_radio_seen_at, :utc_datetime + belongs_to :device, DeviceSchema has_many :interfaces, Interface, foreign_key: :snmp_device_id @@ -64,6 +71,10 @@ defmodule Towerops.Snmp.Device do serial_number: String.t() | nil, raw_discovery_data: map() | nil, last_discovery_at: DateTime.t() | nil, + current_channel: integer() | nil, + current_frequency_mhz: integer() | nil, + current_channel_width_mhz: integer() | nil, + last_radio_seen_at: DateTime.t() | nil, device_id: Ecto.UUID.t(), device: NotLoaded.t() | DeviceSchema.t(), interfaces: NotLoaded.t() | [Interface.t()], @@ -94,7 +105,11 @@ defmodule Towerops.Snmp.Device do :firmware_version, :serial_number, :raw_discovery_data, - :last_discovery_at + :last_discovery_at, + :current_channel, + :current_frequency_mhz, + :current_channel_width_mhz, + :last_radio_seen_at ]) |> validate_required([:device_id]) |> unique_constraint(:device_id) diff --git a/lib/towerops/snmp/wireless_neighbor_scan.ex b/lib/towerops/snmp/wireless_neighbor_scan.ex new file mode 100644 index 00000000..351c4407 --- /dev/null +++ b/lib/towerops/snmp/wireless_neighbor_scan.ex @@ -0,0 +1,59 @@ +defmodule Towerops.Snmp.WirelessNeighborScan do + @moduledoc """ + An observation of a neighboring access point seen by one of our APs + during a wireless scan (vendor-specific SNMP `*WlSurveyTable` / + `cambium*Neighbor*` / etc.). + + Append-only — multiple rows per (device, bssid) accumulate over time + so the recommendation engine can reason about RF environment trends. + + `is_own_fleet` is true when the observed BSSID matches the MAC of + another device in the same organization (set at write time, not at + query time, so we can index it cheaply if needed later). + """ + + use Ecto.Schema + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "wireless_neighbor_scans" do + field :bssid, :string + field :ssid, :string + field :channel, :integer + field :frequency_mhz, :integer + field :channel_width_mhz, :integer + field :rssi_dbm, :integer + field :is_own_fleet, :boolean, default: false + field :seen_at, :utc_datetime + + belongs_to :device, Towerops.Devices.Device + belongs_to :organization, Towerops.Organizations.Organization + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(scan, attrs) do + scan + |> cast(attrs, [ + :device_id, + :organization_id, + :bssid, + :ssid, + :channel, + :frequency_mhz, + :channel_width_mhz, + :rssi_dbm, + :is_own_fleet, + :seen_at + ]) + |> validate_required([:device_id, :organization_id, :bssid, :seen_at]) + |> foreign_key_constraint(:device_id) + |> foreign_key_constraint(:organization_id) + end +end diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex new file mode 100644 index 00000000..3bef636f --- /dev/null +++ b/lib/towerops/workers/recommendations_run_worker.ex @@ -0,0 +1,51 @@ +defmodule Towerops.Workers.RecommendationsRunWorker do + @moduledoc """ + Oban cron worker that runs all recommendation rules across organizations. + + Each rule is a pure function that returns a list of insight attribute + maps. We upsert via `Towerops.Preseem.Insights.insert_insight_if_new/1`, + which dedups on `(device_id, type, status="active")` so re-runs of the + same rule against the same AP do not produce duplicates. + + New rules can be added by appending to `@rules`. They must implement + `evaluate(organization_id) :: [insight_attrs]`. + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 3 + + import Ecto.Query + + alias Towerops.Organizations.Organization + alias Towerops.Preseem.Insights + alias Towerops.Recommendations.Rules.FrequencyChange + alias Towerops.Repo + + require Logger + + @rules [FrequencyChange] + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Enum.each(list_organization_ids(), &run_rules_for_org/1) + :ok + end + + defp list_organization_ids do + Organization + |> select([o], o.id) + |> Repo.all() + end + + defp run_rules_for_org(organization_id) do + Enum.each(@rules, fn rule -> + try do + organization_id |> rule.evaluate() |> Enum.each(&Insights.insert_insight_if_new/1) + rescue + e -> + Logger.warning( + "Recommendation rule #{inspect(rule)} failed for org #{organization_id}: #{Exception.message(e)}" + ) + end + end) + end +end diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 7f174176..337367ed 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -392,6 +392,69 @@ <% end %> + <%= if insight.type == "ap_frequency_change" do %> +
+
+
+ + {t("Current channel")} + + + {insight.metadata["current_channel"]} + +
+ <.icon + name="hero-arrow-right" + class="h-4 w-4 text-purple-500 dark:text-purple-400" + /> +
+ + {t("Recommended")} + + + {insight.metadata["recommended_channel"]} + +
+ <%= if insight.metadata["improvement_db"] do %> + + {Float.round(insight.metadata["improvement_db"] / 1.0, 1)} dB cleaner + + <% end %> +
+ + <%= if insight.metadata["top_offenders"] && insight.metadata["top_offenders"] != [] do %> +
+

+ {t("Top interferers on current channel")} +

+
    +
  • + + {offender["bssid"]} + <%= if offender["ssid"] do %> + + {offender["ssid"]} + + <% end %> + <%= if offender["is_own_fleet"] do %> + + own fleet + + <% end %> + + + {offender["rssi_dbm"]} dBm + +
  • +
+
+ <% end %> +
+ <% end %> +
<%!-- Access point --%> <%= if insight.preseem_access_point do %> diff --git a/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs b/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs new file mode 100644 index 00000000..112a0686 --- /dev/null +++ b/priv/repo/migrations/20260509214247_create_wireless_neighbor_scans.exs @@ -0,0 +1,31 @@ +defmodule Towerops.Repo.Migrations.CreateWirelessNeighborScans do + use Ecto.Migration + + def change do + create table(:wireless_neighbor_scans, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false + + add :organization_id, + references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :bssid, :string, null: false + add :ssid, :string + add :channel, :integer + add :frequency_mhz, :integer + add :channel_width_mhz, :integer + add :rssi_dbm, :integer + add :is_own_fleet, :boolean, default: false, null: false + add :seen_at, :utc_datetime, null: false + + timestamps(type: :utc_datetime, updated_at: false) + end + + create index(:wireless_neighbor_scans, [:device_id, :seen_at]) + create index(:wireless_neighbor_scans, [:device_id, :channel]) + create index(:wireless_neighbor_scans, [:bssid]) + create index(:wireless_neighbor_scans, [:organization_id, :seen_at]) + end +end diff --git a/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs b/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs new file mode 100644 index 00000000..e3e3c29d --- /dev/null +++ b/priv/repo/migrations/20260509214358_add_radio_fields_to_snmp_devices.exs @@ -0,0 +1,14 @@ +defmodule Towerops.Repo.Migrations.AddRadioFieldsToSnmpDevices do + use Ecto.Migration + + def change do + alter table(:snmp_devices) do + add :current_channel, :integer + add :current_frequency_mhz, :integer + add :current_channel_width_mhz, :integer + add :last_radio_seen_at, :utc_datetime + end + + create index(:snmp_devices, [:current_channel]) + end +end diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index 2c38a482..f70dd7fd 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -1,4 +1,5 @@ -2026-05-09 — AI-generated insight summaries +2026-05-09 — Frequency-change recommendations + AI-generated insight summaries +* New recommendation: when a wireless AP has strong same-channel interference, the system suggests a specific cleaner channel and shows the top interfering neighbors with their signal levels * Active insights now include a plain-language summary and a specific recommended action * AI summaries refresh automatically — no setup required for users diff --git a/test/towerops/recommendations/rules/frequency_change_test.exs b/test/towerops/recommendations/rules/frequency_change_test.exs new file mode 100644 index 00000000..588fd07c --- /dev/null +++ b/test/towerops/recommendations/rules/frequency_change_test.exs @@ -0,0 +1,183 @@ +defmodule Towerops.Recommendations.Rules.FrequencyChangeTest do + @moduledoc """ + Pure-function tests for the FrequencyChange rule. + + Builds a small fixture: one observing AP with a current channel, plus + a set of `wireless_neighbor_scans` rows representing the RF environment. + Asserts the rule produces (or skips) an `ap_frequency_change` insight + with the expected evidence. + """ + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Recommendations.Rules.FrequencyChange + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.WirelessNeighborScan + + setup do + user = user_fixture() + org = organization_fixture(user.id) + device = device_fixture(%{organization_id: org.id, name: "AP-North"}) + + {:ok, snmp} = + %SnmpDevice{} + |> SnmpDevice.changeset(%{ + device_id: device.id, + sys_name: "AP-North", + current_channel: 149, + current_frequency_mhz: 5745, + current_channel_width_mhz: 80, + last_radio_seen_at: ~U[2026-05-09 12:00:00Z] + }) + |> Repo.insert() + + %{org: org, device: device, snmp: snmp} + end + + defp insert_scan(org, device, attrs) do + full = + Map.merge( + %{ + device_id: device.id, + organization_id: org.id, + bssid: "AA:BB:CC:DD:EE:FF", + channel: 149, + frequency_mhz: 5745, + channel_width_mhz: 80, + rssi_dbm: -70, + is_own_fleet: false, + seen_at: ~U[2026-05-09 12:00:00Z] + }, + attrs + ) + + %WirelessNeighborScan{} + |> WirelessNeighborScan.changeset(full) + |> Repo.insert!() + end + + test "returns no insight when there are no neighbor scans", %{org: org} do + assert FrequencyChange.evaluate(org.id) == [] + end + + test "returns no insight when same-channel neighbors are below the threshold", + %{org: org, device: device} do + insert_scan(org, device, %{channel: 149, rssi_dbm: -85}) + + assert FrequencyChange.evaluate(org.id) == [] + end + + test "produces an insight when a strong same-channel neighbor exists", + %{org: org, device: device} do + insert_scan(org, device, %{ + bssid: "11:22:33:44:55:66", + ssid: "Neighbor-A", + channel: 149, + rssi_dbm: -62 + }) + + # Add a clean alternative channel — no neighbors observed there + insert_scan(org, device, %{ + bssid: "11:22:33:44:55:77", + ssid: "Neighbor-B", + channel: 153, + rssi_dbm: -90 + }) + + assert [insight] = FrequencyChange.evaluate(org.id) + assert insight.type == "ap_frequency_change" + assert insight.urgency in ~w(warning critical) + assert insight.organization_id == org.id + assert insight.device_id == device.id + + md = insight.metadata + assert md["current_channel"] == 149 + assert md["current_frequency_mhz"] == 5745 + assert md["channel_width_mhz"] == 80 + assert is_integer(md["recommended_channel"]) + assert md["recommended_channel"] != 149 + assert is_number(md["current_score"]) + assert is_number(md["recommended_score"]) + assert md["current_score"] > md["recommended_score"] + assert is_list(md["top_offenders"]) + refute Enum.empty?(md["top_offenders"]) + [first | _] = md["top_offenders"] + assert first["bssid"] == "11:22:33:44:55:66" + assert first["rssi_dbm"] == -62 + + assert is_binary(insight.title) + assert insight.title =~ "AP-North" + assert insight.title =~ "149" + end + + test "marks urgency critical when current interference score is very high", + %{org: org, device: device} do + # Many strong same-channel neighbors → score far above critical threshold + for i <- 1..6 do + insert_scan(org, device, %{ + bssid: "AA:BB:CC:00:00:0#{i}", + channel: 149, + rssi_dbm: -55 + }) + end + + insert_scan(org, device, %{bssid: "11:11:11:11:11:11", channel: 153, rssi_dbm: -90}) + + assert [insight] = FrequencyChange.evaluate(org.id) + assert insight.urgency == "critical" + end + + test "top_offenders is sorted by RSSI descending and capped at 5", + %{org: org, device: device} do + rssis = [-78, -65, -62, -70, -82, -55, -90] + + for {rssi, i} <- Enum.with_index(rssis) do + insert_scan(org, device, %{ + bssid: "AA:BB:CC:DD:00:0#{i}", + channel: 149, + rssi_dbm: rssi + }) + end + + assert [insight] = FrequencyChange.evaluate(org.id) + offenders = insight.metadata["top_offenders"] + assert length(offenders) == 5 + + rssi_values = Enum.map(offenders, & &1["rssi_dbm"]) + assert rssi_values == Enum.sort(rssi_values, :desc) + # Strongest signal should be first + assert hd(rssi_values) == -55 + end + + test "ignores APs without a current_channel", %{org: org, device: device} do + # Disable the radio data on our AP + Repo.update_all(SnmpDevice, set: [current_channel: nil, current_frequency_mhz: nil]) + + insert_scan(org, device, %{channel: 149, rssi_dbm: -55}) + + assert FrequencyChange.evaluate(org.id) == [] + end + + test "ignores neighbor scans older than 24h", %{org: org, device: device} do + old = DateTime.add(DateTime.utc_now(), -25 * 3600, :second) + + insert_scan(org, device, %{ + channel: 149, + rssi_dbm: -55, + seen_at: DateTime.truncate(old, :second) + }) + + assert FrequencyChange.evaluate(org.id) == [] + end + + test "scopes to the given organization", %{org: org, device: device} do + insert_scan(org, device, %{channel: 149, rssi_dbm: -55}) + + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + assert FrequencyChange.evaluate(other_org.id) == [] + end +end diff --git a/test/towerops/snmp/device_radio_fields_test.exs b/test/towerops/snmp/device_radio_fields_test.exs new file mode 100644 index 00000000..74494246 --- /dev/null +++ b/test/towerops/snmp/device_radio_fields_test.exs @@ -0,0 +1,58 @@ +defmodule Towerops.Snmp.DeviceRadioFieldsTest do + @moduledoc """ + Coverage for the radio fields added to snmp_devices to support + RF/frequency-based recommendations. + """ + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Snmp.Device, as: SnmpDevice + + setup do + user = user_fixture() + org = organization_fixture(user.id) + device = device_fixture(%{organization_id: org.id}) + %{device: device} + end + + describe "changeset/2 with radio fields" do + test "casts and round-trips current_channel/frequency/width and last_radio_seen_at", + %{device: device} do + attrs = %{ + device_id: device.id, + sys_name: "ap1", + current_channel: 149, + current_frequency_mhz: 5745, + current_channel_width_mhz: 80, + last_radio_seen_at: ~U[2026-05-09 12:00:00Z] + } + + assert {:ok, snmp} = + %SnmpDevice{} + |> SnmpDevice.changeset(attrs) + |> Repo.insert() + + assert snmp.current_channel == 149 + assert snmp.current_frequency_mhz == 5745 + assert snmp.current_channel_width_mhz == 80 + assert snmp.last_radio_seen_at == ~U[2026-05-09 12:00:00Z] + end + + test "radio fields are optional", %{device: device} do + attrs = %{device_id: device.id, sys_name: "ap-no-radio"} + + assert {:ok, snmp} = + %SnmpDevice{} + |> SnmpDevice.changeset(attrs) + |> Repo.insert() + + assert is_nil(snmp.current_channel) + assert is_nil(snmp.current_frequency_mhz) + assert is_nil(snmp.current_channel_width_mhz) + assert is_nil(snmp.last_radio_seen_at) + end + end +end diff --git a/test/towerops/snmp/wireless_neighbor_scan_test.exs b/test/towerops/snmp/wireless_neighbor_scan_test.exs new file mode 100644 index 00000000..6600715f --- /dev/null +++ b/test/towerops/snmp/wireless_neighbor_scan_test.exs @@ -0,0 +1,117 @@ +defmodule Towerops.Snmp.WirelessNeighborScanTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Snmp.WirelessNeighborScan + + setup do + user = user_fixture() + org = organization_fixture(user.id) + device = device_fixture(%{organization_id: org.id}) + %{org: org, device: device} + end + + describe "changeset/2" do + test "valid changeset with all fields", %{org: org, device: device} do + attrs = %{ + device_id: device.id, + organization_id: org.id, + bssid: "AA:BB:CC:11:22:33", + ssid: "neighbor-ap", + channel: 149, + frequency_mhz: 5745, + channel_width_mhz: 80, + rssi_dbm: -62, + is_own_fleet: true, + seen_at: ~U[2026-05-09 12:00:00Z] + } + + changeset = WirelessNeighborScan.changeset(%WirelessNeighborScan{}, attrs) + assert changeset.valid? + + assert {:ok, scan} = Repo.insert(changeset) + assert scan.bssid == "AA:BB:CC:11:22:33" + assert scan.is_own_fleet == true + assert scan.rssi_dbm == -62 + end + + test "minimal changeset (just required fields)", %{org: org, device: device} do + attrs = %{ + device_id: device.id, + organization_id: org.id, + bssid: "DE:AD:BE:EF:00:01", + seen_at: ~U[2026-05-09 12:00:00Z] + } + + changeset = WirelessNeighborScan.changeset(%WirelessNeighborScan{}, attrs) + assert changeset.valid? + assert {:ok, _scan} = Repo.insert(changeset) + end + + test "is_own_fleet defaults to false", %{org: org, device: device} do + attrs = %{ + device_id: device.id, + organization_id: org.id, + bssid: "AA:BB:CC:DD:EE:FF", + seen_at: ~U[2026-05-09 12:00:00Z] + } + + assert {:ok, scan} = + %WirelessNeighborScan{} + |> WirelessNeighborScan.changeset(attrs) + |> Repo.insert() + + assert scan.is_own_fleet == false + end + + test "requires device_id" do + changeset = + WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{ + bssid: "AA:BB:CC:DD:EE:FF", + seen_at: ~U[2026-05-09 12:00:00Z] + }) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).device_id + end + + test "requires organization_id", %{device: device} do + changeset = + WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{ + device_id: device.id, + bssid: "AA:BB:CC:DD:EE:FF", + seen_at: ~U[2026-05-09 12:00:00Z] + }) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires bssid", %{org: org, device: device} do + changeset = + WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{ + device_id: device.id, + organization_id: org.id, + seen_at: ~U[2026-05-09 12:00:00Z] + }) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).bssid + end + + test "requires seen_at", %{org: org, device: device} do + changeset = + WirelessNeighborScan.changeset(%WirelessNeighborScan{}, %{ + device_id: device.id, + organization_id: org.id, + bssid: "AA:BB:CC:DD:EE:FF" + }) + + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).seen_at + end + end +end diff --git a/test/towerops/workers/recommendations_run_worker_test.exs b/test/towerops/workers/recommendations_run_worker_test.exs new file mode 100644 index 00000000..97e48f28 --- /dev/null +++ b/test/towerops/workers/recommendations_run_worker_test.exs @@ -0,0 +1,84 @@ +defmodule Towerops.Workers.RecommendationsRunWorkerTest do + @moduledoc """ + Drives the cron worker that fans out recommendation rule evaluation + across organizations and upserts the resulting insights. + """ + use Towerops.DataCase, async: false + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.Insight + alias Towerops.Preseem.Insights + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.WirelessNeighborScan + alias Towerops.Workers.RecommendationsRunWorker + + defp setup_ap_with_strong_neighbor(org) do + device = device_fixture(%{organization_id: org.id, name: "AP-X"}) + + {:ok, _snmp} = + %SnmpDevice{} + |> SnmpDevice.changeset(%{ + device_id: device.id, + sys_name: "AP-X", + current_channel: 149, + current_frequency_mhz: 5745, + current_channel_width_mhz: 80, + last_radio_seen_at: ~U[2026-05-09 12:00:00Z] + }) + |> Repo.insert() + + %WirelessNeighborScan{} + |> WirelessNeighborScan.changeset(%{ + device_id: device.id, + organization_id: org.id, + bssid: "AA:BB:CC:DD:EE:FF", + channel: 149, + rssi_dbm: -55, + seen_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + |> Repo.insert!() + + device + end + + test "perform/1 generates ap_frequency_change insights for orgs with offending APs" do + user = user_fixture() + org = organization_fixture(user.id) + device = setup_ap_with_strong_neighbor(org) + + assert :ok = RecommendationsRunWorker.perform(%Oban.Job{}) + + insight = Repo.get_by(Insight, organization_id: org.id, type: "ap_frequency_change") + assert insight + assert insight.device_id == device.id + assert insight.metadata["current_channel"] == 149 + assert insight.metadata["recommended_channel"] != 149 + end + + test "perform/1 is idempotent: running twice does not duplicate insights" do + user = user_fixture() + org = organization_fixture(user.id) + setup_ap_with_strong_neighbor(org) + + assert :ok = RecommendationsRunWorker.perform(%Oban.Job{}) + assert :ok = RecommendationsRunWorker.perform(%Oban.Job{}) + + insights = + Insights.list_insights(org.id, type: "ap_frequency_change") + + assert length(insights) == 1 + end + + test "perform/1 produces no insights when there are no qualifying APs" do + user = user_fixture() + org = organization_fixture(user.id) + _quiet_device = device_fixture(%{organization_id: org.id}) + + assert :ok = RecommendationsRunWorker.perform(%Oban.Job{}) + + assert Insights.list_insights(org.id, type: "ap_frequency_change") == [] + end +end diff --git a/test/towerops_web/live/agent_live_test.exs b/test/towerops_web/live/agent_live_test.exs index d47d6925..9a63615e 100644 --- a/test/towerops_web/live/agent_live_test.exs +++ b/test/towerops_web/live/agent_live_test.exs @@ -6,6 +6,7 @@ defmodule ToweropsWeb.AgentLiveTest do import Phoenix.LiveViewTest alias Towerops.Agents + alias Towerops.Agents.ReleaseChecker setup :register_and_log_in_user @@ -572,8 +573,20 @@ defmodule ToweropsWeb.AgentLiveTest do Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true})) {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent") + # Force a transport error from the release-checker HTTP client so the + # test is deterministic. Without an explicit stub, behavior depends on + # whether other tests have left a Req.Test stub registered for this + # module — which makes this test flaky under the full suite. + Req.Test.stub(ReleaseChecker, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + {:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}") + # Allow the LiveView process to use stubs registered in this test + # process (Req.Test.stub is owner-scoped by default). + Req.Test.allow(ReleaseChecker, self(), view.pid) + html = view |> element("button", "Update") diff --git a/test/towerops_web/live/insights_live_events_test.exs b/test/towerops_web/live/insights_live_events_test.exs index b0dc95fd..781618f8 100644 --- a/test/towerops_web/live/insights_live_events_test.exs +++ b/test/towerops_web/live/insights_live_events_test.exs @@ -136,4 +136,55 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do refute html =~ "AI-generated" end end + + describe "ap_frequency_change rendering" do + test "shows current vs recommended channel and the top offender", %{ + conn: conn, + organization: organization + } do + {:ok, _} = + Repo.insert( + Insight.changeset(%Insight{}, %{ + organization_id: organization.id, + type: "ap_frequency_change", + urgency: "warning", + status: "active", + channel: "proactive", + source: "system", + title: "AP-North should change channel: 149 → 161", + metadata: %{ + "current_channel" => 149, + "recommended_channel" => 161, + "current_score" => 25.4, + "recommended_score" => 1.0, + "improvement_db" => 14.0, + "channel_width_mhz" => 80, + "current_frequency_mhz" => 5745, + "top_offenders" => [ + %{ + "bssid" => "AA:BB:CC:DD:EE:FF", + "ssid" => "Neighbor-A", + "rssi_dbm" => -55, + "channel" => 149, + "frequency_mhz" => 5745, + "is_own_fleet" => true, + "seen_at" => "2026-05-09T12:00:00Z" + } + ] + } + }) + ) + + {:ok, _view, html} = live(conn, ~p"/insights") + + assert html =~ "Current channel" + assert html =~ "149" + assert html =~ "161" + assert html =~ "AA:BB:CC:DD:EE:FF" + assert html =~ "Neighbor-A" + assert html =~ "-55 dBm" + assert html =~ "own fleet" + assert html =~ "14.0 dB cleaner" + end + end end diff --git a/test/towerops_web/telemetry_test.exs b/test/towerops_web/telemetry_test.exs index c837689d..5f920f1f 100644 --- a/test/towerops_web/telemetry_test.exs +++ b/test/towerops_web/telemetry_test.exs @@ -157,7 +157,13 @@ defmodule ToweropsWeb.TelemetryTest do ) end) - assert log == "" + # capture_log catches every log line emitted by *any* process while the + # function runs, so a stray DB sandbox or Postgrex disconnect log from + # a concurrent test can show up here. Assert on what this function + # itself does NOT log instead of demanding empty output. + refute log =~ "Slow request" + refute log =~ "Server error" + refute log =~ "/fast-endpoint" end test "logs both warning and error for slow 5xx request" do