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:
parent
03f364a956
commit
f41f5fa1aa
18 changed files with 1005 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
225
lib/towerops/recommendations/rules/frequency_change.ex
Normal file
225
lib/towerops/recommendations/rules/frequency_change.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
59
lib/towerops/snmp/wireless_neighbor_scan.ex
Normal file
59
lib/towerops/snmp/wireless_neighbor_scan.ex
Normal 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
|
||||
51
lib/towerops/workers/recommendations_run_worker.ex
Normal file
51
lib/towerops/workers/recommendations_run_worker.ex
Normal 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
|
||||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
183
test/towerops/recommendations/rules/frequency_change_test.exs
Normal file
183
test/towerops/recommendations/rules/frequency_change_test.exs
Normal 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
|
||||
58
test/towerops/snmp/device_radio_fields_test.exs
Normal file
58
test/towerops/snmp/device_radio_fields_test.exs
Normal 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
|
||||
117
test/towerops/snmp/wireless_neighbor_scan_test.exs
Normal file
117
test/towerops/snmp/wireless_neighbor_scan_test.exs
Normal 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
|
||||
84
test/towerops/workers/recommendations_run_worker_test.exs
Normal file
84
test/towerops/workers/recommendations_run_worker_test.exs
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue