diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 91bb0986..963b4cfc 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,17 @@ +2026-05-09 +feat(insights): OpticalRxLow rule for fiber transceivers + Towerops.Recommendations.Rules.OpticalRxLow — flags fiber + transceivers (snmp_transceivers) whose latest received optical + power is below operator thresholds: -28 dBm warning, -32 dBm + critical. Reads only readings within the last hour to avoid + false positives from stale DOM snapshots. One insight per + (device, port) via metadata.dedup_key. + Insight.@valid_types extended with optical_rx_low. + LLM hint walks the operator through the standard ladder: clean the + connector, inspect bend radius, verify splices, then replace. + UI: cyan card with port, Rx power, Tx power, transceiver type, and + wavelength. + 2026-05-09 feat(insights): UpstreamDegradation multi-AP correlation rule Towerops.Recommendations.Rules.UpstreamDegradation — when 50%+ of diff --git a/lib/towerops/llm/insight_prompt.ex b/lib/towerops/llm/insight_prompt.ex index bfe50e97..51a51b76 100644 --- a/lib/towerops/llm/insight_prompt.ex +++ b/lib/towerops/llm/insight_prompt.ex @@ -109,6 +109,16 @@ defmodule Towerops.LLM.InsightPrompt do Tell the operator to investigate the backhaul / upstream path FIRST and to NOT chase per-AP RF symptoms. List the affected APs. """, + "optical_rx_low" => """ + Rule context: a fiber transceiver shows received optical power + below the actionable threshold. Metadata fields: + port, rx_power_dbm, tx_power_dbm, transceiver_type, vendor_name, + wavelength_nm, warning_threshold_dbm, critical_threshold_dbm. + Recommend (in order): clean the SC/LC connector with a one-click + cleaner; inspect the patch panel for kinks or tight bends; verify + the splice; replace the transceiver if Rx still low. Mention the + specific port and dBm value. + """, "device_overheating" => """ Rule context: a device has at least one temperature sensor above 65 °C (warning) or 75 °C (critical). Metadata fields: diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index fbfb608c..8cbd8eeb 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 sector_overload cpe_realign own_fleet_channel_conflict device_overheating upstream_degradation) + @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 own_fleet_channel_conflict device_overheating upstream_degradation optical_rx_low) @valid_urgencies ~w(critical warning info) @valid_statuses ~w(active dismissed resolved) @valid_channels ~w(proactive contextual passive) diff --git a/lib/towerops/recommendations/rules/optical_rx_low.ex b/lib/towerops/recommendations/rules/optical_rx_low.ex new file mode 100644 index 00000000..432bc4d8 --- /dev/null +++ b/lib/towerops/recommendations/rules/optical_rx_low.ex @@ -0,0 +1,103 @@ +defmodule Towerops.Recommendations.Rules.OpticalRxLow do + @moduledoc """ + Flags fiber transceivers whose latest received optical power is below + operator-actionable thresholds. Catches dirty connectors, splice + degradation, fiber bend-loss, and aging optics before they fail + outright. + + Thresholds (typical 1310/1550 nm SFP/SFP+ operating range): + * `< -28 dBm` → warning + * `< -32 dBm` → critical (link may be unstable; below most receiver + sensitivity floors) + + Reads only readings within the last hour to avoid false positives from + stale data when DOM polling pauses. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Transceiver + alias Towerops.Snmp.TransceiverReading + + @warning_threshold_dbm -28.0 + @critical_threshold_dbm -32.0 + @lookback_seconds 3600 + + @spec evaluate(Ecto.UUID.t()) :: [map()] + 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_low_rx(cutoff) + |> Enum.map(&build_insight(&1, organization_id)) + end + + defp load_low_rx(organization_id, cutoff) do + # Latest reading per transceiver in last hour, with rx below threshold + latest_per_xcvr = + from(r in TransceiverReading, + where: r.measured_at >= ^cutoff and not is_nil(r.rx_power_dbm), + distinct: r.transceiver_id, + order_by: [desc: r.measured_at], + select: r + ) + + Repo.all( + from(r in subquery(latest_per_xcvr), + join: x in Transceiver, + on: x.id == r.transceiver_id, + join: sd in SnmpDevice, + on: sd.id == x.snmp_device_id, + join: d in Device, + on: d.id == sd.device_id, + where: d.organization_id == ^organization_id and r.rx_power_dbm <= ^@warning_threshold_dbm, + select: {d, x, r} + ) + ) + end + + defp build_insight({device, xcvr, reading}, organization_id) do + rx = reading.rx_power_dbm + + %{ + organization_id: organization_id, + device_id: device.id, + type: "optical_rx_low", + urgency: urgency_for(rx), + channel: "proactive", + source: "snmp", + title: + "Optical Rx low on #{device.name || "device"} #{xcvr.port_index}: " <> + "#{format_dbm(rx)}", + description: + "Transceiver #{xcvr.port_index} (#{xcvr.transceiver_type || "SFP"}) " <> + "shows received optical power of #{format_dbm(rx)} — below the " <> + "actionable threshold. Inspect connector cleanliness, fiber " <> + "bend-radius, and patch-panel splices.", + metadata: %{ + "dedup_key" => "optical_rx_low:#{device.id}:#{xcvr.port_index}", + "port" => xcvr.port_index, + "rx_power_dbm" => rx, + "tx_power_dbm" => reading.tx_power_dbm, + "transceiver_type" => xcvr.transceiver_type, + "vendor_name" => xcvr.vendor_name, + "wavelength_nm" => xcvr.wavelength_nm, + "warning_threshold_dbm" => @warning_threshold_dbm, + "critical_threshold_dbm" => @critical_threshold_dbm, + "measured_at" => reading.measured_at && DateTime.to_iso8601(reading.measured_at) + } + } + end + + defp urgency_for(rx) when is_number(rx) and rx <= @critical_threshold_dbm, do: "critical" + defp urgency_for(_), do: "warning" + + defp format_dbm(rx) when is_number(rx), do: "#{Float.round(rx / 1.0, 1)} dBm" + defp format_dbm(_), do: "?dBm" +end diff --git a/lib/towerops/workers/recommendations_run_worker.ex b/lib/towerops/workers/recommendations_run_worker.ex index 411fc259..9b84eccd 100644 --- a/lib/towerops/workers/recommendations_run_worker.ex +++ b/lib/towerops/workers/recommendations_run_worker.ex @@ -20,6 +20,7 @@ defmodule Towerops.Workers.RecommendationsRunWorker do alias Towerops.Recommendations.Rules.CpeRealign alias Towerops.Recommendations.Rules.DeviceOverheating alias Towerops.Recommendations.Rules.FrequencyChange + alias Towerops.Recommendations.Rules.OpticalRxLow alias Towerops.Recommendations.Rules.OwnFleetChannelConflict alias Towerops.Recommendations.Rules.SectorOverload alias Towerops.Recommendations.Rules.UpstreamDegradation @@ -35,7 +36,8 @@ defmodule Towerops.Workers.RecommendationsRunWorker do OwnFleetChannelConflict, SectorOverload, CpeRealign, - DeviceOverheating + DeviceOverheating, + OpticalRxLow ] @impl Oban.Worker diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 91def05f..e067d1bd 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -480,6 +480,47 @@ <% end %> + <%= if insight.type == "optical_rx_low" do %> +