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:
parent
f41f5fa1aa
commit
91f23416e4
13 changed files with 665 additions and 11 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
97
lib/towerops/recommendations/rules/cpe_realign.ex
Normal file
97
lib/towerops/recommendations/rules/cpe_realign.ex
Normal 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
|
||||
101
lib/towerops/recommendations/rules/sector_overload.ex
Normal file
101
lib/towerops/recommendations/rules/sector_overload.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
118
test/towerops/recommendations/rules/cpe_realign_test.exs
Normal file
118
test/towerops/recommendations/rules/cpe_realign_test.exs
Normal 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
|
||||
136
test/towerops/recommendations/rules/sector_overload_test.exs
Normal file
136
test/towerops/recommendations/rules/sector_overload_test.exs
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue