neiam
/lambent_ex
Lambent Aether 5
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
Give the sweep interfaces a page of their own
8c68923 · Gabriel Morell · 2026-09-17 00:31
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