feat(insights): superuser regenerate button + dialyzer cleanup

- /insights now shows a "Regenerate insights" button for superusers
  that enqueues all seven insight workers (Preseem baselines,
  capacity, device-health, Gaiia, system, wireless, LLM enrichment).
  Non-superusers neither see the button nor can trigger the event.
- Dialyzer is clean: added :tools to plt_add_apps, gave
  Preseem.Insight a @type t, pinned a few unmatched returns, and
  suppressed a known MapSet opaque false-positive in Towerops.Unused.
- e2e: NODE_OPTIONS=--disable-warning=DEP0205 silences the Node 25
  deprecation noise from Playwright's internal ESM loader.
This commit is contained in:
Graham McIntire 2026-05-10 13:41:25 -05:00
parent 01d64a446e
commit bdfb20efdf
9 changed files with 169 additions and 24 deletions

View file

@ -1,3 +1,36 @@
2026-05-10
feat(insights): superuser "Regenerate insights" button on /insights
ToweropsWeb.InsightsLive.Index — new handle_event("regenerate_insights")
enqueues PreseemBaselineWorker, CapacityInsightWorker,
DeviceHealthInsightWorker, GaiiaInsightWorker, SystemInsightWorker,
WirelessInsightWorker, and InsightLlmEnrichmentWorker as one-off
Oban jobs. Guarded by Scope.superuser?/1; non-superusers get a
flash error and the button is hidden.
index.html.heex — header is now a flex row with the button on the
right, rendered only when the current scope is a superuser. Uses
a confirm prompt before queueing.
Tests: insights_live_events_test.exs gains a "regenerate_insights
event" describe — non-superuser visibility, superuser visibility,
enqueue assertions for all 7 workers, and authorization rejection.
chore(dialyzer): fix all warnings, dialyzer now passes clean
mix.exs — added :tools to plt_add_apps so :xref calls in
Towerops.Unused are recognized.
Towerops.Preseem.Insight — added @type t :: %__MODULE__{} so
Towerops.LLM.InsightPrompt.build/1's @spec resolves.
Towerops.Unused — pinned unmatched returns from Code.ensure_loaded/1
and :xref.set_default/2; added @dialyzer
{:nowarn_function, analyse_beams: 1} for a known MapSet opaque
false-positive in collect_called_set/1.
Towerops.Workers.InsightLlmEnrichmentWorker — pinned the
Towerops.LLM.Usage.record/1 return so unmatched_returns is happy.
chore(e2e): silence Node 25 DEP0205 deprecation warning
e2e/package.json — every Playwright-running script now sets
NODE_OPTIONS=--disable-warning=DEP0205. The deprecation is from
Playwright's internal esmLoaderHost.js (still present in 1.59.1
stable) and isn't fixable from our side.
2026-05-09
feat(insights): OpticalRxLow rule for fiber transceivers
Towerops.Recommendations.Rules.OpticalRxLow — flags fiber

View file

@ -4,13 +4,13 @@
"type": "module",
"description": "End-to-end tests for Towerops",
"scripts": {
"test": "playwright test",
"test:local": "BASE_URL=http://localhost:4000 playwright test",
"test:staging": "BASE_URL=https://staging.towerops.net playwright test",
"test:ui": "playwright test --ui",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"codegen": "playwright codegen http://localhost:4000",
"test": "NODE_OPTIONS=--disable-warning=DEP0205 playwright test",
"test:local": "NODE_OPTIONS=--disable-warning=DEP0205 BASE_URL=http://localhost:4000 playwright test",
"test:staging": "NODE_OPTIONS=--disable-warning=DEP0205 BASE_URL=https://staging.towerops.net playwright test",
"test:ui": "NODE_OPTIONS=--disable-warning=DEP0205 playwright test --ui",
"test:headed": "NODE_OPTIONS=--disable-warning=DEP0205 playwright test --headed",
"test:debug": "NODE_OPTIONS=--disable-warning=DEP0205 playwright test --debug",
"codegen": "NODE_OPTIONS=--disable-warning=DEP0205 playwright codegen http://localhost:4000",
"report": "playwright show-report"
},
"devDependencies": {

View file

@ -16,6 +16,8 @@ defmodule Towerops.Preseem.Insight do
@valid_channels ~w(proactive contextual passive)
@valid_sources ~w(preseem snmp gaiia system)
@type t :: %__MODULE__{}
schema "preseem_insights" do
field :type, :string
field :urgency, :string

View file

@ -1,4 +1,7 @@
defmodule Towerops.Unused do
# Dialyzer can't reconcile MapSet's opaque internal type with what
# `MapSet.new/2` returns when given the {caller, callee} tuples from
# `:xref.q/2`. Suppressing the false positive on the one filter call.
@moduledoc """
Finds unused public functions across the project a from-scratch
reimplementation inspired by [`mix_unused`](https://github.com/hauleth/mix_unused).
@ -22,6 +25,8 @@ defmodule Towerops.Unused do
Run `mix unused` to print the report.
"""
@dialyzer {:nowarn_function, analyse_beams: 1}
@doc """
Runs an xref pass and returns a sorted list of
`{module, function, arity, file, line}` for public functions that no
@ -109,7 +114,14 @@ defmodule Towerops.Unused do
case :xref.start(name) do
{:ok, pid} ->
:xref.set_default(pid, builtins: false, recurse: false, verbose: false, warnings: false)
_ =
:xref.set_default(pid,
builtins: false,
recurse: false,
verbose: false,
warnings: false
)
pid
{:error, {:already_started, pid}} ->
@ -142,7 +154,7 @@ defmodule Towerops.Unused do
end
defp source_for(module) when is_atom(module) do
Code.ensure_loaded(module)
_ = Code.ensure_loaded(module)
if function_exported?(module, :module_info, 1) do
compile_info = module.module_info(:compile)

View file

@ -39,13 +39,14 @@ defmodule Towerops.Workers.InsightLlmEnrichmentWorker do
# Token-usage tracking lives in its own table — every LLM
# consumer in the codebase records here so the per-org daily
# cost is queryable in one place.
Usage.record(%{
organization_id: insight.organization_id,
model: model,
purpose: "insight_enrichment",
prompt_tokens: Map.get(response, :prompt_tokens) || 0,
completion_tokens: Map.get(response, :completion_tokens) || 0
})
_ =
Usage.record(%{
organization_id: insight.organization_id,
model: model,
purpose: "insight_enrichment",
prompt_tokens: Map.get(response, :prompt_tokens) || 0,
completion_tokens: Map.get(response, :completion_tokens) || 0
})
:ok

View file

@ -4,9 +4,20 @@ defmodule ToweropsWeb.InsightsLive.Index do
"""
use ToweropsWeb, :live_view
alias Towerops.Accounts.Scope
alias Towerops.Preseem
alias Towerops.Repo
@regeneration_workers [
Towerops.Workers.PreseemBaselineWorker,
Towerops.Workers.CapacityInsightWorker,
Towerops.Workers.DeviceHealthInsightWorker,
Towerops.Workers.GaiiaInsightWorker,
Towerops.Workers.SystemInsightWorker,
Towerops.Workers.WirelessInsightWorker,
Towerops.Workers.InsightLlmEnrichmentWorker
]
@impl true
def mount(_params, _session, socket) do
org = socket.assigns.current_scope.organization
@ -92,6 +103,24 @@ defmodule ToweropsWeb.InsightsLive.Index do
{:noreply, assign(socket, :selected_ids, MapSet.new())}
end
@impl true
def handle_event("regenerate_insights", _params, socket) do
if Scope.superuser?(socket.assigns.current_scope) do
Enum.each(@regeneration_workers, fn worker ->
%{} |> worker.new() |> Oban.insert()
end)
{:noreply,
put_flash(
socket,
:info,
t("Insight regeneration queued. New insights will appear shortly.")
)}
else
{:noreply, put_flash(socket, :error, t("Not authorized."))}
end
end
@impl true
def handle_event("bulk_dismiss", _params, socket) do
ids = MapSet.to_list(socket.assigns.selected_ids)

View file

@ -3,13 +3,28 @@
current_scope={@current_scope}
active_page="insights"
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t("Network Insights")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Proactive network health observations from all sources.")}
</p>
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
<div>
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
{t("Network Insights")}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Proactive network health observations from all sources.")}
</p>
</div>
<%= if Towerops.Accounts.Scope.superuser?(@current_scope) do %>
<button
type="button"
id="regenerate-insights-btn"
phx-click="regenerate_insights"
data-confirm={t("Queue insight regeneration jobs for all sources?")}
class="inline-flex items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
<.icon name="hero-arrow-path" class="h-4 w-4" />
{t("Regenerate insights")}
</button>
<% end %>
</div>
<%!-- Filter bar --%>

View file

@ -162,7 +162,8 @@ defmodule Towerops.MixProject do
:public_key,
:geo,
:geo_postgis,
:db_connection
:db_connection,
:tools
],
flags: [:unmatched_returns, :error_handling, :underspecs, :unknown],
ignore_warnings: ".dialyzer_ignore.exs"

View file

@ -4,6 +4,7 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
toggle_select, select_all, deselect_all, bulk_dismiss).
"""
use ToweropsWeb.ConnCase, async: true
use Oban.Testing, repo: Towerops.Repo
import Phoenix.LiveViewTest
@ -205,6 +206,57 @@ defmodule ToweropsWeb.InsightsLive.IndexEventsTest do
end
end
describe "regenerate_insights event" do
alias Towerops.Workers.CapacityInsightWorker
alias Towerops.Workers.DeviceHealthInsightWorker
alias Towerops.Workers.GaiiaInsightWorker
alias Towerops.Workers.InsightLlmEnrichmentWorker
alias Towerops.Workers.PreseemBaselineWorker
alias Towerops.Workers.SystemInsightWorker
alias Towerops.Workers.WirelessInsightWorker
test "non-superusers do not see the regenerate button", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/insights")
refute html =~ "Regenerate insights"
end
test "superusers see the regenerate button", %{conn: conn, user: user} do
promote_to_superuser(user)
{:ok, _view, html} = live(conn, ~p"/insights")
assert html =~ "Regenerate insights"
end
test "regenerate_insights enqueues all insight workers for superusers", %{conn: conn, user: user} do
promote_to_superuser(user)
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "regenerate_insights", %{})
assert_enqueued(worker: PreseemBaselineWorker)
assert_enqueued(worker: CapacityInsightWorker)
assert_enqueued(worker: DeviceHealthInsightWorker)
assert_enqueued(worker: GaiiaInsightWorker)
assert_enqueued(worker: SystemInsightWorker)
assert_enqueued(worker: WirelessInsightWorker)
assert_enqueued(worker: InsightLlmEnrichmentWorker)
end
test "regenerate_insights is rejected for non-superusers", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/insights")
_ = render_hook(view, "regenerate_insights", %{})
refute_enqueued(worker: PreseemBaselineWorker)
refute_enqueued(worker: CapacityInsightWorker)
end
defp promote_to_superuser(user) do
user
|> Ecto.Changeset.change(is_superuser: true)
|> Repo.update!()
end
end
describe "ap_frequency_change rendering" do
test "shows current vs recommended channel and the top offender", %{
conn: conn,