From 59cf53144fde39ec5fa8e2bfa28ff638c0227b61 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 18:05:26 -0600 Subject: [PATCH] add preseem context and device detail preseem tab --- lib/towerops/preseem.ex | 70 ++++++ lib/towerops_web/live/device_live/show.ex | 19 ++ .../live/device_live/show.html.heex | 201 ++++++++++++++++++ test/towerops/preseem_test.exs | 174 +++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 lib/towerops/preseem.ex create mode 100644 test/towerops/preseem_test.exs diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex new file mode 100644 index 00000000..c6ee02fb --- /dev/null +++ b/lib/towerops/preseem.ex @@ -0,0 +1,70 @@ +defmodule Towerops.Preseem do + @moduledoc """ + Context for querying Preseem integration data. + """ + + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.SubscriberMetric + alias Towerops.Repo + + @doc "Get the Preseem AP linked to a specific device, or nil." + def get_access_point_for_device(device_id) do + Repo.get_by(AccessPoint, device_id: device_id) + end + + @doc "List all Preseem APs for an organization." + def list_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> order_by(:name) + |> Repo.all() + end + + @doc "List unmatched or ambiguous Preseem APs for an organization." + def list_unmatched_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], ap.match_confidence in ["unmatched", "ambiguous"]) + |> order_by(:name) + |> Repo.all() + end + + @doc "List recent subscriber metrics for a Preseem AP. Default limit 100." + def list_subscriber_metrics(access_point_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + + SubscriberMetric + |> where(preseem_access_point_id: ^access_point_id) + |> order_by(desc: :recorded_at) + |> limit(^limit) + |> Repo.all() + end + + @doc "Link a Preseem AP to a device manually." + def link_access_point(access_point_id, device_id) do + case Repo.get(AccessPoint, access_point_id) do + nil -> + {:error, :not_found} + + ap -> + ap + |> AccessPoint.changeset(%{device_id: device_id, match_confidence: "manual"}) + |> Repo.update() + end + end + + @doc "Unlink a Preseem AP from its device." + def unlink_access_point(access_point_id) do + case Repo.get(AccessPoint, access_point_id) do + nil -> + {:error, :not_found} + + ap -> + ap + |> AccessPoint.changeset(%{device_id: nil, match_confidence: "unmatched"}) + |> Repo.update() + end + end +end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index f7b8863b..9a93f179 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -233,6 +233,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign_ports_data() |> assign_logs_data(device_id) |> assign_backups_data(device_id) + |> assign_preseem_data() end # -- Selective reload helpers (used by handle_info handlers) -- @@ -271,6 +272,7 @@ defmodule ToweropsWeb.DeviceLive.Show do "ports" -> assign_ports_data(socket) "logs" -> assign_logs_data(socket, device_id) "backups" -> assign_backups_data(socket, device_id) + "preseem" -> assign_preseem_data(socket) # neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base _ -> socket end @@ -429,6 +431,23 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:selected_backup_ids, MapSet.new()) end + # Preseem tab data. + defp assign_preseem_data(socket) do + device = socket.assigns.device + preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id) + + metrics = + if preseem_ap do + Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50) + else + [] + end + + socket + |> assign(:preseem_access_point, preseem_ap) + |> assign(:preseem_metrics, metrics) + end + defp get_available_firmware(nil), do: nil defp get_available_firmware(snmp_device) do diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 986557f0..a0c643d8 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -228,6 +228,25 @@ <% end %> + <%= if @preseem_access_point do %> + <.link + patch={~p"/devices/#{@device.id}?tab=preseem"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if(@active_tab == "preseem", + do: "border-blue-500 text-blue-600 dark:text-blue-400", + else: + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + ) + ]} + > + + <.icon name="hero-signal" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" /> + Preseem + + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=checks"} class={[ @@ -2184,6 +2203,188 @@ + <% "preseem" -> %> + <%= if @preseem_access_point do %> +
+ <%!-- Score cards --%> +
+
+
QoE Score
+
+ {if @preseem_access_point.qoe_score, + do: Float.round(@preseem_access_point.qoe_score, 1), + else: "---"} +
+
+
+
+ Capacity Score +
+
+ {if @preseem_access_point.capacity_score, + do: Float.round(@preseem_access_point.capacity_score, 1), + else: "---"} +
+
+
+
RF Score
+
+ {if @preseem_access_point.rf_score, + do: Float.round(@preseem_access_point.rf_score, 1), + else: "---"} +
+
+
+ + <%!-- Details card --%> +
+
+

+ Access Point Details +

+
+
+
+
+
Name
+
+ {@preseem_access_point.name || "---"} +
+
+
+
+ Preseem ID +
+
+ {@preseem_access_point.preseem_id} +
+
+
+
+ Subscribers +
+
+ {@preseem_access_point.subscriber_count || "---"} +
+
+
+
+ Busy Hours +
+
+ <%= if @preseem_access_point.busy_hours do %> + {@preseem_access_point.busy_hours} hrs/day + <% else %> + --- + <% end %> +
+
+
+
+ Airtime Utilization +
+
+ <%= if @preseem_access_point.airtime_utilization do %> + {Float.round(@preseem_access_point.airtime_utilization, 1)}% + <% else %> + --- + <% end %> +
+
+
+
+ Match Type +
+
+ {@preseem_access_point.match_confidence} +
+
+
+
+
+ + <%!-- Recent metrics table --%> + <%= if @preseem_metrics != [] do %> +
+
+

+ Recent QoE Metrics +

+
+
+ + + + + + + + + + + + + + <%= for metric <- @preseem_metrics do %> + + + + + + + + + + <% end %> + +
+ Time + + Latency (ms) + + P95 Latency + + Jitter (ms) + + Loss (%) + + Throughput + + Subscribers +
+ {Calendar.strftime(metric.recorded_at, "%Y-%m-%d %H:%M")} + + {if metric.avg_latency, + do: Float.round(metric.avg_latency, 1), + else: "---"} + + {if metric.p95_latency, + do: Float.round(metric.p95_latency, 1), + else: "---"} + + {if metric.avg_jitter, + do: Float.round(metric.avg_jitter, 1), + else: "---"} + + {if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"} + + {if metric.avg_throughput, + do: "#{Float.round(metric.avg_throughput, 1)} Mbps", + else: "---"} + + {metric.subscriber_count || "---"} +
+
+
+ <% end %> +
+ <% else %> +
+

+ No Preseem data linked to this device. +

+
+ <% end %> <% _ -> %>

Tab not found

diff --git a/test/towerops/preseem_test.exs b/test/towerops/preseem_test.exs new file mode 100644 index 00000000..491effea --- /dev/null +++ b/test/towerops/preseem_test.exs @@ -0,0 +1,174 @@ +defmodule Towerops.PreseemTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.SubscriberMetric + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{organization: org} + end + + defp insert_access_point!(org, attrs \\ %{}) do + default = %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP" + } + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + ap + end + + defp insert_metric!(ap, attrs) do + default = %{ + preseem_access_point_id: ap.id, + recorded_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, metric} = + %SubscriberMetric{} + |> SubscriberMetric.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + metric + end + + describe "get_access_point_for_device/1" do + test "returns nil when no AP linked to device" do + assert is_nil(Preseem.get_access_point_for_device(Ecto.UUID.generate())) + end + + test "returns AP when linked to device", %{organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + device_id: device.id, + match_confidence: "auto_ip" + }) + + result = Preseem.get_access_point_for_device(device.id) + assert result.id == ap.id + end + end + + describe "list_access_points/1" do + test "returns all APs for an organization ordered by name", %{organization: org} do + insert_access_point!(org, %{name: "Bravo AP"}) + insert_access_point!(org, %{name: "Alpha AP"}) + + aps = Preseem.list_access_points(org.id) + assert length(aps) == 2 + assert Enum.map(aps, & &1.name) == ["Alpha AP", "Bravo AP"] + end + + test "does not return APs from other organizations", %{organization: org} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + insert_access_point!(org, %{name: "Our AP"}) + insert_access_point!(other_org, %{name: "Their AP"}) + + aps = Preseem.list_access_points(org.id) + assert length(aps) == 1 + assert hd(aps).name == "Our AP" + end + end + + describe "list_unmatched_access_points/1" do + test "returns only unmatched and ambiguous APs", %{organization: org} do + insert_access_point!(org, %{name: "Matched", match_confidence: "auto_mac"}) + insert_access_point!(org, %{name: "Unmatched", match_confidence: "unmatched"}) + insert_access_point!(org, %{name: "Ambiguous", match_confidence: "ambiguous"}) + insert_access_point!(org, %{name: "Manual", match_confidence: "manual"}) + + unmatched = Preseem.list_unmatched_access_points(org.id) + assert length(unmatched) == 2 + names = unmatched |> Enum.map(& &1.name) |> Enum.sort() + assert names == ["Ambiguous", "Unmatched"] + end + end + + describe "list_subscriber_metrics/2" do + test "returns metrics ordered by recorded_at desc", %{organization: org} do + ap = insert_access_point!(org) + now = DateTime.truncate(DateTime.utc_now(), :second) + old = DateTime.add(now, -3600, :second) + + insert_metric!(ap, %{recorded_at: old, avg_latency: 10.0}) + insert_metric!(ap, %{recorded_at: now, avg_latency: 20.0}) + + metrics = Preseem.list_subscriber_metrics(ap.id) + assert length(metrics) == 2 + assert hd(metrics).avg_latency == 20.0 + end + + test "respects limit option", %{organization: org} do + ap = insert_access_point!(org) + now = DateTime.truncate(DateTime.utc_now(), :second) + + for i <- 1..5 do + insert_metric!(ap, %{ + recorded_at: DateTime.add(now, -i, :second), + avg_latency: i * 1.0 + }) + end + + metrics = Preseem.list_subscriber_metrics(ap.id, limit: 2) + assert length(metrics) == 2 + end + + test "defaults to 100 limit", %{organization: org} do + ap = insert_access_point!(org) + # Just verify the function works with default + metrics = Preseem.list_subscriber_metrics(ap.id) + assert is_list(metrics) + end + end + + describe "link_access_point/2" do + test "links AP to device with manual confidence", %{organization: org} do + ap = insert_access_point!(org, %{match_confidence: "unmatched"}) + device = device_fixture(%{organization_id: org.id}) + + assert {:ok, linked} = Preseem.link_access_point(ap.id, device.id) + assert linked.device_id == device.id + assert linked.match_confidence == "manual" + end + + test "returns error for non-existent AP" do + assert {:error, :not_found} = Preseem.link_access_point(Ecto.UUID.generate(), Ecto.UUID.generate()) + end + end + + describe "unlink_access_point/1" do + test "unlinks AP and resets to unmatched", %{organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + device_id: device.id, + match_confidence: "manual" + }) + + assert {:ok, unlinked} = Preseem.unlink_access_point(ap.id) + assert is_nil(unlinked.device_id) + assert unlinked.match_confidence == "unmatched" + end + + test "returns error for non-existent AP" do + assert {:error, :not_found} = Preseem.unlink_access_point(Ecto.UUID.generate()) + end + end +end