add preseem context and device detail preseem tab
This commit is contained in:
parent
b04d25e607
commit
59cf53144f
4 changed files with 464 additions and 0 deletions
70
lib/towerops/preseem.ex
Normal file
70
lib/towerops/preseem.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
174
test/towerops/preseem_test.exs
Normal file
174
test/towerops/preseem_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue