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
325 lines
9.1 KiB
Elixir
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
|