feat(insights): AP frequency-change recommendation rule

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).
This commit is contained in:
Graham McIntire 2026-05-09 16:56:19 -05:00
parent 03f364a956
commit f41f5fa1aa
18 changed files with 1005 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -392,6 +392,69 @@
</div>
<% end %>
<%= if insight.type == "ap_frequency_change" do %>
<div class="mt-3 rounded-md border border-purple-200 bg-purple-50 p-3 dark:border-purple-800 dark:bg-purple-900/20">
<div class="flex items-center gap-3 text-sm">
<div class="flex flex-col">
<span class="text-xs text-purple-700 dark:text-purple-300">
{t("Current channel")}
</span>
<span class="font-mono text-base font-semibold text-purple-900 dark:text-purple-100">
{insight.metadata["current_channel"]}
</span>
</div>
<.icon
name="hero-arrow-right"
class="h-4 w-4 text-purple-500 dark:text-purple-400"
/>
<div class="flex flex-col">
<span class="text-xs text-purple-700 dark:text-purple-300">
{t("Recommended")}
</span>
<span class="font-mono text-base font-semibold text-purple-900 dark:text-purple-100">
{insight.metadata["recommended_channel"]}
</span>
</div>
<%= if insight.metadata["improvement_db"] do %>
<span class="ml-auto rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
{Float.round(insight.metadata["improvement_db"] / 1.0, 1)} dB cleaner
</span>
<% end %>
</div>
<%= if insight.metadata["top_offenders"] && insight.metadata["top_offenders"] != [] do %>
<div class="mt-3">
<p class="text-xs font-medium text-purple-800 dark:text-purple-300">
{t("Top interferers on current channel")}
</p>
<ul class="mt-1 space-y-1">
<li
:for={offender <- insight.metadata["top_offenders"]}
class="flex items-center justify-between gap-2 text-xs text-purple-900 dark:text-purple-100"
>
<span class="flex items-center gap-2 truncate">
<span class="font-mono">{offender["bssid"]}</span>
<%= if offender["ssid"] do %>
<span class="truncate text-purple-700 dark:text-purple-300">
{offender["ssid"]}
</span>
<% end %>
<%= if offender["is_own_fleet"] do %>
<span class="rounded bg-purple-200 px-1.5 py-0.5 text-[10px] font-medium text-purple-900 dark:bg-purple-800 dark:text-purple-100">
own fleet
</span>
<% end %>
</span>
<span class="font-mono text-purple-700 dark:text-purple-300">
{offender["rssi_dbm"]} dBm
</span>
</li>
</ul>
</div>
<% end %>
</div>
<% end %>
<div class="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<%!-- Access point --%>
<%= if insight.preseem_access_point do %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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