From 9ccea8daaf4520ac2e3fe97c82847dfb5419f9f4 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 09:00:34 -0600 Subject: [PATCH] add network insights feed page with filters and bulk dismiss --- .../live/org/integrations_live.html.heex | 8 + .../live/org/preseem_insights_live.ex | 163 ++++++++++ .../live/org/preseem_insights_live.html.heex | 303 ++++++++++++++++++ lib/towerops_web/router.ex | 1 + .../live/org/preseem_insights_live_test.exs | 226 +++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 lib/towerops_web/live/org/preseem_insights_live.ex create mode 100644 lib/towerops_web/live/org/preseem_insights_live.html.heex create mode 100644 test/towerops_web/live/org/preseem_insights_live_test.exs diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index fb556ead..dd1e0f38 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -85,6 +85,14 @@ > Manage Devices → + <.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 → + <% end %> diff --git a/lib/towerops_web/live/org/preseem_insights_live.ex b/lib/towerops_web/live/org/preseem_insights_live.ex new file mode 100644 index 00000000..afd70419 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_insights_live.ex @@ -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 diff --git a/lib/towerops_web/live/org/preseem_insights_live.html.heex b/lib/towerops_web/live/org/preseem_insights_live.html.heex new file mode 100644 index 00000000..e43e0b86 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_insights_live.html.heex @@ -0,0 +1,303 @@ + +
+
+ <.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 + +
+
+
+

+ Network Insights +

+

+ Proactive network health observations generated from Preseem data analysis. +

+
+
+
+ + <%!-- Filter bar --%> +
+ <%!-- Status filter tabs --%> + + + <%!-- Urgency filter --%> +
+ Urgency: + <.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 + 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 + 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 + 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 + +
+
+ + <%!-- Bulk actions bar --%> + <%= if any_selected?(@selected_ids) do %> +
+ + {MapSet.size(@selected_ids)} selected + + + +
+ <% end %> + + <%!-- Insights list --%> +
+ <%= if @insights == [] do %> +
+ <.icon + name="hero-light-bulb" + class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" + /> +

+ No insights found +

+

+ <%= 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 %> +

+
+ <% else %> + <%!-- Select all bar --%> +
+ +
+ +
+
+
+
+ <%!-- Checkbox --%> + + +
+
+ <%!-- Urgency badge --%> + + {insight.urgency} + + + <%!-- Type badge --%> + + {String.replace(insight.type, "_", " ")} + +
+ +

+ {insight.title} +

+ + <%= if insight.description do %> +

+ {insight.description} +

+ <% end %> + +
+ <%!-- Access point --%> + <%= if insight.preseem_access_point do %> + + <.icon name="hero-signal" class="h-3.5 w-3.5" /> + {insight.preseem_access_point.name} + + <% 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"} + + <% end %> + + <%!-- Timestamp --%> + + <.icon name="hero-clock" class="h-3.5 w-3.5" /> + {Calendar.strftime(insight.inserted_at, "%Y-%m-%d %H:%M UTC")} + +
+
+
+ + <%!-- Dismiss button --%> + <%= if insight.status == "active" do %> + + <% end %> +
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index af453f29..c4c2a194 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/test/towerops_web/live/org/preseem_insights_live_test.exs b/test/towerops_web/live/org/preseem_insights_live_test.exs new file mode 100644 index 00000000..5901b603 --- /dev/null +++ b/test/towerops_web/live/org/preseem_insights_live_test.exs @@ -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