- Regenerate button now logs Oban.insert failures and shows per-worker counts - AI network insight worker: log errors for LLM failures, insert failures, Usage.record failures, and transaction failures (was discarding all) - Enrichment worker: handle apply_llm_enrichment errors instead of crashing, log LLM and Usage.record failures - Wire up build_gaiia in network snapshot to actually query reconciliation finding counts instead of hardcoding zeros
223 lines
6.9 KiB
Elixir
223 lines
6.9 KiB
Elixir
defmodule ToweropsWeb.InsightsLive.Index do
|
|
@moduledoc """
|
|
Unified insights page showing insights from all sources (Preseem, Gaiia, SNMP, System).
|
|
"""
|
|
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,
|
|
Towerops.Workers.AiNetworkInsightWorker
|
|
]
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
org = socket.assigns.current_scope.organization
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:organization, org)
|
|
|> assign(:page_title, t("Insights"))
|
|
|> assign(:selected_ids, MapSet.new())}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
filter_source = params["source"]
|
|
filter_urgency = params["urgency"]
|
|
filter_status = params["status"] || "active"
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:filter_source, filter_source)
|
|
|> assign(:filter_urgency, filter_urgency)
|
|
|> assign(:filter_status, filter_status)
|
|
|> assign(:selected_ids, MapSet.new())
|
|
|> load_insights()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("filter", params, socket) do
|
|
query_params = %{}
|
|
|
|
query_params =
|
|
if params["source"] && params["source"] != "",
|
|
do: Map.put(query_params, "source", params["source"]),
|
|
else: query_params
|
|
|
|
query_params =
|
|
if params["urgency"] && params["urgency"] != "",
|
|
do: Map.put(query_params, "urgency", params["urgency"]),
|
|
else: query_params
|
|
|
|
query_params =
|
|
if params["status"] && params["status"] != "",
|
|
do: Map.put(query_params, "status", params["status"]),
|
|
else: query_params
|
|
|
|
{:noreply, push_patch(socket, to: ~p"/insights?#{query_params}")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("dismiss", %{"id" => id}, socket) do
|
|
case Preseem.dismiss_insight(id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> load_insights()
|
|
|> put_flash(:info, t("Insight dismissed"))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t("Failed to dismiss"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_select", %{"id" => id}, socket) do
|
|
selected = socket.assigns.selected_ids
|
|
|
|
selected =
|
|
if MapSet.member?(selected, id),
|
|
do: MapSet.delete(selected, id),
|
|
else: MapSet.put(selected, id)
|
|
|
|
{:noreply, assign(socket, :selected_ids, selected)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("select_all", _params, socket) do
|
|
ids = MapSet.new(socket.assigns.insights, & &1.id)
|
|
{:noreply, assign(socket, :selected_ids, ids)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("deselect_all", _params, socket) 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
|
|
{ok_count, fail_count} = insert_regeneration_jobs()
|
|
|
|
flash =
|
|
if fail_count == 0 do
|
|
{:info, t("Insight regeneration queued. New insights will appear shortly.")}
|
|
else
|
|
{:error,
|
|
"%{ok} worker queued, %{fail} worker failed to queue. Check logs."
|
|
|> ngettext(
|
|
"%{ok} workers queued, %{fail} workers failed to queue. Check logs.",
|
|
ok_count
|
|
)
|
|
|> then(&Gettext.dngettext(ToweropsWeb.Gettext, "", &1, &1, ok_count: ok_count, fail: fail_count))}
|
|
end
|
|
|
|
{:noreply, put_flash(socket, elem(flash, 0), elem(flash, 1))}
|
|
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)
|
|
|
|
if ids == [] do
|
|
{:noreply, socket}
|
|
else
|
|
{:ok, count} = Preseem.dismiss_insights(ids)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:selected_ids, MapSet.new())
|
|
|> load_insights()
|
|
|> put_flash(:info, "#{count} insight(s) dismissed")}
|
|
end
|
|
end
|
|
|
|
defp insert_regeneration_jobs do
|
|
require Logger
|
|
|
|
Enum.reduce(@regeneration_workers, {0, 0}, fn worker, {ok, fail} ->
|
|
case %{} |> worker.new() |> Oban.insert() do
|
|
{:ok, _} ->
|
|
{ok + 1, fail}
|
|
|
|
{:error, changeset} ->
|
|
Logger.error("Failed to insert #{inspect(worker)}: #{inspect(changeset.errors)}")
|
|
{ok, fail + 1}
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp load_insights(socket) do
|
|
org_id = socket.assigns.organization.id
|
|
opts = [status: socket.assigns.filter_status]
|
|
|
|
opts =
|
|
if socket.assigns.filter_source,
|
|
do: Keyword.put(opts, :source, socket.assigns.filter_source),
|
|
else: opts
|
|
|
|
opts =
|
|
if socket.assigns.filter_urgency,
|
|
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
|
|
else: opts
|
|
|
|
insights =
|
|
org_id
|
|
|> Preseem.list_insights(opts)
|
|
|> Repo.preload([:preseem_access_point, :device])
|
|
|
|
assign(socket, :insights, insights)
|
|
end
|
|
|
|
@doc false
|
|
def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
@doc false
|
|
def source_classes("preseem"), do: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
|
|
def source_classes("gaiia"), do: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
|
|
def source_classes("snmp"), do: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
|
|
def source_classes("system"), do: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
|
def source_classes("ai"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
|
def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
@doc """
|
|
Display label for a source. Acronyms render uppercase; everything else
|
|
capitalises the first letter so the badge reads "Preseem" rather than
|
|
"preseem". Picked over CSS `capitalize` because that produces "Snmp"
|
|
and "Ai" instead of "SNMP" and "AI".
|
|
"""
|
|
def source_label("ai"), do: "AI"
|
|
def source_label("snmp"), do: "SNMP"
|
|
def source_label(other) when is_binary(other), do: String.capitalize(other)
|
|
def source_label(other), do: other
|
|
|
|
@doc false
|
|
def build_filter_params(base, overrides) do
|
|
base
|
|
|> Map.merge(overrides)
|
|
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
|
|> Map.new()
|
|
end
|
|
|
|
@doc false
|
|
def selected?(selected_ids, id), do: MapSet.member?(selected_ids, id)
|
|
|
|
@doc false
|
|
def any_selected?(selected_ids), do: MapSet.size(selected_ids) > 0
|
|
end
|