add network insights feed page with filters and bulk dismiss

This commit is contained in:
Graham McIntire 2026-02-13 09:00:34 -06:00
parent 62b8ec7afd
commit 9ccea8daaf
No known key found for this signature in database
5 changed files with 701 additions and 0 deletions

View file

@ -85,6 +85,14 @@
>
Manage Devices →
</.link>
<.link
navigate={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights"
}
class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Network Insights →
</.link>
</div>
<% end %>
</div>

View file

@ -0,0 +1,163 @@
defmodule ToweropsWeb.Org.PreseemInsightsLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Preseem
alias Towerops.Repo
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
{:ok,
socket
|> assign(:organization, org)
|> assign(:selected_ids, MapSet.new())}
end
@impl true
def handle_params(params, _url, socket) do
filter_type = params["type"]
filter_urgency = params["urgency"]
filter_status = params["status"] || "active"
{:noreply,
socket
|> assign(:filter_type, filter_type)
|> assign(:filter_urgency, filter_urgency)
|> assign(:filter_status, filter_status)
|> assign(:selected_ids, MapSet.new())
|> load_insights()}
end
@impl true
def handle_event("filter", params, socket) do
org = socket.assigns.organization
query_params = %{}
query_params =
if params["type"] && params["type"] != "",
do: Map.put(query_params, "type", params["type"]),
else: query_params
query_params =
if params["urgency"] && params["urgency"] != "",
do: Map.put(query_params, "urgency", params["urgency"]),
else: query_params
query_params =
if params["status"] && params["status"] != "",
do: Map.put(query_params, "status", params["status"]),
else: query_params
{:noreply,
push_patch(socket,
to: ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?#{query_params}"
)}
end
@impl true
def handle_event("dismiss", %{"id" => id}, socket) do
case Preseem.dismiss_insight(id) do
{:ok, _} ->
{:noreply,
socket
|> load_insights()
|> put_flash(:info, "Insight dismissed")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to dismiss")}
end
end
@impl true
def handle_event("toggle_select", %{"id" => id}, socket) do
selected = socket.assigns.selected_ids
selected =
if MapSet.member?(selected, id),
do: MapSet.delete(selected, id),
else: MapSet.put(selected, id)
{:noreply, assign(socket, :selected_ids, selected)}
end
@impl true
def handle_event("select_all", _params, socket) do
ids = MapSet.new(socket.assigns.insights, & &1.id)
{:noreply, assign(socket, :selected_ids, ids)}
end
@impl true
def handle_event("deselect_all", _params, socket) do
{:noreply, assign(socket, :selected_ids, MapSet.new())}
end
@impl true
def handle_event("bulk_dismiss", _params, socket) do
ids = MapSet.to_list(socket.assigns.selected_ids)
if ids == [] do
{:noreply, socket}
else
case Preseem.dismiss_insights(ids) do
{:ok, count} ->
{:noreply,
socket
|> assign(:selected_ids, MapSet.new())
|> load_insights()
|> put_flash(:info, "#{count} insight(s) dismissed")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to dismiss insights")}
end
end
end
defp load_insights(socket) do
org_id = socket.assigns.organization.id
opts = [status: socket.assigns.filter_status]
opts =
if socket.assigns.filter_type,
do: Keyword.put(opts, :type, socket.assigns.filter_type),
else: opts
opts =
if socket.assigns.filter_urgency,
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
else: opts
insights =
org_id
|> Preseem.list_insights(opts)
|> Repo.preload([:preseem_access_point, :device])
assign(socket, :insights, insights)
end
defp urgency_classes(urgency) do
case urgency do
"critical" ->
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
"warning" ->
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
"info" ->
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
_ ->
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
end
end
defp selected?(selected_ids, id) do
MapSet.member?(selected_ids, id)
end
defp any_selected?(selected_ids) do
MapSet.size(selected_ids) > 0
end
end

View file

@ -0,0 +1,303 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}/settings/integrations"}
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations
</.link>
</div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Network Insights
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Proactive network health observations generated from Preseem data analysis.
</p>
</div>
</div>
</div>
<%!-- Filter bar --%>
<div class="mt-6 flex flex-wrap items-center gap-4 border-b border-gray-200 pb-4 dark:border-white/10">
<%!-- Status filter tabs --%>
<nav class="flex space-x-4">
<.link
id="filter-active"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?status=active"
}
class={[
"rounded-md px-3 py-1.5 text-sm font-medium",
if(@filter_status == "active",
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400",
else:
"text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
)
]}
>
Active
</.link>
<.link
id="filter-dismissed"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?status=dismissed"
}
class={[
"rounded-md px-3 py-1.5 text-sm font-medium",
if(@filter_status == "dismissed",
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400",
else:
"text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-300"
)
]}
>
Dismissed
</.link>
</nav>
<%!-- Urgency filter --%>
<div class="flex items-center gap-2 border-l border-gray-200 pl-4 dark:border-white/10">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Urgency:</span>
<.link
id="filter-urgency-all"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status}}"
}
class={[
"rounded-md px-2 py-1 text-xs font-medium",
if(is_nil(@filter_urgency),
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
All
</.link>
<.link
id="filter-urgency-critical"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "critical"}}"
}
class={[
"rounded-md px-2 py-1 text-xs font-medium",
if(@filter_urgency == "critical",
do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
Critical
</.link>
<.link
id="filter-urgency-warning"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "warning"}}"
}
class={[
"rounded-md px-2 py-1 text-xs font-medium",
if(@filter_urgency == "warning",
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
Warning
</.link>
<.link
id="filter-urgency-info"
patch={
~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "info"}}"
}
class={[
"rounded-md px-2 py-1 text-xs font-medium",
if(@filter_urgency == "info",
do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
Info
</.link>
</div>
</div>
<%!-- Bulk actions bar --%>
<%= if any_selected?(@selected_ids) do %>
<div
id="bulk-actions"
class="mt-4 flex items-center gap-3 rounded-lg bg-indigo-50 px-4 py-3 dark:bg-indigo-900/20"
>
<span class="text-sm font-medium text-indigo-700 dark:text-indigo-300">
{MapSet.size(@selected_ids)} selected
</span>
<button
type="button"
id="deselect-all-btn"
phx-click="deselect_all"
class="text-sm text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Deselect All
</button>
<button
type="button"
id="bulk-dismiss-btn"
phx-click="bulk_dismiss"
class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
Dismiss Selected
</button>
</div>
<% end %>
<%!-- Insights list --%>
<div class="mt-6" id="insights-list">
<%= if @insights == [] do %>
<div
id="empty-state"
class="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-700"
>
<.icon
name="hero-light-bulb"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
/>
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No insights found
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= cond do %>
<% @filter_status == "dismissed" -> %>
No dismissed insights.
<% @filter_urgency != nil -> %>
No {@filter_urgency} insights right now.
<% true -> %>
No active insights. Your network is looking healthy!
<% end %>
</p>
</div>
<% else %>
<%!-- Select all bar --%>
<div class="mb-3 flex items-center gap-2">
<button
type="button"
id="select-all-btn"
phx-click="select_all"
class="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
Select All
</button>
</div>
<div class="space-y-3">
<div
:for={insight <- @insights}
id={"insight-#{insight.id}"}
class={[
"rounded-lg border bg-white p-4 transition-shadow hover:shadow-md dark:bg-gray-800/50",
if(selected?(@selected_ids, insight.id),
do:
"border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-600 dark:ring-indigo-800",
else: "border-gray-200 dark:border-white/10"
)
]}
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-3">
<%!-- Checkbox --%>
<button
type="button"
phx-click="toggle_select"
phx-value-id={insight.id}
class="mt-0.5 flex-shrink-0"
>
<div class={[
"flex h-5 w-5 items-center justify-center rounded border",
if(selected?(@selected_ids, insight.id),
do:
"border-indigo-600 bg-indigo-600 dark:border-indigo-500 dark:bg-indigo-500",
else: "border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-700"
)
]}>
<%= if selected?(@selected_ids, insight.id) do %>
<.icon name="hero-check" class="h-3.5 w-3.5 text-white" />
<% end %>
</div>
</button>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<%!-- Urgency badge --%>
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
urgency_classes(insight.urgency)
]}>
{insight.urgency}
</span>
<%!-- Type badge --%>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{String.replace(insight.type, "_", " ")}
</span>
</div>
<h3 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{insight.title}
</h3>
<%= if insight.description do %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{insight.description}
</p>
<% end %>
<div class="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<%!-- Access point --%>
<%= if insight.preseem_access_point do %>
<span class="flex items-center gap-1">
<.icon name="hero-signal" class="h-3.5 w-3.5" />
{insight.preseem_access_point.name}
</span>
<% end %>
<%!-- Linked device --%>
<%= if insight.device do %>
<.link
navigate={~p"/devices/#{insight.device.id}"}
class="flex items-center gap-1 text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<.icon name="hero-server" class="h-3.5 w-3.5" />
{insight.device.name || "Device"}
</.link>
<% end %>
<%!-- Timestamp --%>
<span class="flex items-center gap-1">
<.icon name="hero-clock" class="h-3.5 w-3.5" />
{Calendar.strftime(insight.inserted_at, "%Y-%m-%d %H:%M UTC")}
</span>
</div>
</div>
</div>
<%!-- Dismiss button --%>
<%= if insight.status == "active" do %>
<button
type="button"
phx-click="dismiss"
phx-value-id={insight.id}
class="flex-shrink-0 rounded-md px-2.5 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-white"
>
Dismiss
</button>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</Layouts.authenticated>

View file

@ -311,6 +311,7 @@ defmodule ToweropsWeb.Router do
live "/settings", Org.SettingsLive, :index
live "/settings/integrations", Org.IntegrationsLive, :index
live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index
live "/settings/integrations/preseem/insights", Org.PreseemInsightsLive, :index
end
end

View file

@ -0,0 +1,226 @@
defmodule ToweropsWeb.Org.PreseemInsightsLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Repo
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{user: user, 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_insight!(org, ap, attrs) do
default = %{
organization_id: org.id,
preseem_access_point_id: ap.id,
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test insight",
description: "Test description"
}
{:ok, insight} =
%Insight{}
|> Insight.changeset(Map.merge(default, attrs))
|> Repo.insert()
insight
end
describe "index" do
test "renders empty state when no insights exist", %{
conn: conn,
user: user,
organization: org
} do
{:ok, view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert html =~ "Network Insights"
assert has_element?(view, "#empty-state")
end
test "shows insights in list", %{conn: conn, user: user, organization: org} do
ap = insert_access_point!(org, %{name: "Tower 1"})
insert_insight!(org, ap, %{title: "QoE dropping on Tower 1"})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert html =~ "QoE dropping on Tower 1"
end
test "shows urgency badges", %{conn: conn, user: user, organization: org} do
ap = insert_access_point!(org)
insert_insight!(org, ap, %{
title: "Critical Issue",
urgency: "critical"
})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert has_element?(view, "#insights-list")
assert render(view) =~ "Critical Issue"
assert render(view) =~ "critical"
end
test "shows related access point name", %{conn: conn, user: user, organization: org} do
ap = insert_access_point!(org, %{name: "Main Tower AP"})
insert_insight!(org, ap, %{title: "Some insight"})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert html =~ "Main Tower AP"
end
test "links to device page when device_id exists", %{
conn: conn,
user: user,
organization: org
} do
ap = insert_access_point!(org)
device = device_fixture(%{organization_id: org.id, name: "My Router"})
insert_insight!(org, ap, %{
title: "Device issue",
device_id: device.id
})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert has_element?(view, "a[href='/devices/#{device.id}']")
end
end
describe "filtering" do
setup %{organization: org} do
ap = insert_access_point!(org)
critical =
insert_insight!(org, ap, %{
title: "Critical Issue",
urgency: "critical"
})
info =
insert_insight!(org, ap, %{
title: "Info Note",
urgency: "info"
})
%{ap: ap, critical: critical, info: info}
end
test "filters by urgency via URL params", %{conn: conn, user: user, organization: org} do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?urgency=critical")
assert html =~ "Critical Issue"
refute html =~ "Info Note"
end
test "filters by status", %{conn: conn, user: user, organization: org, ap: ap} do
insert_insight!(org, ap, %{
title: "Dismissed One",
status: "dismissed",
dismissed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?status=dismissed")
assert html =~ "Dismissed One"
refute html =~ "Critical Issue"
end
end
describe "dismiss" do
test "dismisses a single insight", %{conn: conn, user: user, organization: org} do
ap = insert_access_point!(org)
insight = insert_insight!(org, ap, %{title: "Dismissable"})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert render(view) =~ "Dismissable"
view
|> element("button[phx-click=dismiss][phx-value-id='#{insight.id}']")
|> render_click()
html = render(view)
assert html =~ "Insight dismissed"
refute html =~ "Dismissable"
end
end
describe "bulk actions" do
test "select all and bulk dismiss", %{conn: conn, user: user, organization: org} do
ap = insert_access_point!(org)
insert_insight!(org, ap, %{title: "Bulk 1"})
insert_insight!(org, ap, %{title: "Bulk 2"})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights")
assert render(view) =~ "Bulk 1"
assert render(view) =~ "Bulk 2"
# Select all
view |> element("#select-all-btn") |> render_click()
# Bulk dismiss
view |> element("#bulk-dismiss-btn") |> render_click()
html = render(view)
assert html =~ "dismissed"
refute html =~ "Bulk 1"
refute html =~ "Bulk 2"
end
end
end