- Replace filter tabs with pill buttons (All/Unmatched/Matched) - Add IP-based match suggestions when AP IP matches a TowerOps device - Show "Match found" badge on rows with suggestions; sort suggestions first - Show suggestion chips in inline linking row for one-click linking - Make unlinked AP rows clickable to open linking panel - Make IP addresses clickable http links - Improve linking row layout and descriptive copy - Replace bare dash with "Not linked" pill in Linked Device column
194 lines
5.2 KiB
Elixir
194 lines
5.2 KiB
Elixir
defmodule ToweropsWeb.Org.PreseemDevicesLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Devices
|
|
alias Towerops.Preseem
|
|
alias Towerops.Repo
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
org = socket.assigns.current_scope.organization
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Preseem Devices"))
|
|
|> assign(:organization, org)
|
|
|> assign(:filter, "all")
|
|
|> assign(:linking_ap_id, nil)
|
|
|> assign(:device_search, "")
|
|
|> assign(:search_results, [])
|
|
|> assign(:suggestions, %{})
|
|
|> load_access_points()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
filter =
|
|
case params["filter"] do
|
|
f when f in ~w(all unmatched matched) -> f
|
|
_ -> "all"
|
|
end
|
|
|
|
{:noreply, socket |> assign(:filter, filter) |> load_access_points()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter", %{"filter" => filter}, socket) do
|
|
org = socket.assigns.organization
|
|
|
|
{:noreply,
|
|
push_patch(socket,
|
|
to: ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=#{filter}"
|
|
)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("start_link", %{"ap-id" => ap_id}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:linking_ap_id, ap_id)
|
|
|> assign(:device_search, "")
|
|
|> assign(:search_results, [])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("cancel_link", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:linking_ap_id, nil)
|
|
|> assign(:device_search, "")
|
|
|> assign(:search_results, [])}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search_devices", %{"query" => query}, socket) do
|
|
org = socket.assigns.organization
|
|
|
|
results =
|
|
if String.length(query) >= 2 do
|
|
Devices.search_devices(org.id, query)
|
|
else
|
|
[]
|
|
end
|
|
|
|
{:noreply, socket |> assign(:device_search, query) |> assign(:search_results, results)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("link_device", %{"ap-id" => ap_id, "device-id" => device_id}, socket) do
|
|
case Preseem.link_access_point(ap_id, device_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:linking_ap_id, nil)
|
|
|> assign(:device_search, "")
|
|
|> assign(:search_results, [])
|
|
|> load_access_points()
|
|
|> put_flash(:info, t("Device linked successfully"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to link device"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("unlink_device", %{"ap-id" => ap_id}, socket) do
|
|
case Preseem.unlink_access_point(ap_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> load_access_points()
|
|
|> put_flash(:info, t("Device unlinked"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to unlink device"))}
|
|
end
|
|
end
|
|
|
|
defp match_confidence_classes(confidence) do
|
|
case confidence do
|
|
"unmatched" ->
|
|
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
|
|
"ambiguous" ->
|
|
"bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
|
|
|
|
"manual" ->
|
|
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
|
|
_ ->
|
|
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
end
|
|
end
|
|
|
|
defp humanize_confidence(confidence) do
|
|
case confidence do
|
|
"auto_mac" -> "Auto (MAC)"
|
|
"auto_ip" -> "Auto (IP)"
|
|
"auto_hostname" -> "Auto (Hostname)"
|
|
"manual" -> "Manual"
|
|
"ambiguous" -> "Ambiguous"
|
|
"unmatched" -> "Unmatched"
|
|
other -> String.capitalize(other || "unknown")
|
|
end
|
|
end
|
|
|
|
defp load_access_points(socket) do
|
|
org_id = socket.assigns.organization.id
|
|
filter = socket.assigns.filter
|
|
|
|
access_points =
|
|
case filter do
|
|
"unmatched" -> Preseem.list_unmatched_access_points(org_id)
|
|
"matched" -> Preseem.list_matched_access_points(org_id)
|
|
_ -> Preseem.list_access_points(org_id)
|
|
end
|
|
|
|
access_points = Repo.preload(access_points, :device)
|
|
|
|
suggestions = compute_suggestions(access_points, org_id)
|
|
|
|
sorted =
|
|
Enum.sort_by(access_points, fn ap ->
|
|
cond do
|
|
Map.has_key?(suggestions, ap.id) -> 0
|
|
is_nil(ap.device_id) -> 1
|
|
true -> 2
|
|
end
|
|
end)
|
|
|
|
socket
|
|
|> assign(:access_points, sorted)
|
|
|> assign(:suggestions, suggestions)
|
|
end
|
|
|
|
defp compute_suggestions(access_points, org_id) do
|
|
unmatched_with_ip =
|
|
Enum.filter(access_points, &(is_nil(&1.device_id) and &1.ip_address not in [nil, ""]))
|
|
|
|
if unmatched_with_ip == [] do
|
|
%{}
|
|
else
|
|
linked_device_ids =
|
|
access_points
|
|
|> Enum.filter(&(not is_nil(&1.device_id)))
|
|
|> MapSet.new(& &1.device_id)
|
|
|
|
unlinked_by_ip =
|
|
org_id
|
|
|> Devices.list_organization_devices()
|
|
|> Enum.filter(&(not MapSet.member?(linked_device_ids, &1.id) and &1.ip_address not in [nil, ""]))
|
|
|> Enum.group_by(&to_string(&1.ip_address))
|
|
|
|
Enum.reduce(unmatched_with_ip, %{}, &add_ap_suggestion(&1, &2, unlinked_by_ip))
|
|
end
|
|
end
|
|
|
|
defp add_ap_suggestion(ap, acc, unlinked_by_ip) do
|
|
case Map.get(unlinked_by_ip, ap.ip_address, []) do
|
|
[] -> acc
|
|
matches -> Map.put(acc, ap.id, Enum.map(matches, &%{device: &1, match_type: "ip"}))
|
|
end
|
|
end
|
|
end
|