From 91f23416e4f7610382ec22913a172672be806acc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 9 May 2026 17:07:15 -0500 Subject: [PATCH] feat(insights): SectorOverload + CpeRealign recommendation rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 4 + CHANGELOG.txt | 22 +++ k8s/README.md | 23 +-- lib/towerops/preseem/insight.ex | 2 +- lib/towerops/preseem/insights.ex | 11 ++ .../recommendations/rules/cpe_realign.ex | 97 +++++++++++++ .../recommendations/rules/sector_overload.ex | 101 +++++++++++++ .../workers/recommendations_run_worker.ex | 4 +- .../live/insights_live/index.html.heex | 88 ++++++++++++ priv/static/changelog.txt | 4 +- .../rules/cpe_realign_test.exs | 118 +++++++++++++++ .../rules/sector_overload_test.exs | 136 ++++++++++++++++++ .../live/insights_live_events_test.exs | 66 +++++++++ 13 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 lib/towerops/recommendations/rules/cpe_realign.ex create mode 100644 lib/towerops/recommendations/rules/sector_overload.ex create mode 100644 test/towerops/recommendations/rules/cpe_realign_test.exs create mode 100644 test/towerops/recommendations/rules/sector_overload_test.exs diff --git a/.gitignore b/.gitignore index b21198f4..b59e5016 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 2a836704..98177c61 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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) diff --git a/k8s/README.md b/k8s/README.md index 02b60167..38626662 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -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="" \ - --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. diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 3c860b27..8e5cc045 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap 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) diff --git a/lib/towerops/preseem/insights.ex b/lib/towerops/preseem/insights.ex index 28055c01..861b739d 100644 --- a/lib/towerops/preseem/insights.ex +++ b/lib/towerops/preseem/insights.ex @@ -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 diff --git a/lib/towerops/recommendations/rules/cpe_realign.ex b/lib/towerops/recommendations/rules/cpe_realign.ex new file mode 100644 index 00000000..be0288c8 --- /dev/null +++ b/lib/towerops/recommendations/rules/cpe_realign.ex @@ -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 diff --git a/lib/towerops/recommendations/rules/sector_overload.ex b/lib/towerops/recommendations/rules/sector_overload.ex new file mode 100644 index 00000000..9fe080d9 --- /dev/null +++ b/lib/towerops/recommendations/rules/sector_overload.ex @@ -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 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 diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex index 3bef636f..cc55e51c 100644 --- a/lib/towerops/workers/recommendations_run_worker.ex +++ b/lib/towerops/workers/recommendations_run_worker.ex @@ -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 diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 337367ed..43a4bdf3 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -392,6 +392,94 @@ <% end %> + <%= if insight.type == "sector_overload" do %> +
+
+
+ + {t("Free airtime")} + + + {Float.round(insight.metadata["airtime_remaining_pct"] / 1.0, 1)}% + +
+
+ + {t("Subscribers")} + + + {insight.metadata["subscriber_count"]} + +
+ <%= if insight.metadata["qoe_score"] do %> +
+ + {t("QoE score")} + + + {Float.round(insight.metadata["qoe_score"] / 1.0, 1)} + +
+ <% end %> + <%= if insight.metadata["ap_model"] do %> + + {insight.metadata["ap_model"]} + {insight.metadata["ap_firmware"] && + "(" <> insight.metadata["ap_firmware"] <> ")"} + + <% end %> +
+
+ <% end %> + + <%= if insight.type == "cpe_realign" do %> +
+
+
+ + {t("Signal")} + + + {insight.metadata["signal_strength_dbm"]} dBm + +
+
+ + {t("SNR")} + + + {insight.metadata["snr_db"]} dB + +
+ <%= if insight.metadata["tx_rate"] && insight.metadata["rx_rate"] do %> +
+ + {t("TX/RX rate")} + + + {insight.metadata["tx_rate"]}/{insight.metadata["rx_rate"]} Mbps + +
+ <% end %> + <%= if insight.metadata["distance_m"] do %> +
+ + {t("Distance")} + + + {insight.metadata["distance_m"]} m + +
+ <% end %> + <%= if insight.metadata["cpe_hostname"] do %> + + {insight.metadata["cpe_hostname"]} + + <% end %> +
+
+ <% end %> + <%= if insight.type == "ap_frequency_change" do %>
diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index f70dd7fd..7201f486 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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 diff --git a/test/towerops/recommendations/rules/cpe_realign_test.exs b/test/towerops/recommendations/rules/cpe_realign_test.exs new file mode 100644 index 00000000..a15517e8 --- /dev/null +++ b/test/towerops/recommendations/rules/cpe_realign_test.exs @@ -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 diff --git a/test/towerops/recommendations/rules/sector_overload_test.exs b/test/towerops/recommendations/rules/sector_overload_test.exs new file mode 100644 index 00000000..1d747488 --- /dev/null +++ b/test/towerops/recommendations/rules/sector_overload_test.exs @@ -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 diff --git a/test/towerops_web/live/insights_live_events_test.exs b/test/towerops_web/live/insights_live_events_test.exs index 781618f8..fd4af27b 100644 --- a/test/towerops_web/live/insights_live_events_test.exs +++ b/test/towerops_web/live/insights_live_events_test.exs @@ -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,