add preseem context and device detail preseem tab

This commit is contained in:
Graham McIntire 2026-02-12 18:05:26 -06:00
parent b04d25e607
commit 59cf53144f
No known key found for this signature in database
4 changed files with 464 additions and 0 deletions

70
lib/towerops/preseem.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -228,6 +228,25 @@
</.link>
<% 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"
)
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-signal" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" />
Preseem
</span>
</.link>
<% end %>
<.link
patch={~p"/devices/#{@device.id}?tab=checks"}
class={[
@ -2184,6 +2203,188 @@
</div>
</div>
</div>
<% "preseem" -> %>
<%= if @preseem_access_point do %>
<div class="space-y-6">
<%!-- Score cards --%>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">QoE Score</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.qoe_score,
do: Float.round(@preseem_access_point.qoe_score, 1),
else: "---"}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
Capacity Score
</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.capacity_score,
do: Float.round(@preseem_access_point.capacity_score, 1),
else: "---"}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">RF Score</div>
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
{if @preseem_access_point.rf_score,
do: Float.round(@preseem_access_point.rf_score, 1),
else: "---"}
</div>
</div>
</div>
<%!-- Details card --%>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Access Point Details
</h3>
</div>
<div class="px-4 py-3">
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.name || "---"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Preseem ID
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.preseem_id}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Subscribers
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.subscriber_count || "---"}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Busy Hours
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
<%= if @preseem_access_point.busy_hours do %>
{@preseem_access_point.busy_hours} hrs/day
<% else %>
---
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Airtime Utilization
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
<%= if @preseem_access_point.airtime_utilization do %>
{Float.round(@preseem_access_point.airtime_utilization, 1)}%
<% else %>
---
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
Match Type
</dt>
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
{@preseem_access_point.match_confidence}
</dd>
</div>
</dl>
</div>
</div>
<%!-- Recent metrics table --%>
<%= if @preseem_metrics != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Recent QoE Metrics
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead>
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Time
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Latency (ms)
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
P95 Latency
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Jitter (ms)
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Loss (%)
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Throughput
</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
Subscribers
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for metric <- @preseem_metrics do %>
<tr>
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">
{Calendar.strftime(metric.recorded_at, "%Y-%m-%d %H:%M")}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_latency,
do: Float.round(metric.avg_latency, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.p95_latency,
do: Float.round(metric.p95_latency, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_jitter,
do: Float.round(metric.avg_jitter, 1),
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{if metric.avg_throughput,
do: "#{Float.round(metric.avg_throughput, 1)} Mbps",
else: "---"}
</td>
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
{metric.subscriber_count || "---"}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
<p class="text-sm text-gray-500 dark:text-gray-400">
No Preseem data linked to this device.
</p>
</div>
<% end %>
<% _ -> %>
<div class="text-center py-12">
<p class="text-gray-500 dark:text-gray-400">Tab not found</p>

View file

@ -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