From bdfb20efdff93eebab9bef80e7a62df08efcbc60 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 10 May 2026 13:41:25 -0500 Subject: [PATCH] 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. --- CHANGELOG.txt | 33 ++++++++++++ e2e/package.json | 14 ++--- lib/towerops/preseem/insight.ex | 2 + lib/towerops/unused.ex | 16 +++++- .../workers/insight_llm_enrichment_worker.ex | 15 +++--- lib/towerops_web/live/insights_live/index.ex | 29 +++++++++++ .../live/insights_live/index.html.heex | 29 ++++++++--- mix.exs | 3 +- .../live/insights_live_events_test.exs | 52 +++++++++++++++++++ 9 files changed, 169 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 963b4cfc..ae21d077 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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 diff --git a/e2e/package.json b/e2e/package.json index 24cb8469..6ce67df7 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -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": { diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 8cbd8eeb..4e3367fc 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -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 diff --git a/lib/towerops/unused.ex b/lib/towerops/unused.ex index 9c43d6ee..d4f60b60 100644 --- a/lib/towerops/unused.ex +++ b/lib/towerops/unused.ex @@ -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) diff --git a/lib/towerops/workers/insight_llm_enrichment_worker.ex b/lib/towerops/workers/insight_llm_enrichment_worker.ex index f6202b88..bbf3ddb7 100644 --- a/lib/towerops/workers/insight_llm_enrichment_worker.ex +++ b/lib/towerops/workers/insight_llm_enrichment_worker.ex @@ -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 diff --git a/lib/towerops_web/live/insights_live/index.ex b/lib/towerops_web/live/insights_live/index.ex index 904691cb..d48f846a 100644 --- a/lib/towerops_web/live/insights_live/index.ex +++ b/lib/towerops_web/live/insights_live/index.ex @@ -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) diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index d6b73bbc..6518b29d 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -3,13 +3,28 @@ current_scope={@current_scope} active_page="insights" > -
-

- {t("Network Insights")} -

-

- {t("Proactive network health observations from all sources.")} -

+
+
+

+ {t("Network Insights")} +

+

+ {t("Proactive network health observations from all sources.")} +

+
+ + <%= if Towerops.Accounts.Scope.superuser?(@current_scope) do %> + + <% end %>
<%!-- Filter bar --%> diff --git a/mix.exs b/mix.exs index d28519c2..7c4e037f 100644 --- a/mix.exs +++ b/mix.exs @@ -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" diff --git a/test/towerops_web/live/insights_live_events_test.exs b/test/towerops_web/live/insights_live_events_test.exs index c83213fb..bf4da972 100644 --- a/test/towerops_web/live/insights_live_events_test.exs +++ b/test/towerops_web/live/insights_live_events_test.exs @@ -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,