Give the sweep interfaces a page of their own

8c68923 · Gabriel Morell · 2026-09-17 00:31

13 files +133 -62
Message
{commit_body(@commit)}

Files changed

modified lib/lambent_ex_web/live/devices/index.ex
+0 −20
@@ -11,12 +11,10 @@ defmodule LambentExWeb.DevicesLive.Index do
11 11 def mount(_params, _session, socket) do
12 12 Phoenix.PubSub.subscribe(@pubsub_name, "scan-82667777")
13 13 Phoenix.PubSub.subscribe(@pubsub_name, "scan-manual")
14 − Phoenix.PubSub.subscribe(@pubsub_name, "scan-ifaces")
15 14
16 15 {:ok,
17 16 socket
18 17 |> assign(:devices, %{})
19 − |> assign(:interfaces, LambentEx.Scan.NMAP.interfaces())
20 18 |> assign(:nonew, true)}
21 19 end
22 20
@@ -38,11 +36,6 @@ defmodule LambentExWeb.DevicesLive.Index do
38 36 {:noreply, socket |> assign(:devices, n)}
39 37 end
40 38
41 − @impl true
42 − def handle_info({:interfaces, interfaces}, socket) do
43 − {:noreply, socket |> assign(:interfaces, interfaces)}
44 − end
45 −
46 39 @impl true
47 40 def handle_info({:rename, data}, socket) do
48 41 id = data["id"] |> String.trim_trailing("name") |> String.trim_trailing("namem")
@@ -129,19 +122,6 @@ defmodule LambentExWeb.DevicesLive.Index do
129 122 {:noreply, socket}
130 123 end
131 124
132 − # the sweep runs on every interface that is not filtered out by prefix; this
133 − # is for the ones that are left but still not worth scanning
134 − @impl true
135 − def handle_event("toggle_iface", %{"name" => name}, socket) do
136 − enabled =
137 − socket.assigns.interfaces
138 − |> Enum.find(%{}, fn iface -> iface.name == name end)
139 − |> Map.get(:enabled, true)
140 −
141 − LambentEx.Scan.NMAP.set_enabled(name, !enabled)
142 − {:noreply, socket}
143 − end
144 −
145 125 @impl true
146 126 def handle_event("manual", %{"ip" => ip}, socket) do
147 127 ip = String.trim(ip)
modified lib/lambent_ex_web/live/devices/index.html.heex
+3 −34
@@ -18,41 +18,10 @@
18 18 <.link patch={~p"/cfg/devices/versions"} class="btn btn-ghost normal-case">
19 19 <i class="fa-solid fa-code-branch"></i> Versions
20 20 </.link>
21 + <.link patch={~p"/cfg/devices/interfaces"} class="btn btn-ghost normal-case">
22 + <i class="fa-solid fa-network-wired"></i> Sweep
23 + </.link>
21 24 </form>
22 − <div class="collapse collapse-arrow border border-base-300 bg-base-100 rounded-box my-2">
23 − <input type="checkbox" />
24 − <div class="collapse-title text-sm opacity-60">
25 − <i class="fa-solid fa-network-wired fa-fw"></i>
26 − Sweep
27 − <span class="opacity-80">
28 − <%= @interfaces |> Enum.filter(& &1.enabled) |> length %> of <%= length(@interfaces) %> interfaces
29 − </span>
30 − </div>
31 − <div class="collapse-content">
32 − <div class="flex flex-wrap items-center gap-4">
33 − <%= for iface <- @interfaces do %>
34 − <label class="label cursor-pointer gap-2 p-0">
35 − <input
36 − type="checkbox"
37 − class="checkbox checkbox-sm"
38 − checked={iface.enabled}
39 − phx-click="toggle_iface"
40 − phx-value-name={iface.name}
41 − />
42 − <span class="label-text">
43 − <%= iface.name %>
44 − <%= if iface.addr do %>
45 − <span class="opacity-60"><%= iface.addr %></span>
46 − <% end %>
47 − </span>
48 − </label>
49 − <% end %>
50 − <%= if @interfaces == [] do %>
51 − <span class="text-sm opacity-60">No interfaces to scan.</span>
52 − <% end %>
53 − </div>
54 − </div>
55 − </div>
56 25 <div class="hidden lg:block">
57 26 <table class="table table-zebra w-full">
58 27 <!-- head -->
added lib/lambent_ex_web/live/devices/interfaces.ex
+52 −0
@@ -0,0 +1,52 @@
1 +defmodule LambentExWeb.DevicesLive.Interfaces do
2 + use LambentExWeb, :live_view
3 +
4 + alias LambentEx.Scan.NMAP
5 +
6 + @pubsub_name LambentEx.PubSub
7 +
8 + @impl true
9 + def mount(_params, _session, socket) do
10 + Phoenix.PubSub.subscribe(@pubsub_name, "scan-ifaces")
11 +
12 + {:ok,
13 + socket
14 + |> assign(:interfaces, NMAP.interfaces())
15 + |> assign(:nonew, true)}
16 + end
17 +
18 + @impl true
19 + def handle_params(params, _url, socket) do
20 + {:noreply, apply_action(socket, socket.assigns.live_action, params)}
21 + end
22 +
23 + defp apply_action(socket, :index, _params) do
24 + socket
25 + |> assign(:page_link, "Devices")
26 + |> assign(:page_title, "Scanned Interfaces")
27 + |> assign(:source, nil)
28 + end
29 +
30 + @impl true
31 + def handle_info({:interfaces, interfaces}, socket) do
32 + {:noreply, socket |> assign(:interfaces, interfaces)}
33 + end
34 +
35 + @impl true
36 + def handle_info(_msg, socket), do: {:noreply, socket}
37 +
38 + # the sweep runs on every interface that is not filtered out by prefix; this
39 + # is for the ones that are left but still not worth scanning
40 + @impl true
41 + def handle_event("toggle_iface", %{"name" => name}, socket) do
42 + enabled =
43 + socket.assigns.interfaces
44 + |> Enum.find(%{}, fn iface -> iface.name == name end)
45 + |> Map.get(:enabled, true)
46 +
47 + NMAP.set_enabled(name, !enabled)
48 + {:noreply, socket}
49 + end
50 +
51 + defp swept(interfaces), do: Enum.count(interfaces, & &1.enabled)
52 +end
added lib/lambent_ex_web/live/devices/interfaces.html.heex
+49 −0
@@ -0,0 +1,49 @@
1 +<div class="overflow-x-auto">
2 + <div class="my-2 flex flex-wrap items-center gap-2">
3 + <.link patch={~p"/cfg/devices"} class="btn btn-ghost normal-case">
4 + <i class="fa-solid fa-chevron-left"></i> All devices
5 + </.link>
6 + <span class="text-sm opacity-60">
7 + The scan looks for devices on every interface it is left with. Loopback, docker,
8 + tunnels and the like are dropped by name before they get here; unchecking one of
9 + these stops sweeping it, and is remembered across a restart.
10 + </span>
11 + </div>
12 +
13 + <table class="table table-zebra w-full">
14 + <thead>
15 + <tr>
16 + <th>Swept</th>
17 + <th>Interface</th>
18 + <th>Address</th>
19 + </tr>
20 + </thead>
21 + <tbody>
22 + <%= if Enum.empty?(@interfaces) do %>
23 + <tr>
24 + <th colspan="3">No interfaces to scan.</th>
25 + </tr>
26 + <% end %>
27 + <%= for iface <- @interfaces do %>
28 + <tr>
29 + <td>
30 + <input
31 + type="checkbox"
32 + class="checkbox"
33 + checked={iface.enabled}
34 + phx-click="toggle_iface"
35 + phx-value-name={iface.name}
36 + />
37 + </td>
38 + <th><%= iface.name %></th>
39 + <td><%= iface.addr || "-" %></td>
40 + </tr>
41 + <% end %>
42 + </tbody>
43 + </table>
44 +
45 + <p class="text-sm opacity-60 py-2">
46 + <%= swept(@interfaces) %> of <%= length(@interfaces) %> swept. A manual probe still
47 + reaches an address on an interface that is not being swept.
48 + </p>
49 +</div>
modified lib/lambent_ex_web/router.ex
+1 −0
@@ -21,6 +21,7 @@ defmodule LambentExWeb.Router do
21 21
22 22 live "/cfg/devices", DevicesLive.Index, :index
23 23 live "/cfg/devices/versions", DevicesLive.Versions, :index
24 + live "/cfg/devices/interfaces", DevicesLive.Interfaces, :index
24 25 live "/cfg/brightness", BrightnessLive.Index, :index
25 26 live "/cfg/machines", MachinesLive.Index, :index
26 27 live "/cfg/machines/new", MachinesLive.Index, :new
modified test/lambent_ex_web/live/interfaces_test.exs
+28 −8
@@ -11,24 +11,29 @@ defmodule LambentExWeb.InterfacesTest do
11 11 :ok
12 12 end
13 13
14 − test "the devices page lists the interfaces the sweep runs on", %{conn: conn} do
14 + test "the devices page links to the sweep page rather than carrying it", %{conn: conn} do
15 15 {:ok, _live, html} = live(conn, "/cfg/devices")
16 16
17 − assert html =~ "Sweep"
17 + assert html =~ "/cfg/devices/interfaces"
18 + refute html =~ "toggle_iface"
19 + end
20 +
21 + test "the sweep page lists the interfaces worth choosing about", %{conn: conn} do
22 + {:ok, _live, html} = live(conn, "/cfg/devices/interfaces")
23 +
18 24 assert html =~ "toggle_iface"
19 − # folded away, with a count of what is on so it reads at a glance
20 − assert html =~ "collapse"
21 − assert html =~ "of #{length(NMAP.interfaces())} interfaces"
25 + assert html =~ "of #{length(NMAP.interfaces())} swept"
22 26
23 − # whatever this machine has, minus the prefixes the scanner drops
24 27 for iface <- NMAP.interfaces() do
25 28 assert html =~ iface.name
29 + # never the ones dropped by prefix
26 30 refute String.starts_with?(iface.name, "docker")
31 + refute String.starts_with?(iface.name, "lo")
27 32 end
28 33 end
29 34
30 35 test "unchecking one is remembered", %{conn: conn} do
31 − {:ok, live, _html} = live(conn, "/cfg/devices")
36 + {:ok, live, _html} = live(conn, "/cfg/devices/interfaces")
32 37
33 38 # an interface this machine does not have exercises the plumbing without
34 39 # stopping a sweep the test host is actually running
@@ -48,7 +53,7 @@ defmodule LambentExWeb.InterfacesTest do
48 53 :ok
49 54
50 55 [iface | _rest] ->
51 − {:ok, live, _html} = live(conn, "/cfg/devices")
56 + {:ok, live, _html} = live(conn, "/cfg/devices/interfaces")
52 57
53 58 live
54 59 |> element(~s([phx-click="toggle_iface"][phx-value-name="#{iface.name}"]))
@@ -64,4 +69,19 @@ defmodule LambentExWeb.InterfacesTest do
64 69 assert NMAP.interfaces() |> Enum.find(&(&1.name == iface.name)) |> Map.get(:enabled)
65 70 end
66 71 end
72 +
73 + test "the page follows a change made elsewhere", %{conn: conn} do
74 + {:ok, live, _html} = live(conn, "/cfg/devices/interfaces")
75 +
76 + Phoenix.PubSub.broadcast(
77 + LambentEx.PubSub,
78 + "scan-ifaces",
79 + {:interfaces, [%{name: "somewhere-else", addr: "10.9.9.1", enabled: false}]}
80 + )
81 +
82 + html = render(live)
83 + assert html =~ "somewhere-else"
84 + assert html =~ "10.9.9.1"
85 + assert html =~ "0 of 1 swept"
86 + end
67 87 end

Parents: 9e5a6e6