feat(insights): SectorOverload + CpeRealign recommendation rules

Two new multi-source rules using data we already collect, both wired
into the existing hourly RecommendationsRunWorker.

SectorOverload — fires when a Preseem-monitored AP has <25% free
airtime AND active subscribers. Critical urgency when free airtime
drops below 15%, OR when free airtime <25% AND QoE <50. Reuses the
airtime/subscriber/QoE data already pulled by PreseemSyncWorker so
no new collection is needed. UI: orange evidence card with free
airtime, subscriber count, QoE score, and AP model.

CpeRealign — fires when a wireless client has BOTH signal_strength
<=-78 dBm AND SNR <=18 dB seen in the last 2 hours. This is the
classic alignment / obstruction signature, distinct from the
existing single-metric wireless_signal_weak / wireless_snr_low
alerts produced by WirelessInsightWorker. One insight per (AP, CPE)
pair via a metadata.dedup_key. Critical when signal <=-88 OR SNR
<=10. UI: rose evidence card with signal, SNR, TX/RX rate, distance,
hostname.

Insights.insert_insight_if_new/1 dedup logic now prefers a
metadata.dedup_key over device_id when explicitly set, allowing
per-CPE insights without collapsing multiple CPEs on the same AP
into a single insight. Existing rules without dedup_key are
unaffected.

Insight.@valid_types extended with sector_overload and cpe_realign.

LLM enrichment automatically applies to both new types via the
existing Phase 1 worker — no extra wiring.

Also adds k8s/secrets.yaml to .gitignore so operators can drop a
local Secret manifest with real values, kubectl apply manually, and
never accidentally commit it. Documented in k8s/README.md.
This commit is contained in:
Graham McIntire 2026-05-09 17:07:15 -05:00
parent f41f5fa1aa
commit 91f23416e4
13 changed files with 665 additions and 11 deletions

4
.gitignore vendored
View file

@ -99,6 +99,10 @@ profiles.json
# Stride API credentials (contains secrets - never commit)
.stride_auth.md
# Local k8s secret manifest — copy from any *.example.yaml in k8s/, fill in
# real values, then `kubectl apply -f k8s/secrets.yaml`. Never commit.
k8s/secrets.yaml
# Build artifacts
/build/

View file

@ -1,3 +1,25 @@
2026-05-09
feat(insights): two more recommendation rules — SectorOverload, CpeRealign
Towerops.Recommendations.Rules.SectorOverload — fires when a Preseem
AP has <25% free airtime AND active subscribers. Critical when
free airtime <15% OR (free airtime <25% AND QoE <50). Reuses
existing Preseem AP polling — no new data collection. UI: orange
card with free airtime, subscriber count, QoE score, AP model.
Towerops.Recommendations.Rules.CpeRealign — fires when a wireless
client has BOTH signal_strength <=-78 dBm AND snr <=18 dB (the
alignment-issue signature, distinct from the existing single-metric
wireless_signal_weak / wireless_snr_low alerts). One insight per
(AP, CPE) pair via metadata.dedup_key. Critical when signal <=-88
OR snr <=10. UI: rose card with signal, SNR, TX/RX rate, distance,
hostname.
Insights.insert_insight_if_new/1 dedup logic now prefers
metadata.dedup_key over device_id when set, allowing per-CPE
insights without collapsing all CPEs on one AP into a single insight.
Insight.@valid_types extended with sector_overload, cpe_realign.
Both rules are wired into RecommendationsRunWorker (hourly).
k8s: gitignored k8s/secrets.yaml workflow — copy from any
*.example.yaml in k8s/, fill in values, kubectl apply manually.
2026-05-09
feat(insights): AP frequency-change recommendation rule
Schema: new wireless_neighbor_scans table (migration 20260509214247)

View file

@ -13,15 +13,22 @@ Required secrets in the `towerops` namespace:
Optional secrets:
- `towerops-llm` - DeepSeek API credentials for LLM-powered insight enrichment.
Optional — when missing, insights still display without an AI summary.
See `k8s/towerops-llm-secret.example.yaml` for the template, or create with:
```bash
kubectl create secret generic towerops-llm \
--from-literal=DEEPSEEK_API_KEY="<your-key>" \
--from-literal=DEEPSEEK_MODEL="deepseek-v4-pro" \
-n towerops
kubectl rollout restart deployment/towerops -n towerops
```
### Local secrets workflow
The file `k8s/secrets.yaml` is gitignored. Use it to keep one-or-more
`Secret` manifests with real values for hand-application against the
cluster. Bootstrap from any `*.example.yaml` template:
```bash
cp k8s/towerops-llm-secret.example.yaml k8s/secrets.yaml
# edit k8s/secrets.yaml — fill in DEEPSEEK_API_KEY, etc.
kubectl apply -f k8s/secrets.yaml
kubectl rollout restart deployment/towerops -n towerops
```
`k8s/secrets.yaml` may contain multiple `---`-separated documents if you
need more than one secret. It is excluded from git via `.gitignore`.
For local development, the project root `.envrc` is used by direnv.

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 ap_frequency_change)
@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 sector_overload cpe_realign)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)

View file

@ -152,6 +152,17 @@ defmodule Towerops.Preseem.Insights do
end
end
# When a rule wants per-sub-entity dedup (e.g. one insight per CPE on
# the same AP), it can set `metadata.dedup_key`. That takes precedence
# over the broader `device_id` dedup so multiple insights for different
# CPEs on the same AP don't collapse into one.
defp build_dedup_query(%{organization_id: org_id, type: type, metadata: %{"dedup_key" => key}})
when not is_nil(org_id) and is_binary(key) do
Insight
|> where(organization_id: ^org_id, type: ^type, status: "active")
|> where([i], fragment("?->>'dedup_key' = ?", i.metadata, ^key))
end
defp build_dedup_query(%{device_id: device_id, type: type}) when not is_nil(device_id) do
where(Insight, device_id: ^device_id, type: ^type, status: "active")
end

View file

@ -0,0 +1,97 @@
defmodule Towerops.Recommendations.Rules.CpeRealign do
@moduledoc """
Recommendation rule that flags CPE radios needing physical re-aim.
This is *distinct* from the existing `wireless_signal_weak` and
`wireless_snr_low` insights produced by `WirelessInsightWorker`:
* Those fire on a single weak metric and aggregate per-AP.
* This rule requires BOTH signal AND SNR to be weak the strong
signature of an alignment / obstruction problem rather than just
ambient interference. It produces one insight per (AP, CPE) pair
so each truck-roll candidate gets its own actionable card.
Inputs:
* `wireless_clients` rows with non-nil `signal_strength` and `snr`
seen in the last 2 hours.
Output:
* One `cpe_realign` insight per qualifying (AP, CPE) with metadata
including signal_strength_dbm, snr_db, tx_rate, rx_rate, distance,
uptime_seconds, hostname.
"""
import Ecto.Query
alias Towerops.Repo
alias Towerops.Snmp.WirelessClient
@signal_threshold_dbm -78
@snr_threshold_db 18
@signal_critical_dbm -88
@snr_critical_db 10
@lookback_hours 2
@spec evaluate(Ecto.UUID.t()) :: [map()]
def evaluate(organization_id) when is_binary(organization_id) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@lookback_hours * 3600, :second)
|> DateTime.truncate(:second)
organization_id
|> load_problem_clients(cutoff)
|> Enum.map(&build_insight(&1, organization_id))
end
defp load_problem_clients(organization_id, cutoff) do
Repo.all(
from(c in WirelessClient,
where:
c.organization_id == ^organization_id and not is_nil(c.signal_strength) and not is_nil(c.snr) and
c.signal_strength <= ^@signal_threshold_dbm and c.snr <= ^@snr_threshold_db and c.last_seen_at >= ^cutoff
)
)
end
defp build_insight(client, organization_id) do
%{
organization_id: organization_id,
device_id: client.device_id,
type: "cpe_realign",
urgency: urgency_for(client),
channel: "proactive",
source: "snmp",
title:
"Re-aim CPE #{client.mac_address}: " <>
"#{client.signal_strength} dBm signal, #{client.snr} dB SNR",
description:
"CPE #{client.mac_address} on the AP shows both weak signal " <>
"(#{client.signal_strength} dBm) and low SNR (#{client.snr} dB) — " <>
"a classic alignment / obstruction signature distinct from " <>
"ambient interference.",
metadata: %{
"dedup_key" => "cpe_realign:#{client.device_id}:#{client.mac_address}",
"cpe_mac" => client.mac_address,
"cpe_hostname" => client.hostname,
"cpe_ip" => client.ip_address,
"signal_strength_dbm" => client.signal_strength,
"snr_db" => client.snr,
"tx_rate" => client.tx_rate,
"rx_rate" => client.rx_rate,
"distance_m" => client.distance,
"uptime_seconds" => client.uptime_seconds,
"signal_threshold_dbm" => @signal_threshold_dbm,
"snr_threshold_db" => @snr_threshold_db,
"last_seen_at" => client.last_seen_at && DateTime.to_iso8601(client.last_seen_at)
}
}
end
defp urgency_for(%{signal_strength: s, snr: n})
when is_integer(s) and is_integer(n) and (s <= @signal_critical_dbm or n <= @snr_critical_db), do: "critical"
defp urgency_for(_), do: "warning"
end

View file

@ -0,0 +1,101 @@
defmodule Towerops.Recommendations.Rules.SectorOverload do
@moduledoc """
Recommendation rule that flags Preseem-monitored access points whose
free airtime drops below the operator-actionable threshold.
Per Preseem's published guidance, sustaining <25% airtime free during
busy hours degrades QoE substantially; below ~15% the AP is in or
approaching congestion collapse. This rule emits a `sector_overload`
insight with structured evidence (free airtime, subscriber count, QoE
score) so the operator can make a load-balance / split / upgrade call.
Scoped to APs that are *matched* to a Towerops device (we need a
`device_id` for the insight), have at least one active subscriber,
and whose `airtime_utilization` is non-nil. The data underlying these
insights is the per-AP `airtime_remaining_tx` field stored in the
`airtime_utilization` column on `preseem_access_points`.
"""
import Ecto.Query
alias Towerops.Preseem.AccessPoint
alias Towerops.Repo
# Free airtime % below this triggers a warning insight.
@warning_threshold 25.0
# Free airtime % below this is critical (regardless of QoE).
@critical_threshold 15.0
# If free airtime is <warning AND QoE is below this, escalate to critical.
@qoe_critical_threshold 50.0
@spec evaluate(Ecto.UUID.t()) :: [map()]
def evaluate(organization_id) when is_binary(organization_id) do
organization_id
|> load_overloaded_aps()
|> Enum.map(&build_insight(&1, organization_id))
end
defp load_overloaded_aps(organization_id) do
Repo.all(
from(ap in AccessPoint,
where:
ap.organization_id == ^organization_id and not is_nil(ap.device_id) and not is_nil(ap.airtime_utilization) and
ap.airtime_utilization < ^@warning_threshold and not is_nil(ap.subscriber_count) and ap.subscriber_count > 0
)
)
end
defp build_insight(ap, organization_id) do
%{
organization_id: organization_id,
device_id: ap.device_id,
preseem_access_point_id: ap.id,
type: "sector_overload",
urgency: urgency_for(ap),
channel: "proactive",
source: "preseem",
title: "Sector overload on #{ap.name || "AP"}: #{format_pct(ap.airtime_utilization)} airtime free",
description:
"AP has #{ap.subscriber_count} active subscribers and only " <>
"#{format_pct(ap.airtime_utilization)} free airtime — at or below " <>
"the threshold where latency under load typically degrades sharply.",
metadata: %{
"airtime_remaining_pct" => to_float(ap.airtime_utilization),
"subscriber_count" => ap.subscriber_count,
"qoe_score" => to_nilable_float(ap.qoe_score),
"capacity_score" => to_nilable_float(ap.capacity_score),
"warning_threshold_pct" => @warning_threshold,
"critical_threshold_pct" => @critical_threshold,
"ap_model" => ap.model,
"ap_firmware" => ap.firmware
}
}
end
defp urgency_for(ap) do
airtime = to_float(ap.airtime_utilization)
qoe = to_nilable_float(ap.qoe_score)
cond do
airtime < @critical_threshold -> "critical"
not is_nil(qoe) and qoe < @qoe_critical_threshold -> "critical"
true -> "warning"
end
end
defp to_float(value) when is_float(value), do: value
defp to_float(value) when is_integer(value), do: value * 1.0
defp to_float(_), do: 0.0
defp to_nilable_float(nil), do: nil
defp to_nilable_float(value) when is_float(value), do: value
defp to_nilable_float(value) when is_integer(value), do: value * 1.0
defp format_pct(value) when is_number(value) do
"#{Float.round(to_float(value), 1)}%"
end
defp format_pct(_), do: "?%"
end

View file

@ -17,12 +17,14 @@ defmodule Towerops.Workers.RecommendationsRunWorker do
alias Towerops.Organizations.Organization
alias Towerops.Preseem.Insights
alias Towerops.Recommendations.Rules.CpeRealign
alias Towerops.Recommendations.Rules.FrequencyChange
alias Towerops.Recommendations.Rules.SectorOverload
alias Towerops.Repo
require Logger
@rules [FrequencyChange]
@rules [FrequencyChange, SectorOverload, CpeRealign]
@impl Oban.Worker
def perform(%Oban.Job{}) do

View file

@ -392,6 +392,94 @@
</div>
<% end %>
<%= if insight.type == "sector_overload" do %>
<div class="mt-3 rounded-md border border-orange-200 bg-orange-50 p-3 dark:border-orange-800 dark:bg-orange-900/20">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex flex-col">
<span class="text-xs text-orange-700 dark:text-orange-300">
{t("Free airtime")}
</span>
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
{Float.round(insight.metadata["airtime_remaining_pct"] / 1.0, 1)}%
</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-orange-700 dark:text-orange-300">
{t("Subscribers")}
</span>
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
{insight.metadata["subscriber_count"]}
</span>
</div>
<%= if insight.metadata["qoe_score"] do %>
<div class="flex flex-col">
<span class="text-xs text-orange-700 dark:text-orange-300">
{t("QoE score")}
</span>
<span class="font-mono text-base font-semibold text-orange-900 dark:text-orange-100">
{Float.round(insight.metadata["qoe_score"] / 1.0, 1)}
</span>
</div>
<% end %>
<%= if insight.metadata["ap_model"] do %>
<span class="ml-auto text-xs text-orange-700 dark:text-orange-300">
{insight.metadata["ap_model"]}
{insight.metadata["ap_firmware"] &&
"(" <> insight.metadata["ap_firmware"] <> ")"}
</span>
<% end %>
</div>
</div>
<% end %>
<%= if insight.type == "cpe_realign" do %>
<div class="mt-3 rounded-md border border-rose-200 bg-rose-50 p-3 dark:border-rose-800 dark:bg-rose-900/20">
<div class="flex flex-wrap items-center gap-4 text-sm">
<div class="flex flex-col">
<span class="text-xs text-rose-700 dark:text-rose-300">
{t("Signal")}
</span>
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
{insight.metadata["signal_strength_dbm"]} dBm
</span>
</div>
<div class="flex flex-col">
<span class="text-xs text-rose-700 dark:text-rose-300">
{t("SNR")}
</span>
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
{insight.metadata["snr_db"]} dB
</span>
</div>
<%= if insight.metadata["tx_rate"] && insight.metadata["rx_rate"] do %>
<div class="flex flex-col">
<span class="text-xs text-rose-700 dark:text-rose-300">
{t("TX/RX rate")}
</span>
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
{insight.metadata["tx_rate"]}/{insight.metadata["rx_rate"]} Mbps
</span>
</div>
<% end %>
<%= if insight.metadata["distance_m"] do %>
<div class="flex flex-col">
<span class="text-xs text-rose-700 dark:text-rose-300">
{t("Distance")}
</span>
<span class="font-mono text-base font-semibold text-rose-900 dark:text-rose-100">
{insight.metadata["distance_m"]} m
</span>
</div>
<% end %>
<%= if insight.metadata["cpe_hostname"] do %>
<span class="ml-auto text-xs text-rose-700 dark:text-rose-300">
{insight.metadata["cpe_hostname"]}
</span>
<% end %>
</div>
</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">

View file

@ -1,4 +1,6 @@
2026-05-09 — Frequency-change recommendations + AI-generated insight summaries
2026-05-09 — Smarter recommendations + AI-generated insight summaries
* New recommendation: sector overload detection — APs with low free airtime and active subscribers get flagged with subscriber count, QoE score, and AP model so you can decide whether to split the sector or upgrade the radio
* New recommendation: CPE re-aim candidates — wireless clients with both weak signal and low SNR (the alignment-issue signature) get individual cards with signal, SNR, TX/RX rate, and distance, so each truck-roll candidate is actionable on its own
* 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,118 @@
defmodule Towerops.Recommendations.Rules.CpeRealignTest do
@moduledoc """
Tests the CpeRealign rule, which recommends a truck-roll re-aim for
CPE radios that are simultaneously weak in signal AND low in SNR
(an RF-link problem the existing single-metric alerts don't pinpoint).
"""
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Recommendations.Rules.CpeRealign
alias Towerops.Snmp.WirelessClient
setup do
user = user_fixture()
org = organization_fixture(user.id)
ap_device = device_fixture(%{organization_id: org.id, name: "AP-North"})
%{org: org, ap: ap_device}
end
defp insert_client(org, ap, attrs) do
full =
Map.merge(
%{
organization_id: org.id,
device_id: ap.id,
mac_address: "AA:BB:CC:#{3 |> :crypto.strong_rand_bytes() |> Base.encode16()}",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
},
attrs
)
{:ok, client} =
%WirelessClient{}
|> WirelessClient.changeset(full)
|> Repo.insert()
client
end
test "no insight when no wireless clients exist", %{org: org} do
assert CpeRealign.evaluate(org.id) == []
end
test "no insight when signal is weak but SNR is healthy",
%{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: -82, snr: 25})
assert CpeRealign.evaluate(org.id) == []
end
test "no insight when SNR is low but signal is strong (interference, not alignment)",
%{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: -55, snr: 12})
assert CpeRealign.evaluate(org.id) == []
end
test "insight when both signal and SNR are weak",
%{org: org, ap: ap} do
client = insert_client(org, ap, %{signal_strength: -80, snr: 14, tx_rate: 12, rx_rate: 18})
assert [insight] = CpeRealign.evaluate(org.id)
assert insight.type == "cpe_realign"
assert insight.urgency in ~w(warning critical)
assert insight.device_id == ap.id
md = insight.metadata
assert md["cpe_mac"] == client.mac_address
assert md["signal_strength_dbm"] == -80
assert md["snr_db"] == 14
assert md["tx_rate"] == 12
assert md["rx_rate"] == 18
assert insight.title =~ "Re-aim"
assert insight.title =~ client.mac_address
end
test "critical urgency when signal AND SNR are both severely degraded",
%{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: -90, snr: 8})
assert [insight] = CpeRealign.evaluate(org.id)
assert insight.urgency == "critical"
end
test "skips clients last seen >2h ago (stale data)",
%{org: org, ap: ap} do
old = DateTime.utc_now() |> DateTime.add(-3 * 3600, :second) |> DateTime.truncate(:second)
insert_client(org, ap, %{signal_strength: -85, snr: 10, last_seen_at: old})
assert CpeRealign.evaluate(org.id) == []
end
test "skips clients missing signal_strength or snr",
%{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: nil, snr: 10})
insert_client(org, ap, %{signal_strength: -85, snr: nil})
assert CpeRealign.evaluate(org.id) == []
end
test "scopes to the requested organization", %{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: -85, snr: 10})
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
assert CpeRealign.evaluate(other_org.id) == []
end
test "produces one insight per (ap, cpe) pair", %{org: org, ap: ap} do
insert_client(org, ap, %{signal_strength: -85, snr: 10})
insert_client(org, ap, %{signal_strength: -82, snr: 12})
assert insights = CpeRealign.evaluate(org.id)
assert length(insights) == 2
end
end

View file

@ -0,0 +1,136 @@
defmodule Towerops.Recommendations.Rules.SectorOverloadTest do
@moduledoc """
Tests the SectorOverload rule, which fires when a Preseem-monitored AP
has insufficient free airtime the canonical "reduce load OR upgrade
OR enable AP capacity management" trigger.
"""
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Recommendations.Rules.SectorOverload
setup do
user = user_fixture()
org = organization_fixture(user.id)
device = device_fixture(%{organization_id: org.id, name: "AP-North"})
%{org: org, device: device}
end
defp insert_ap(org, device, attrs) do
base = %{
organization_id: org.id,
preseem_id: "ap-#{System.unique_integer([:positive])}",
name: "Preseem AP",
match_confidence: "auto_ip"
}
base =
case device do
%{id: id} -> Map.put(base, :device_id, id)
_ -> base
end
full = Map.merge(base, attrs)
{:ok, ap} =
%AccessPoint{}
|> AccessPoint.changeset(full)
|> Repo.insert()
ap
end
test "no insight when there are no APs", %{org: org} do
assert SectorOverload.evaluate(org.id) == []
end
test "no insight when AP has plenty of free airtime", %{org: org, device: device} do
insert_ap(org, device, %{
airtime_utilization: 60.0,
subscriber_count: 30,
qoe_score: 85.0
})
assert SectorOverload.evaluate(org.id) == []
end
test "no insight when AP is unmatched (no device_id)", %{org: org} do
insert_ap(org, nil, %{
device_id: nil,
match_confidence: "unmatched",
airtime_utilization: 10.0,
subscriber_count: 60
})
assert SectorOverload.evaluate(org.id) == []
end
test "no insight when subscriber_count is 0 (idle AP)", %{org: org, device: device} do
insert_ap(org, device, %{
airtime_utilization: 10.0,
subscriber_count: 0,
qoe_score: 50.0
})
assert SectorOverload.evaluate(org.id) == []
end
test "warning insight when free airtime is between 15 and 25%",
%{org: org, device: device} do
insert_ap(org, device, %{
name: "AP-North-Preseem",
airtime_utilization: 22.0,
subscriber_count: 45,
qoe_score: 75.0
})
assert [insight] = SectorOverload.evaluate(org.id)
assert insight.type == "sector_overload"
assert insight.urgency == "warning"
assert insight.device_id == device.id
assert insight.metadata["airtime_remaining_pct"] == 22.0
assert insight.metadata["subscriber_count"] == 45
assert insight.title =~ "AP-North-Preseem"
assert insight.title =~ "22.0%"
end
test "critical insight when free airtime < 15%",
%{org: org, device: device} do
insert_ap(org, device, %{
airtime_utilization: 8.0,
subscriber_count: 60,
qoe_score: 60.0
})
assert [insight] = SectorOverload.evaluate(org.id)
assert insight.urgency == "critical"
end
test "critical insight when free airtime < 25 AND QoE is poor",
%{org: org, device: device} do
insert_ap(org, device, %{
airtime_utilization: 22.0,
subscriber_count: 60,
qoe_score: 40.0
})
assert [insight] = SectorOverload.evaluate(org.id)
assert insight.urgency == "critical"
end
test "scopes to the requested organization", %{org: org, device: device} do
insert_ap(org, device, %{
airtime_utilization: 8.0,
subscriber_count: 60
})
other_user = user_fixture()
other_org = organization_fixture(other_user.id)
assert SectorOverload.evaluate(other_org.id) == []
end
end

View file

@ -137,6 +137,72 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
end
end
describe "sector_overload rendering" do
test "shows free airtime, subscriber count, QoE score", %{conn: conn, organization: organization} do
{:ok, _} =
Repo.insert(
Insight.changeset(%Insight{}, %{
organization_id: organization.id,
type: "sector_overload",
urgency: "warning",
status: "active",
channel: "proactive",
source: "preseem",
title: "Sector overload on AP-North: 22.0% airtime free",
metadata: %{
"airtime_remaining_pct" => 22.0,
"subscriber_count" => 45,
"qoe_score" => 75.0,
"ap_model" => "ePMP-3000",
"ap_firmware" => "5.2"
}
})
)
{:ok, _view, html} = live(conn, ~p"/insights")
assert html =~ "Free airtime"
assert html =~ "22.0%"
assert html =~ "45"
assert html =~ "ePMP-3000"
end
end
describe "cpe_realign rendering" do
test "shows signal, SNR, TX/RX rate, distance", %{conn: conn, organization: organization} do
{:ok, _} =
Repo.insert(
Insight.changeset(%Insight{}, %{
organization_id: organization.id,
type: "cpe_realign",
urgency: "warning",
status: "active",
channel: "proactive",
source: "snmp",
title: "Re-aim CPE AA:BB:CC:DD:EE:FF: -82 dBm signal, 14 dB SNR",
metadata: %{
"dedup_key" => "cpe_realign:device-1:AA:BB:CC:DD:EE:FF",
"cpe_mac" => "AA:BB:CC:DD:EE:FF",
"cpe_hostname" => "house-42",
"signal_strength_dbm" => -82,
"snr_db" => 14,
"tx_rate" => 18,
"rx_rate" => 24,
"distance_m" => 1750
}
})
)
{:ok, _view, html} = live(conn, ~p"/insights")
assert html =~ "-82 dBm"
assert html =~ "14 dB"
assert html =~ "18/24 Mbps"
assert html =~ "1750 m"
assert html =~ "house-42"
end
end
describe "ap_frequency_change rendering" do
test "shows current vs recommended channel and the top offender", %{
conn: conn,