feat(insights): RF-aware channel-conflict — multi-source noise + Preseem

Same-channel conflicts at one site are not all equal. A pair of APs
sharing a channel in a clean RF environment is a planning issue;
the same pair in a contaminated environment is an outage waiting to
happen. This commit makes the rule reason across sources before
deciding urgency.

- Towerops.Snmp.RfHealth — cross-source helper that combines:
  * Most recent noise-floor / noise sensor on the AP (dBm)
  * Preseem rf_score and qoe_score from the linked AccessPoint row
  * Average CPE SNR and client count from wireless_clients
  Returns a snapshot map and an interference_severity/1 classifier
  (:critical | :warning | :ok). Critical when noise floor >= -80 dBm
  OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
  active clients.
- OwnFleetChannelConflict now enriches each conflicting AP with its
  noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a
  per-AP rf_severity. Urgency promotes to critical when 3+ APs share
  a channel OR any conflicting AP shows critical RF health.
- UI: amber card now renders an evidence table with width, noise,
  rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
  environment detected" badge when has_critical_rf is true.

Also strengthens the previous flake fix on agent_live_test.exs:567
by switching to Req.Test shared mode (the test is sync), so the
ReleaseChecker stub is visible to the LiveView process regardless of
when it spawns relative to setup. The earlier Req.Test.allow approach
relied on stub timing that didn't always hold under full-suite load.

11 new tests across RfHealth + the enriched rule. LLM enrichment
automatically picks up the richer metadata via the existing Phase 1
worker.
This commit is contained in:
Graham McIntire 2026-05-09 17:23:43 -05:00
parent 18f62022f5
commit b735adc067
8 changed files with 506 additions and 41 deletions

View file

@ -1,3 +1,23 @@
2026-05-09
feat(insights): RF-aware channel-conflict — multi-source noise + Preseem
Towerops.Snmp.RfHealth — cross-source helper that combines:
* Most recent noise-floor / noise sensor value (dBm) on the AP
* Preseem rf_score and qoe_score from the linked AccessPoint row
* Average CPE SNR and client count from wireless_clients
Returns a snapshot map and an `interference_severity/1` classifier
(:critical | :warning | :ok). Critical when noise floor >= -80 dBm
OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
active clients.
OwnFleetChannelConflict rule now enriches each conflicting AP with
noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and per-AP
rf_severity. Urgency promotes to critical when 3+ APs share a
channel OR any conflicting AP shows critical RF health — so a
same-channel conflict in a clean RF environment stays "warning"
while one in a contaminated environment escalates.
UI: amber card now shows a small evidence table with width, noise,
rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
environment detected" badge when has_critical_rf is true.
2026-05-09
feat(snmp): canonical AP radio state + own-fleet channel conflict rule
Towerops.Snmp.RadioState — derives current_channel from existing

View file

@ -8,9 +8,17 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do
rule needs no neighbor scan data just the `current_channel` already
populated on `snmp_devices` by `Towerops.Snmp.RadioState`.
Emits one `own_fleet_channel_conflict` insight per (site, channel)
group, listing the conflicting APs in the metadata so the operator
can pick which one to move.
Each conflicting AP is enriched with cross-source RF health data via
`Towerops.Snmp.RfHealth` so the operator sees the real impact:
* Noise floor (dBm) from the AP's most recent noise-floor sensor
* Preseem rf_score and qoe_score (when the AP is matched)
* Average CPE SNR for currently connected subscribers
Urgency is bumped to **critical** when:
* 3+ APs share a channel, OR
* Any conflicting AP shows critical RF health (noise floor -80 dBm
or Preseem rf_score < 4.0)
"""
import Ecto.Query
@ -18,6 +26,7 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.RfHealth
@spec evaluate(Ecto.UUID.t()) :: [map()]
def evaluate(organization_id) when is_binary(organization_id) do
@ -44,42 +53,70 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflict do
defp build_insight({{site_id, channel}, group}, organization_id) do
[{first_device, first_snmp} | _] = group
aps =
enriched =
Enum.map(group, fn {device, snmp} ->
%{
health = RfHealth.for_device(device.id)
severity = RfHealth.interference_severity(health)
ap = %{
"device_id" => device.id,
"name" => device.name,
"channel" => snmp.current_channel,
"frequency_mhz" => snmp.current_frequency_mhz,
"channel_width_mhz" => snmp.current_channel_width_mhz
"channel_width_mhz" => snmp.current_channel_width_mhz,
"noise_floor_dbm" => health.noise_floor_dbm,
"rf_score" => health.rf_score,
"qoe_score" => health.qoe_score,
"avg_cpe_snr_db" => health.avg_cpe_snr_db,
"cpe_count" => health.client_count,
"rf_severity" => Atom.to_string(severity)
}
{ap, severity}
end)
aps = Enum.map(enriched, fn {ap, _sev} -> ap end)
severities = Enum.map(enriched, fn {_ap, sev} -> sev end)
has_critical_rf = :critical in severities
%{
organization_id: organization_id,
site_id: site_id,
device_id: first_device.id,
type: "own_fleet_channel_conflict",
urgency: urgency_for(length(group)),
urgency: urgency_for(length(group), has_critical_rf),
channel: "proactive",
source: "system",
title:
"#{length(group)} APs at the same site share channel #{channel} " <>
"(#{first_snmp.current_frequency_mhz} MHz)",
description:
"Multiple access points at the same site are configured on the " <>
"same channel. Without strict GPS sync this causes co-channel " <>
"interference between sectors and degrades airtime efficiency.",
description: description_for(group, has_critical_rf),
metadata: %{
"dedup_key" => "own_fleet_channel_conflict:#{site_id}:#{channel}",
"channel" => channel,
"frequency_mhz" => first_snmp.current_frequency_mhz,
"ap_count" => length(group),
"has_critical_rf" => has_critical_rf,
"aps" => aps
}
}
end
defp urgency_for(count) when count >= 3, do: "critical"
defp urgency_for(_), do: "warning"
defp urgency_for(count, has_critical_rf) when count >= 3 or has_critical_rf == true, do: "critical"
defp urgency_for(_count, _has_critical_rf), do: "warning"
defp description_for(group, true) do
"#{length(group)} access points at the same site share a channel " <>
"AND at least one of them shows critical RF health " <>
"(elevated noise floor or low Preseem RF score). This is the " <>
"high-impact case — co-channel self-interference compounded by " <>
"external interference."
end
defp description_for(group, false) do
"#{length(group)} access points at the same site are configured on " <>
"the same channel. Without strict GPS sync this causes co-channel " <>
"interference between sectors and degrades airtime efficiency."
end
end

View file

@ -0,0 +1,144 @@
defmodule Towerops.Snmp.RfHealth do
@moduledoc """
Cross-source RF health snapshot for an access point.
Combines:
* **Noise floor** (dBm) from the most recent `noise-floor` / `noise`
sensor on the AP's snmp_device.
* **Preseem scores** (`rf_score`, `qoe_score`) from the linked
`preseem_access_points` row, if any.
* **Average CPE SNR** and **client count** from the wireless
clients currently associated with the AP.
Used by RF-aware recommendation rules to enrich evidence and modulate
urgency. Returns a struct-shaped map with explicit `nil` fields when
data is missing, so callers can treat the absence of a signal as
separate from "signal observed but healthy".
"""
import Ecto.Query
alias Towerops.Preseem.AccessPoint
alias Towerops.Repo
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.WirelessClient
@cpe_lookback_seconds 2 * 3600
@noise_critical_dbm -80.0
@rf_score_critical 4.0
@cpe_snr_warning_db 15.0
@type t :: %{
noise_floor_dbm: float() | nil,
rf_score: float() | nil,
qoe_score: float() | nil,
avg_cpe_snr_db: float() | nil,
client_count: non_neg_integer()
}
@spec for_device(Ecto.UUID.t()) :: t()
def for_device(device_id) when is_binary(device_id) do
%{
noise_floor_dbm: latest_noise_floor(device_id),
rf_score: preseem_field(device_id, :rf_score),
qoe_score: preseem_field(device_id, :qoe_score),
avg_cpe_snr_db: avg_cpe_snr(device_id),
client_count: cpe_count(device_id)
}
end
@doc """
Classify the RF health snapshot as :critical, :warning, or :ok.
Tolerates missing fields if no signals are present, returns :ok.
"""
@spec interference_severity(map()) :: :critical | :warning | :ok
def interference_severity(%{} = h) do
cond do
noise_floor_critical?(Map.get(h, :noise_floor_dbm)) -> :critical
rf_score_critical?(Map.get(h, :rf_score)) -> :critical
cpe_snr_warning?(Map.get(h, :avg_cpe_snr_db), Map.get(h, :client_count, 0)) -> :warning
true -> :ok
end
end
defp noise_floor_critical?(nil), do: false
defp noise_floor_critical?(value) when is_number(value), do: value >= @noise_critical_dbm
defp rf_score_critical?(nil), do: false
defp rf_score_critical?(value) when is_number(value), do: value < @rf_score_critical
defp cpe_snr_warning?(_snr, count) when count == 0, do: false
defp cpe_snr_warning?(nil, _count), do: false
defp cpe_snr_warning?(snr, _count) when is_number(snr) do
snr < @cpe_snr_warning_db
end
defp latest_noise_floor(device_id) do
sensor =
Repo.one(
from(s in Sensor,
join: d in SnmpDevice,
on: d.id == s.snmp_device_id,
where:
d.device_id == ^device_id and s.sensor_type in ^["noise-floor", "noise", "noise_floor"] and
not is_nil(s.last_value),
order_by: [desc: s.updated_at],
limit: 1,
select: s.last_value
)
)
sensor
end
defp preseem_field(device_id, field) do
Repo.one(
from(ap in AccessPoint,
where: ap.device_id == ^device_id,
order_by: [desc: ap.updated_at],
limit: 1,
select: field(ap, ^field)
)
)
end
defp avg_cpe_snr(device_id) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@cpe_lookback_seconds, :second)
|> DateTime.truncate(:second)
from(c in WirelessClient,
where:
c.device_id == ^device_id and
not is_nil(c.snr) and
c.last_seen_at >= ^cutoff,
select: avg(c.snr)
)
|> Repo.one()
|> decimal_to_float()
end
defp cpe_count(device_id) do
cutoff =
DateTime.utc_now()
|> DateTime.add(-@cpe_lookback_seconds, :second)
|> DateTime.truncate(:second)
from(c in WirelessClient,
where: c.device_id == ^device_id and c.last_seen_at >= ^cutoff,
select: count(c.id)
)
|> Repo.one()
|> Kernel.||(0)
end
defp decimal_to_float(nil), do: nil
defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d)
defp decimal_to_float(n) when is_number(n), do: n * 1.0
end

View file

@ -504,34 +504,67 @@
{insight.metadata["ap_count"]}
</span>
</div>
<%= if insight.metadata["has_critical_rf"] do %>
<span class="ml-auto rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-200">
{t("Critical RF environment detected")}
</span>
<% end %>
</div>
<%= if is_list(insight.metadata["aps"]) and insight.metadata["aps"] != [] do %>
<div class="mt-3">
<div class="mt-3 overflow-x-auto">
<p class="text-xs font-medium text-amber-800 dark:text-amber-300">
{t("Conflicting APs")}
</p>
<ul class="mt-1 space-y-1">
<li
:for={ap <- insight.metadata["aps"]}
class="flex items-center gap-2 text-xs text-amber-900 dark:text-amber-100"
>
<%= if ap["device_id"] do %>
<.link
navigate={~p"/devices/#{ap["device_id"]}"}
class="underline hover:no-underline"
>
{ap["name"] || "Unnamed"}
</.link>
<% else %>
<span>{ap["name"] || "Unnamed"}</span>
<% end %>
<%= if ap["channel_width_mhz"] do %>
<span class="text-amber-700 dark:text-amber-300">
({ap["channel_width_mhz"]} MHz wide)
</span>
<% end %>
</li>
</ul>
<table class="mt-1 w-full text-left text-xs text-amber-900 dark:text-amber-100">
<thead class="text-[11px] font-medium text-amber-700 dark:text-amber-300">
<tr>
<th class="py-1 pr-3">{t("AP")}</th>
<th class="py-1 pr-3">{t("Width")}</th>
<th class="py-1 pr-3">{t("Noise")}</th>
<th class="py-1 pr-3">{t("RF score")}</th>
<th class="py-1 pr-3">{t("CPE SNR")}</th>
<th class="py-1 pr-3">{t("CPEs")}</th>
</tr>
</thead>
<tbody>
<tr
:for={ap <- insight.metadata["aps"]}
class="border-t border-amber-200/50 dark:border-amber-800/40"
>
<td class="py-1 pr-3">
<%= if ap["device_id"] do %>
<.link
navigate={~p"/devices/#{ap["device_id"]}"}
class="underline hover:no-underline"
>
{ap["name"] || "Unnamed"}
</.link>
<% else %>
{ap["name"] || "Unnamed"}
<% end %>
<%= if ap["rf_severity"] == "critical" do %>
<span class="ml-1 rounded bg-red-200 px-1 py-0.5 text-[10px] font-medium text-red-900 dark:bg-red-800 dark:text-red-100">
crit
</span>
<% end %>
</td>
<td class="py-1 pr-3 font-mono">
{ap["channel_width_mhz"] && "#{ap["channel_width_mhz"]} MHz"}
</td>
<td class="py-1 pr-3 font-mono">
{ap["noise_floor_dbm"] && "#{ap["noise_floor_dbm"]} dBm"}
</td>
<td class="py-1 pr-3 font-mono">
{ap["rf_score"] && Float.round(ap["rf_score"] / 1.0, 1)}
</td>
<td class="py-1 pr-3 font-mono">
{ap["avg_cpe_snr_db"] &&
"#{Float.round(ap["avg_cpe_snr_db"] / 1.0, 1)} dB"}
</td>
<td class="py-1 pr-3 font-mono">{ap["cpe_count"]}</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>

View file

@ -1,5 +1,5 @@
2026-05-09 — Smarter recommendations + AI-generated insight summaries
* New recommendation: same-channel conflicts within your own network — when two or more of your APs at the same site sit on the same channel, the system flags the conflict with a clickable list so you can pick which one to retune
* New recommendation: same-channel conflicts within your own network — when two or more of your APs at the same site sit on the same channel, the system flags the conflict with a clickable evidence table showing noise floor, RF score, average CPE SNR, and client count per AP, and escalates to critical when the RF environment is also contaminated
* New recommendation: sector overload detection — APs with low free airtime and active subscribers get flagged with subscriber count, QoE score, and AP model so you can decide whether to split the sector or upgrade the radio
* New recommendation: CPE re-aim candidates — wireless clients with both weak signal and low SNR (the alignment-issue signature) get individual cards with signal, SNR, TX/RX rate, and distance, so each truck-roll candidate is actionable on its own
* New recommendation: when a wireless AP has strong same-channel interference, the system suggests a specific cleaner channel and shows the top interfering neighbors with their signal levels

View file

@ -10,8 +10,10 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflictTest do
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Recommendations.Rules.OwnFleetChannelConflict
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Sensor
setup do
user = user_fixture()
@ -101,4 +103,80 @@ defmodule Towerops.Recommendations.Rules.OwnFleetChannelConflictTest do
assert OwnFleetChannelConflict.evaluate(org.id) == []
end
describe "RF health enrichment" do
defp put_noise_floor(device, value) do
snmp = Repo.get_by!(SnmpDevice, device_id: device.id)
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp.id,
sensor_type: "noise-floor",
sensor_index: "n_#{System.unique_integer([:positive])}",
sensor_oid: "1.2.3.#{System.unique_integer([:positive])}",
sensor_unit: "dBm",
sensor_divisor: 1,
last_value: value
})
|> Repo.insert!()
end
defp link_preseem(org, device, attrs) do
%AccessPoint{}
|> AccessPoint.changeset(
Map.merge(
%{
organization_id: org.id,
preseem_id: "ap-#{System.unique_integer([:positive])}",
name: device.name,
device_id: device.id,
match_confidence: "auto_ip"
},
attrs
)
)
|> Repo.insert!()
end
test "enriches each AP with noise floor and rf_score in metadata",
%{org: org, site: site} do
a = insert_ap(org, site, "AP-A", 149, 5745)
b = insert_ap(org, site, "AP-B", 149, 5745)
put_noise_floor(a, -82.0)
link_preseem(org, b, %{rf_score: 6.5, qoe_score: 80.0})
[insight] = OwnFleetChannelConflict.evaluate(org.id)
[ap_a, ap_b] = Enum.sort_by(insight.metadata["aps"], & &1["name"])
assert ap_a["noise_floor_dbm"] == -82.0
assert ap_b["rf_score"] == 6.5
assert ap_b["qoe_score"] == 80.0
assert insight.metadata["has_critical_rf"] == false
end
test "promotes urgency to critical when noise floor is high (>= -80 dBm)",
%{org: org, site: site} do
a = insert_ap(org, site, "AP-A", 149, 5745)
_b = insert_ap(org, site, "AP-B", 149, 5745)
put_noise_floor(a, -78.0)
[insight] = OwnFleetChannelConflict.evaluate(org.id)
assert insight.urgency == "critical"
assert insight.metadata["has_critical_rf"] == true
end
test "promotes urgency to critical when Preseem rf_score is low (< 4.0)",
%{org: org, site: site} do
a = insert_ap(org, site, "AP-A", 149, 5745)
_b = insert_ap(org, site, "AP-B", 149, 5745)
link_preseem(org, a, %{rf_score: 3.2})
[insight] = OwnFleetChannelConflict.evaluate(org.id)
assert insight.urgency == "critical"
end
end
end

View file

@ -0,0 +1,147 @@
defmodule Towerops.Snmp.RfHealthTest do
@moduledoc """
Coverage for the cross-source RF health helper that combines noise-floor
sensor readings, Preseem RF / QoE scores, and average CPE SNR into a
single map per AP for use by RF-aware recommendation rules.
"""
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Preseem.AccessPoint
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.RfHealth
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.WirelessClient
setup do
user = user_fixture()
org = organization_fixture(user.id)
device = device_fixture(%{organization_id: org.id, name: "AP-Test"})
{:ok, snmp} =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "AP-Test"})
|> Repo.insert()
%{org: org, device: device, snmp: snmp}
end
defp insert_sensor(snmp, attrs) do
full =
Map.merge(
%{
snmp_device_id: snmp.id,
sensor_type: "noise-floor",
sensor_index: "n_#{System.unique_integer([:positive])}",
sensor_oid: "1.2.3.#{System.unique_integer([:positive])}",
sensor_unit: "dBm",
sensor_divisor: 1
},
attrs
)
%Sensor{} |> Sensor.changeset(full) |> Repo.insert!()
end
defp insert_client(org, device, attrs) do
full =
Map.merge(
%{
organization_id: org.id,
device_id: device.id,
mac_address: "AA:BB:CC:#{3 |> :crypto.strong_rand_bytes() |> Base.encode16()}",
last_seen_at: DateTime.truncate(DateTime.utc_now(), :second)
},
attrs
)
%WirelessClient{} |> WirelessClient.changeset(full) |> Repo.insert!()
end
describe "for_device/1" do
test "returns nil-ish struct when no signals present", %{device: device} do
assert h = RfHealth.for_device(device.id)
assert is_nil(h.noise_floor_dbm)
assert is_nil(h.rf_score)
assert is_nil(h.qoe_score)
assert is_nil(h.avg_cpe_snr_db)
assert h.client_count == 0
end
test "reads the most recent noise-floor sensor", %{device: device, snmp: snmp} do
insert_sensor(snmp, %{last_value: -82.0})
h = RfHealth.for_device(device.id)
assert h.noise_floor_dbm == -82.0
end
test "accepts the alternate sensor_type 'noise'", %{device: device, snmp: snmp} do
insert_sensor(snmp, %{sensor_type: "noise", last_value: -75.0})
h = RfHealth.for_device(device.id)
assert h.noise_floor_dbm == -75.0
end
test "pulls rf_score and qoe_score from the linked Preseem AccessPoint",
%{org: org, device: device} do
{:ok, _ap} =
%AccessPoint{}
|> AccessPoint.changeset(%{
organization_id: org.id,
preseem_id: "ap-rf-#{System.unique_integer([:positive])}",
name: "Linked",
device_id: device.id,
match_confidence: "auto_ip",
rf_score: 4.2,
qoe_score: 67.0
})
|> Repo.insert()
h = RfHealth.for_device(device.id)
assert h.rf_score == 4.2
assert h.qoe_score == 67.0
end
test "computes average CPE SNR and counts clients seen recently",
%{org: org, device: device} do
insert_client(org, device, %{snr: 20, signal_strength: -65})
insert_client(org, device, %{snr: 10, signal_strength: -78})
insert_client(org, device, %{snr: nil, signal_strength: -70})
h = RfHealth.for_device(device.id)
assert h.avg_cpe_snr_db == 15.0
assert h.client_count == 3
end
end
describe "interference_severity/1" do
test "returns :critical when noise floor is high (>= -80 dBm)" do
assert :critical = RfHealth.interference_severity(%{noise_floor_dbm: -78.0})
end
test "returns :critical when Preseem rf_score is low (< 4.0)" do
assert :critical = RfHealth.interference_severity(%{rf_score: 3.5})
end
test "returns :warning when avg CPE SNR is low (< 15)" do
assert :warning =
RfHealth.interference_severity(%{avg_cpe_snr_db: 12.0, client_count: 5})
end
test "returns :ok when nothing concerning" do
h = %{noise_floor_dbm: -92.0, rf_score: 7.5, avg_cpe_snr_db: 25.0, client_count: 5}
assert :ok = RfHealth.interference_severity(h)
end
test "ignores avg_cpe_snr_db when client_count is 0" do
assert :ok = RfHealth.interference_severity(%{avg_cpe_snr_db: 5.0, client_count: 0})
end
test "tolerates missing fields" do
assert :ok = RfHealth.interference_severity(%{})
end
end
end

View file

@ -577,15 +577,21 @@ defmodule ToweropsWeb.AgentLiveTest do
# test is deterministic. Without an explicit stub, behavior depends on
# whether other tests have left a Req.Test stub registered for this
# module — which makes this test flaky under the full suite.
#
# Use shared mode so the stub is visible to the LiveView process
# regardless of when it spawns relative to this test's setup, and
# regardless of whether the handler hops through a Task. This test
# is sync (use ToweropsWeb.ConnCase without async: true), so shared
# mode is safe.
Req.Test.set_req_test_to_shared(%{async: false})
Req.Test.stub(ReleaseChecker, fn conn ->
Req.Test.transport_error(conn, :econnrefused)
end)
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
on_exit(fn -> Req.Test.set_req_test_to_private(%{}) end)
# Allow the LiveView process to use stubs registered in this test
# process (Req.Test.stub is owner-scoped by default).
Req.Test.allow(ReleaseChecker, self(), view.pid)
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
html =
view