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:
parent
01d64a446e
commit
bdfb20efdf
9 changed files with 169 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 --%>
|
||||
|
|
|
|||
3
mix.exs
3
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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue