Let interfaces be taken out of the sweep

7a73ed3 · Gabriel Morell · 2026-09-17 00:26

32 files +377 -28
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/lambent/app/data/LambentSocket.kt
+14 −0
@@ -42,6 +42,10 @@ object LambentSocket {
42 42 private val _services = MutableStateFlow<Map<String, List<Service>>>(emptyMap())
43 43 val services: StateFlow<Map<String, List<Service>>> = _services.asStateFlow()
44 44
45 + /** which interfaces the sweep runs on */
46 + private val _interfaces = MutableStateFlow<List<Interface>>(emptyList())
47 + val interfaces: StateFlow<List<Interface>> = _interfaces.asStateFlow()
48 +
45 49 /** machine name to its preview strip, 32 pixels of 0xRRGGBB */
46 50 private val _previews = MutableStateFlow<Map<String, List<Int>>>(emptyMap())
47 51 val previews: StateFlow<Map<String, List<Int>>> = _previews.asStateFlow()
@@ -124,6 +128,7 @@ object LambentSocket {
124 128 _placeClamps.value = emptyMap()
125 129 _globalClamp.value = Clamp(null, Clamp.FULL, Clamp.FULL, 0)
126 130 _machineTypes.value = emptyList()
131 + _interfaces.value = emptyList()
127 132 }
128 133
129 134 // ----- joins -----
@@ -141,6 +146,12 @@ object LambentSocket {
141 146 }
142 147 }
143 148
149 + ch.on("interfaces") { message ->
150 + _interfaces.value = message.payload["interfaces"].asList().map {
151 + Interface.from(it.asMap())
152 + }
153 + }
154 +
144 155 ch.on("probe_result") { message ->
145 156 val ip = message.payload["ip"].asStr() ?: "?"
146 157 val notice = when (message.payload["status"].asStr()) {
@@ -273,6 +284,9 @@ object LambentSocket {
273 284 fun poke(mac: String, mode: String = "auto") = push("devices", "poke", mapOf("id" to mac, "mode" to mode))
274 285 fun probe(ip: String) = push("devices", "probe", mapOf("ip" to ip))
275 286
287 + fun sweepInterface(name: String, enabled: Boolean) =
288 + push("devices", "interface", mapOf("name" to name, "enabled" to enabled))
289 +
276 290 // machines
277 291 fun machineCommand(cmd: String, name: String) = push("machines", cmd, mapOf("id" to name))
278 292 fun libraryAdd(cls: String, entry: String) =
modified android/app/src/main/java/org/neiam/lambent/app/data/Models.kt
+15 −0
@@ -99,6 +99,21 @@ data class Link(
99 99 }
100 100 }
101 101
102 +/**
103 + * A network interface the sweep can run on. The scanner filters out the
104 + * obvious ones (loopback, docker, tunnels) before this ever reaches us; these
105 + * are the ones left, which can still be told to stay out of the sweep.
106 + */
107 +data class Interface(val name: String, val addr: String?, val enabled: Boolean) {
108 + companion object {
109 + fun from(raw: Map<String, Any?>) = Interface(
110 + name = raw["name"].asStr() ?: "",
111 + addr = raw["addr"].asStr(),
112 + enabled = raw["enabled"] == true,
113 + )
114 + }
115 +}
116 +
102 117 /**
103 118 * A brightness clamp: where it is, where it is headed, and how long the walk
104 119 * between the two takes. The global clamp has no place.
modified android/app/src/main/java/org/neiam/lambent/app/ui/DevicesScreen.kt
+32 −0
@@ -1,6 +1,8 @@
1 1 package org.neiam.lambent.app.ui
2 2
3 3 import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.ExperimentalLayoutApi
5 +import androidx.compose.foundation.layout.FlowRow
4 6 import androidx.compose.foundation.layout.Column
5 7 import androidx.compose.foundation.layout.Row
6 8 import androidx.compose.foundation.layout.Spacer
@@ -15,6 +17,7 @@ import androidx.compose.material.icons.filled.Search
15 17 import androidx.compose.material.icons.filled.WbIridescent
16 18 import androidx.compose.material3.AssistChip
17 19 import androidx.compose.material3.Card
20 +import androidx.compose.material3.Checkbox
18 21 import androidx.compose.material3.DropdownMenu
19 22 import androidx.compose.material3.DropdownMenuItem
20 23 import androidx.compose.material3.FilledTonalIconButton
@@ -34,12 +37,14 @@ import androidx.compose.ui.Modifier
34 37 import androidx.compose.ui.unit.dp
35 38 import androidx.lifecycle.compose.collectAsStateWithLifecycle
36 39 import org.neiam.lambent.app.data.Device
40 +import org.neiam.lambent.app.data.Interface
37 41 import org.neiam.lambent.app.data.LambentSocket
38 42 import org.neiam.lambent.app.data.ORDERS
39 43
40 44 @Composable
41 45 fun DevicesScreen() {
42 46 val devices by LambentSocket.devices.collectAsStateWithLifecycle()
47 + val interfaces by LambentSocket.interfaces.collectAsStateWithLifecycle()
43 48 var probeIp by remember { mutableStateOf("") }
44 49
45 50 LazyColumn(
@@ -71,6 +76,10 @@ fun DevicesScreen() {
71 76 }
72 77 }
73 78
79 + if (interfaces.isNotEmpty()) {
80 + item { SweepInterfaces(interfaces) }
81 + }
82 +
74 83 if (devices.isEmpty()) {
75 84 item { Text("No devices detected, yet.", style = MaterialTheme.typography.bodyMedium) }
76 85 }
@@ -81,6 +90,29 @@ fun DevicesScreen() {
81 90 }
82 91 }
83 92
93 +/** which interfaces the sweep runs on, beyond the ones filtered out by name */
94 +@OptIn(ExperimentalLayoutApi::class)
95 +@Composable
96 +private fun SweepInterfaces(interfaces: List<Interface>) {
97 + Column {
98 + Text("Sweep", style = MaterialTheme.typography.labelMedium)
99 + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
100 + interfaces.forEach { iface ->
101 + Row(verticalAlignment = Alignment.CenterVertically) {
102 + Checkbox(
103 + checked = iface.enabled,
104 + onCheckedChange = { LambentSocket.sweepInterface(iface.name, it) },
105 + )
106 + Text(
107 + listOfNotNull(iface.name, iface.addr).joinToString(" "),
108 + style = MaterialTheme.typography.labelSmall,
109 + )
110 + }
111 + }
112 + }
113 + }
114 +}
115 +
84 116 @Composable
85 117 private fun DeviceCard(device: Device) {
86 118 var editing by remember { mutableStateOf<String?>(null) }
modified lib/lambent_ex/meta.ex
+16 −0
@@ -87,6 +87,22 @@ defmodule LambentEx.Meta do
87 87 :dets.insert(@table, {"place-#{key}", value})
88 88 end
89 89
90 + # interfaces the sweep has been told to leave alone, beyond the prefixes the
91 + # scanner filters out on its own
92 + def get_skipped_ifaces do
93 + case :dets.lookup(@table, "skip-ifaces") do
94 + [] ->
95 + []
96 +
97 + [{_key, value}] ->
98 + value
99 + end
100 + end
101 +
102 + def put_skipped_ifaces(value) do
103 + :dets.insert(@table, {"skip-ifaces", value})
104 + end
105 +
90 106 # brightness clamps. one row, since it is a single small map and the walk
91 107 # targets change together often enough that separate keys buy nothing
92 108 def get_brightness do
modified lib/lambent_ex/scan/nmap.ex
+132 −27
@@ -17,7 +17,7 @@ defmodule LambentEx.Scan.NMAP.Runner do
17 17 def handle_info({pid, :data, :out, data}, state) do
18 18 previous = state.previous
19 19
20 −# IO.inspect("Received data: #{data}")
20 + # IO.inspect("Received data: #{data}")
21 21
22 22 lines = data |> String.trim_trailing("\n") |> String.split("\n")
23 23 f = lines |> List.first()
@@ -60,11 +60,12 @@ defmodule LambentEx.Scan.NMAP.Runner do
60 60 %{latency: latency, seen: DateTime.utc_now()}
61 61 }
62 62 end)
63 − |> Map.new
64 −# |> IO.inspect
63 + |> Map.new()
65 64
66 − curr = state.results
67 − updated = Map.merge(curr, new_struct)
65 + # |> IO.inspect
66 +
67 + curr = state.results
68 + updated = Map.merge(curr, new_struct)
68 69
69 70 {:noreply, %{state | previous: leftover, results: updated}}
70 71 end
@@ -119,6 +120,9 @@ defmodule LambentEx.Scan.NMAP do
119 120 @s "ꙭ📡"
120 121 @filtered_prefixes ["br", "veth", "docker", "lo", "cni", "flannel", "tun", "tailscale"]
121 122 @pubsub_name LambentEx.PubSub
123 + @pubsub_ifaces "scan-ifaces"
124 +
125 + alias LambentEx.Meta, as: M
122 126
123 127 def start_link(opts) do
124 128 Parent.GenServer.start_link(__MODULE__, opts, name: via_tuple("nmap"))
@@ -128,47 +132,148 @@ defmodule LambentEx.Scan.NMAP do
128 132 via_tuple("nmap") |> GenServer.call(:retrieve)
129 133 end
130 134
135 + @doc """
136 + Every interface worth offering, with its address and whether the sweep is
137 + running on it. The prefixes filtered out at startup never appear here — this
138 + is for saying no to the ones that are left.
139 + """
140 + def interfaces() do
141 + via_tuple("nmap") |> GenServer.call(:interfaces)
142 + end
143 +
144 + @doc "Start or stop sweeping one interface. Remembered across restarts."
145 + def set_enabled(iface, enabled) when is_boolean(enabled) do
146 + via_tuple("nmap") |> GenServer.cast({:set_enabled, Kernel.to_string(iface), enabled})
147 + end
148 +
149 + @impl true
131 150 def init(_opts) do
132 − # Process.send_after(self(), :scan, :timer.seconds(3))
133 151 interfaces = get_valid_interfaces()
152 + skipped = M.get_skipped_ifaces() |> MapSet.new()
134 153
135 154 sov = %{
136 155 ~c"enp38s0" => ~i"172.16.13.0/19"
137 156 }
138 157
139 − pids =
158 + state = %{
159 + interfaces: interfaces,
160 + runners: %{},
161 + skipped: skipped,
162 + subnet_override: sov,
163 + task: nil
164 + }
165 +
166 + state =
140 167 interfaces
141 − |> Enum.map(fn {k, ifd} ->
142 − Parent.child_spec(
143 − {LambentEx.Scan.NMAP.Runner, %{iface: k, ifd: ifd, subnet_override: sov |> Map.get(k)}},
144 − id: k
145 − )
146 − end)
147 − |> Enum.map(fn x -> Parent.start_child(x) end)
148 − |> Enum.filter(fn {status, pid} -> status == :ok end)
149 − |> Enum.map(fn {:ok, v} -> v end)
168 + |> Map.keys()
169 + |> Enum.reduce(state, fn iface, state -> start_runner(iface, state) end)
150 170
151 −# IO.inspect(pids)
171 + if MapSet.size(skipped) > 0 do
172 + Logger.notice("#{@s} Not sweeping #{Enum.join(skipped, ", ")}")
173 + end
152 174
153 − {:ok,
154 − %{
155 − interfaces: interfaces,
156 − pids: pids,
157 − subnet_override: %{
158 − ~c"enp38s0" => ~i"172.16.13.0/19"
159 − },
160 − task: nil
161 − }}
175 + {:ok, state}
176 + end
177 +
178 + # a skipped interface simply has no runner, so nothing is nmapped there and
179 + # nothing it might have found earlier is still on offer
180 + defp start_runner(iface, state) do
181 + cond do
182 + MapSet.member?(state.skipped, Kernel.to_string(iface)) ->
183 + state
184 +
185 + Map.has_key?(state.runners, iface) ->
186 + state
187 +
188 + true ->
189 + spec =
190 + Parent.child_spec(
191 + {LambentEx.Scan.NMAP.Runner,
192 + %{
193 + iface: iface,
194 + ifd: state.interfaces[iface],
195 + subnet_override: state.subnet_override |> Map.get(iface)
196 + }},
197 + id: iface
198 + )
199 +
200 + case Parent.start_child(spec) do
201 + {:ok, pid} -> %{state | runners: Map.put(state.runners, iface, pid)}
202 + _otherwise -> state
203 + end
204 + end
205 + end
206 +
207 + defp stop_runner(iface, state) do
208 + case Map.has_key?(state.runners, iface) do
209 + false ->
210 + state
211 +
212 + true ->
213 + Parent.shutdown_child(iface)
214 + %{state | runners: Map.delete(state.runners, iface)}
215 + end
162 216 end
163 217
164 218 defp via_tuple(name), do: {:via, Registry, {@registry, name}}
165 219
166 220 @impl true
167 221 def handle_call(:retrieve, _from, state) do
168 − res = state.pids |> Enum.map(fn pid -> GenServer.call(pid, :retrieve) end) |> Enum.reduce(&Map.merge/2) # |> IO.inspect
222 + res =
223 + state.runners
224 + |> Map.values()
225 + |> Enum.map(fn pid -> GenServer.call(pid, :retrieve) end)
226 + |> Enum.reduce(%{}, &Map.merge/2)
227 +
169 228 {:reply, res, state}
170 229 end
171 230
231 + @impl true
232 + def handle_call(:interfaces, _from, state) do
233 + {:reply, listing(state), state}
234 + end
235 +
236 + @impl true
237 + def handle_cast({:set_enabled, iface, enabled}, state) do
238 + skipped =
239 + case enabled do
240 + true -> MapSet.delete(state.skipped, iface)
241 + false -> MapSet.put(state.skipped, iface)
242 + end
243 +
244 + state = %{state | skipped: skipped}
245 + M.put_skipped_ifaces(MapSet.to_list(skipped))
246 +
247 + key = Enum.find(Map.keys(state.interfaces), fn k -> Kernel.to_string(k) == iface end)
248 +
249 + state =
250 + case {key, enabled} do
251 + {nil, _enabled} -> state
252 + {key, true} -> start_runner(key, state)
253 + {key, false} -> stop_runner(key, state)
254 + end
255 +
256 + Logger.notice("#{@s} #{if enabled, do: "Sweeping", else: "No longer sweeping"} #{iface}")
257 + Phoenix.PubSub.broadcast(@pubsub_name, @pubsub_ifaces, {:interfaces, listing(state)})
258 +
259 + {:noreply, state}
260 + end
261 +
262 + defp listing(state) do
263 + state.interfaces
264 + |> Enum.map(fn {iface, ifd} ->
265 + %{
266 + name: Kernel.to_string(iface),
267 + addr: ifd |> Keyword.get(:addr) |> address(),
268 + enabled: not MapSet.member?(state.skipped, Kernel.to_string(iface))
269 + }
270 + end)
271 + |> Enum.sort_by(& &1.name)
272 + end
273 +
274 + defp address(nil), do: nil
275 + defp address(addr), do: addr |> Tuple.to_list() |> Enum.join(".")
276 +
172 277 defp get_valid_interfaces() do
173 278 {:ok, addrs} = :inet.getifaddrs()
174 279
modified lib/lambent_ex_web/channels/devices_channel.ex
+19 −0
@@ -7,6 +7,8 @@ defmodule LambentExWeb.DevicesChannel do
7 7 * `"devices"` — `%{devices: %{mac => device}}`, a snapshot on join and a
8 8 delta on every publish. Merge them by mac, the way the liveview does.
9 9 * `"probe_result"` — `%{ip: ip, status: "lambent" | "not_lambent" | "no_iface", sox: n}`
10 + * `"interfaces"` — `[%{name:, addr:, enabled:}]`, which interfaces the
11 + sweep runs on, on join and whenever one is turned off or back on.
10 12 """
11 13
12 14 use Phoenix.Channel
@@ -21,6 +23,7 @@ defmodule LambentExWeb.DevicesChannel do
21 23 def join("devices", _params, socket) do
22 24 Phoenix.PubSub.subscribe(@pubsub, "scan-82667777")
23 25 Phoenix.PubSub.subscribe(@pubsub, "scan-manual")
26 + Phoenix.PubSub.subscribe(@pubsub, "scan-ifaces")
24 27 send(self(), :snapshot)
25 28 {:ok, assign(socket, :devices, %{})}
26 29 end
@@ -31,9 +34,16 @@ defmodule LambentExWeb.DevicesChannel do
31 34 def handle_info(:snapshot, socket) do
32 35 devices = S.tagged_devices()
33 36 push(socket, "devices", %{devices: Payload.devices(devices)})
37 + push(socket, "interfaces", %{interfaces: LambentEx.Scan.NMAP.interfaces()})
34 38 {:noreply, assign(socket, :devices, devices)}
35 39 end
36 40
41 + @impl true
42 + def handle_info({:interfaces, interfaces}, socket) do
43 + push(socket, "interfaces", %{interfaces: interfaces})
44 + {:noreply, socket}
45 + end
46 +
37 47 @impl true
38 48 def handle_info({:devices_pub, devices}, socket) do
39 49 push(socket, "devices", %{devices: Payload.devices(devices)})
@@ -99,6 +109,15 @@ defmodule LambentExWeb.DevicesChannel do
99 109 {:reply, :ok, socket}
100 110 end
101 111
112 + # beyond the prefixes the scanner filters out on its own, an interface can be
113 + # told to stay out of the sweep
114 + @impl true
115 + def handle_in("interface", %{"name" => name, "enabled" => enabled}, socket)
116 + when is_boolean(enabled) do
117 + LambentEx.Scan.NMAP.set_enabled(name, enabled)
118 + {:reply, :ok, socket}
119 + end
120 +
102 121 @impl true
103 122 def handle_in("probe", %{"ip" => ip}, socket) do
104 123 ip = String.trim(ip)
modified lib/lambent_ex_web/live/devices/index.ex
+25 −1
@@ -11,7 +11,13 @@ 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 − {:ok, socket |> assign(:devices, %{}) |> assign(:nonew, true)}
14 + Phoenix.PubSub.subscribe(@pubsub_name, "scan-ifaces")
15 +
16 + {:ok,
17 + socket
18 + |> assign(:devices, %{})
19 + |> assign(:interfaces, LambentEx.Scan.NMAP.interfaces())
20 + |> assign(:nonew, true)}
15 21 end
16 22
17 23 @impl true
@@ -32,6 +38,11 @@ defmodule LambentExWeb.DevicesLive.Index do
32 38 {:noreply, socket |> assign(:devices, n)}
33 39 end
34 40
41 + @impl true
42 + def handle_info({:interfaces, interfaces}, socket) do
43 + {:noreply, socket |> assign(:interfaces, interfaces)}
44 + end
45 +
35 46 @impl true
36 47 def handle_info({:rename, data}, socket) do
37 48 id = data["id"] |> String.trim_trailing("name") |> String.trim_trailing("namem")
@@ -118,6 +129,19 @@ defmodule LambentExWeb.DevicesLive.Index do
118 129 {:noreply, socket}
119 130 end
120 131
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 +
121 145 @impl true
122 146 def handle_event("manual", %{"ip" => ip}, socket) do
123 147 ip = String.trim(ip)
modified lib/lambent_ex_web/live/devices/index.html.heex
+25 −0
@@ -19,6 +19,31 @@
19 19 <i class="fa-solid fa-code-branch"></i> Versions
20 20 </.link>
21 21 </form>
22 + <div class="my-2 flex flex-wrap items-center gap-4">
23 + <span class="text-sm opacity-60">
24 + <i class="fa-solid fa-network-wired fa-fw"></i> Sweep
25 + </span>
26 + <%= for iface <- @interfaces do %>
27 + <label class="label cursor-pointer gap-2 p-0">
28 + <input
29 + type="checkbox"
30 + class="checkbox checkbox-sm"
31 + checked={iface.enabled}
32 + phx-click="toggle_iface"
33 + phx-value-name={iface.name}
34 + />
35 + <span class="label-text">
36 + <%= iface.name %>
37 + <%= if iface.addr do %>
38 + <span class="opacity-60"><%= iface.addr %></span>
39 + <% end %>
40 + </span>
41 + </label>
42 + <% end %>
43 + <%= if @interfaces == [] do %>
44 + <span class="text-sm opacity-60">No interfaces to scan.</span>
45 + <% end %>
46 + </div>
22 47 <div class="hidden lg:block">
23 48 <table class="table table-zebra w-full">
24 49 <!-- head -->
modified test/lambent_ex_web/channels/channels_test.exs
+35 −0
@@ -51,6 +51,41 @@ defmodule LambentExWeb.ChannelsTest do
51 51 end
52 52 end
53 53
54 + describe "choosing what gets swept" do
55 + setup do
56 + on_exit(fn -> LambentEx.Meta.put_skipped_ifaces([]) end)
57 + :ok
58 + end
59 +
60 + test "the devices channel says which interfaces the sweep runs on" do
61 + connect_to("devices")
62 +
63 + assert_push "interfaces", %{interfaces: interfaces}
64 + assert is_list(interfaces)
65 + assert {:ok, _json} = Jason.encode(interfaces)
66 +
67 + # never the ones filtered out by prefix
68 + refute Enum.any?(interfaces, fn iface -> String.starts_with?(iface.name, "lo") end)
69 + refute Enum.any?(interfaces, fn iface -> String.starts_with?(iface.name, "docker") end)
70 + end
71 +
72 + test "turning one off is remembered and pushed back out" do
73 + socket = connect_to("devices")
74 + assert_push "interfaces", %{interfaces: _first}
75 +
76 + ref = push(socket, "interface", %{"name" => "test-iface0", "enabled" => false})
77 + assert_reply ref, :ok
78 +
79 + assert_push "interfaces", %{interfaces: _updated}
80 + assert "test-iface0" in LambentEx.Meta.get_skipped_ifaces()
81 +
82 + ref = push(socket, "interface", %{"name" => "test-iface0", "enabled" => true})
83 + assert_reply ref, :ok
84 + assert_push "interfaces", %{interfaces: _again}
85 + refute "test-iface0" in LambentEx.Meta.get_skipped_ifaces()
86 + end
87 + end
88 +
54 89 describe "the machine form over the machines channel" do
55 90 test "describes what it can build and what each type carries" do
56 91 connect_to("machines", %{"previews" => false})
added test/lambent_ex_web/live/interfaces_test.exs
+64 −0
@@ -0,0 +1,64 @@
1 +defmodule LambentExWeb.InterfacesTest do
2 + use LambentExWeb.ConnCase
3 +
4 + import Phoenix.LiveViewTest
5 +
6 + alias LambentEx.Meta
7 + alias LambentEx.Scan.NMAP
8 +
9 + setup do
10 + on_exit(fn -> Meta.put_skipped_ifaces([]) end)
11 + :ok
12 + end
13 +
14 + test "the devices page lists the interfaces the sweep runs on", %{conn: conn} do
15 + {:ok, _live, html} = live(conn, "/cfg/devices")
16 +
17 + assert html =~ "Sweep"
18 + assert html =~ "toggle_iface"
19 +
20 + # whatever this machine has, minus the prefixes the scanner drops
21 + for iface <- NMAP.interfaces() do
22 + assert html =~ iface.name
23 + refute String.starts_with?(iface.name, "docker")
24 + end
25 + end
26 +
27 + test "unchecking one is remembered", %{conn: conn} do
28 + {:ok, live, _html} = live(conn, "/cfg/devices")
29 +
30 + # an interface this machine does not have exercises the plumbing without
31 + # stopping a sweep the test host is actually running
32 + NMAP.set_enabled("test-iface1", false)
33 + NMAP.interfaces()
34 + assert render(live) =~ "toggle_iface"
35 + assert "test-iface1" in Meta.get_skipped_ifaces()
36 +
37 + NMAP.set_enabled("test-iface1", true)
38 + NMAP.interfaces()
39 + refute "test-iface1" in Meta.get_skipped_ifaces()
40 + end
41 +
42 + test "a real interface can be toggled from the page and comes back", %{conn: conn} do
43 + case NMAP.interfaces() do
44 + [] ->
45 + :ok
46 +
47 + [iface | _rest] ->
48 + {:ok, live, _html} = live(conn, "/cfg/devices")
49 +
50 + live
51 + |> element(~s([phx-click="toggle_iface"][phx-value-name="#{iface.name}"]))
52 + |> render_click()
53 +
54 + refute NMAP.interfaces() |> Enum.find(&(&1.name == iface.name)) |> Map.get(:enabled)
55 + assert iface.name in Meta.get_skipped_ifaces()
56 +
57 + live
58 + |> element(~s([phx-click="toggle_iface"][phx-value-name="#{iface.name}"]))
59 + |> render_click()
60 +
61 + assert NMAP.interfaces() |> Enum.find(&(&1.name == iface.name)) |> Map.get(:enabled)
62 + end
63 + end
64 +end

Parents: 453cc82