towerops/lib/towerops/preseem/insights.ex
Graham McIntire 20f5f48372
feat: command center dashboard with unified insights and contextual enrichment
Transform the dashboard into a single-pane-of-glass command center that
blends operational metrics with business context from Gaiia subscriber data.

- Extend insight schema with multi-source support (snmp, gaiia, system)
- Add Oban workers for automated insight generation (device health, system, gaiia)
- New Dashboard context with health score computation and site summaries
- Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid
- Add source filter pills for insights with URL state management
- Add metrics bar to site detail pages (device count, alerts, subscribers, MRR)
- Add subscriber/MRR context to alert list site links
- Add subscriber badges to device list site headers
- Real-time PubSub updates for alerts on dashboard
2026-02-13 14:52:49 -06:00

325 lines
9.1 KiB
Elixir

defmodule Towerops.Preseem.Insights do
@moduledoc """
Generates and manages Preseem insights based on baselines and fleet data.
"""
import Ecto.Query
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.DeviceBaseline
alias Towerops.Preseem.FleetProfile
alias Towerops.Preseem.Insight
alias Towerops.Repo
# --- Query functions ---
@doc "List active insights for an organization, ordered by urgency then date."
def list_insights(organization_id, opts \\ []) do
status = Keyword.get(opts, :status, "active")
type = Keyword.get(opts, :type)
urgency = Keyword.get(opts, :urgency)
source = Keyword.get(opts, :source)
limit = Keyword.get(opts, :limit, 50)
query =
Insight
|> where(organization_id: ^organization_id)
|> where(status: ^status)
|> order_by_urgency()
|> limit(^limit)
query = if type, do: where(query, type: ^type), else: query
query = if urgency, do: where(query, urgency: ^urgency), else: query
query = if source, do: where(query, source: ^source), else: query
Repo.all(query)
end
@doc "List active insights for a specific site."
def list_site_insights(site_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
Insight
|> where(site_id: ^site_id, status: "active")
|> order_by_urgency()
|> limit(^limit)
|> Repo.all()
end
@doc "Count active insights grouped by urgency for an organization."
def count_active_by_urgency(organization_id) do
results =
Insight
|> where(organization_id: ^organization_id, status: "active")
|> group_by(:urgency)
|> select([i], {i.urgency, count(i.id)})
|> Repo.all()
|> Map.new()
%{
critical: Map.get(results, "critical", 0),
warning: Map.get(results, "warning", 0),
info: Map.get(results, "info", 0)
}
end
@doc "List active insights for a specific device."
def list_device_insights(device_id) do
Insight
|> where(device_id: ^device_id, status: "active")
|> order_by_urgency()
|> Repo.all()
end
@doc "Dismiss an insight."
def dismiss_insight(insight_id) do
case Repo.get(Insight, insight_id) do
nil ->
{:error, :not_found}
insight ->
insight
|> Insight.changeset(%{
status: "dismissed",
dismissed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
end
end
@doc "Bulk dismiss insights by IDs."
def dismiss_insights(insight_ids) when is_list(insight_ids) do
now = DateTime.truncate(DateTime.utc_now(), :second)
{count, _} =
Insight
|> where([i], i.id in ^insight_ids)
|> where(status: "active")
|> Repo.update_all(set: [status: "dismissed", dismissed_at: now])
{:ok, count}
end
@doc """
Insert an insight if no active insight with the same type exists for the same entity.
Deduplicates by: device_id + type, site_id + type, or agent_token_id + type.
Returns `{:ok, insight}` on insert, `{:ok, :duplicate}` if already exists.
"""
def insert_insight_if_new(attrs) do
dedup_query = build_dedup_query(attrs)
if dedup_query && Repo.exists?(dedup_query) do
{:ok, :duplicate}
else
case %Insight{} |> Insight.changeset(attrs) |> Repo.insert() do
{:ok, insight} -> {:ok, insight}
{:error, changeset} -> {:error, changeset}
end
end
end
defp build_dedup_query(%{device_id: device_id, type: type}) when not is_nil(device_id) do
where(Insight, device_id: ^device_id, type: ^type, status: "active")
end
defp build_dedup_query(%{site_id: site_id, type: type}) when not is_nil(site_id) do
where(Insight, site_id: ^site_id, type: ^type, status: "active")
end
defp build_dedup_query(%{agent_token_id: agent_token_id, type: type}) when not is_nil(agent_token_id) do
where(Insight, agent_token_id: ^agent_token_id, type: ^type, status: "active")
end
defp build_dedup_query(%{organization_id: org_id, type: type, dedup_key: dedup_key})
when not is_nil(org_id) and not is_nil(dedup_key) do
Insight
|> where(organization_id: ^org_id, type: ^type, status: "active")
|> where([i], fragment("?->>'dedup_key' = ?", i.metadata, ^dedup_key))
end
defp build_dedup_query(_attrs), do: nil
# --- Generation functions ---
@doc "Generate insights for all matched APs in an organization."
def generate_insights(organization_id) do
matched_aps =
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], not is_nil(ap.device_id))
|> Repo.all()
fleet_profiles =
FleetProfile
|> where(organization_id: ^organization_id)
|> Repo.all()
count =
Enum.reduce(matched_aps, 0, fn ap, acc ->
baselines =
DeviceBaseline
|> where(preseem_access_point_id: ^ap.id)
|> Repo.all()
new_insights = generate_ap_insights(ap, baselines, fleet_profiles)
acc + length(new_insights)
end)
{:ok, count}
end
defp generate_ap_insights(ap, baselines, fleet_profiles) do
candidate_insights =
check_qoe_degradation(ap, baselines) ++
check_capacity_saturation(ap, baselines) ++
check_model_underperforming(ap, fleet_profiles)
Enum.flat_map(candidate_insights, fn attrs ->
full_attrs =
Map.merge(attrs, %{
organization_id: ap.organization_id,
preseem_access_point_id: ap.id,
device_id: ap.device_id
})
insert_insight_if_new(ap.id, full_attrs)
end)
end
defp insert_insight_if_new(ap_id, full_attrs) do
existing =
Repo.get_by(Insight,
preseem_access_point_id: ap_id,
type: full_attrs.type,
status: "active"
)
if existing do
[]
else
case %Insight{} |> Insight.changeset(full_attrs) |> Repo.insert() do
{:ok, insight} -> [insight]
{:error, _} -> []
end
end
end
defp check_qoe_degradation(ap, baselines) do
qoe_baseline = Enum.find(baselines, &(&1.metric_name == "qoe_score" and &1.period == "all"))
cond do
is_nil(qoe_baseline) ->
[]
is_nil(ap.qoe_score) ->
[]
ap.qoe_score < qoe_baseline.mean - 2 * qoe_baseline.stddev ->
urgency =
if ap.qoe_score < qoe_baseline.mean - 3 * qoe_baseline.stddev,
do: "critical",
else: "warning"
[
%{
type: "qoe_degradation",
urgency: urgency,
channel: "proactive",
title: "QoE degradation on #{ap.name}",
description:
"QoE score #{Float.round(ap.qoe_score, 1)} is significantly below the " <>
"14-day baseline of #{Float.round(qoe_baseline.mean, 1)} " <>
"(stddev: #{Float.round(qoe_baseline.stddev, 1)}).",
metadata: %{
"current" => ap.qoe_score,
"baseline_mean" => qoe_baseline.mean,
"baseline_stddev" => qoe_baseline.stddev
}
}
]
true ->
[]
end
end
defp check_capacity_saturation(ap, baselines) do
sub_baseline =
Enum.find(baselines, &(&1.metric_name == "subscriber_count" and &1.period == "all"))
cond do
is_nil(sub_baseline) ->
[]
is_nil(ap.subscriber_count) ->
[]
ap.subscriber_count > sub_baseline.p95 ->
[
%{
type: "capacity_saturation",
urgency: "warning",
channel: "proactive",
title: "#{ap.name} approaching capacity",
description:
"Subscriber count #{ap.subscriber_count} exceeds the 95th percentile " <>
"(#{round(sub_baseline.p95)}) of the 14-day baseline.",
metadata: %{
"current_subscribers" => ap.subscriber_count,
"p95" => sub_baseline.p95
}
}
]
true ->
[]
end
end
defp check_model_underperforming(ap, fleet_profiles) do
matching_profile =
Enum.find(fleet_profiles, fn fp ->
ap.model != nil and fp.model == ap.model
end)
cond do
is_nil(matching_profile) ->
[]
is_nil(ap.qoe_score) or is_nil(matching_profile.avg_qoe_score) ->
[]
ap.qoe_score < matching_profile.avg_qoe_score * 0.8 ->
[
%{
type: "model_underperforming",
urgency: "info",
channel: "passive",
title: "#{ap.name} underperforming fleet average",
description:
"QoE score #{Float.round(ap.qoe_score, 1)} is below the fleet average of " <>
"#{Float.round(matching_profile.avg_qoe_score, 1)} for " <>
"#{matching_profile.model} devices.",
metadata: %{
"current_qoe" => ap.qoe_score,
"fleet_avg_qoe" => matching_profile.avg_qoe_score,
"model" => matching_profile.model
}
}
]
true ->
[]
end
end
defp order_by_urgency(query) do
order_by(query, [i], [
fragment(
"CASE ? WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 WHEN 'info' THEN 2 END",
i.urgency
),
desc: i.inserted_at
])
end
end