towerops/lib/towerops/preseem.ex
Graham McIntire 8d00a04187
feat: add FluxCD image automation, fix Preseem duplicate AP crash
Switch deployment pipeline from GitLab Agent kubectl to FluxCD image
automation. CI now only builds and pushes tagged images to Forgejo
registry; FluxCD detects new tags and updates the deployment manifest.

Fix Ecto.MultipleResultsError on /dashboard when a device has multiple
Preseem access point records by using limit(1) instead of Repo.get_by.
2026-02-14 10:30:12 -06:00

152 lines
4.2 KiB
Elixir

defmodule Towerops.Preseem do
@moduledoc """
Context for querying Preseem integration data.
"""
import Ecto.Query
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insights
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
@doc "Get the Preseem AP linked to a specific device, or nil."
def get_access_point_for_device(device_id) do
AccessPoint
|> where(device_id: ^device_id)
|> limit(1)
|> Repo.one()
end
@doc "List all Preseem APs for an organization."
def list_access_points(organization_id) do
AccessPoint
|> where(organization_id: ^organization_id)
|> order_by(:name)
|> Repo.all()
end
@doc "List unmatched or ambiguous Preseem APs for an organization."
def list_unmatched_access_points(organization_id) do
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], ap.match_confidence in ["unmatched", "ambiguous"])
|> order_by(:name)
|> Repo.all()
end
@doc "List matched Preseem APs for an organization (everything except unmatched/ambiguous)."
def list_matched_access_points(organization_id) do
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], ap.match_confidence not in ["unmatched", "ambiguous"])
|> order_by(:name)
|> Repo.all()
end
@doc "List recent subscriber metrics for a Preseem AP. Default limit 100."
def list_subscriber_metrics(access_point_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
SubscriberMetric
|> where(preseem_access_point_id: ^access_point_id)
|> order_by(desc: :recorded_at)
|> limit(^limit)
|> Repo.all()
end
@doc "Link a Preseem AP to a device manually."
def link_access_point(access_point_id, device_id) do
case Repo.get(AccessPoint, access_point_id) do
nil ->
{:error, :not_found}
ap ->
ap
|> AccessPoint.changeset(%{device_id: device_id, match_confidence: "manual"})
|> Repo.update()
end
end
@doc "Unlink a Preseem AP from its device."
def unlink_access_point(access_point_id) do
case Repo.get(AccessPoint, access_point_id) do
nil ->
{:error, :not_found}
ap ->
ap
|> AccessPoint.changeset(%{device_id: nil, match_confidence: "unmatched"})
|> Repo.update()
end
end
@doc """
Returns an aggregate QoE summary for all APs at a site.
Returns a map with average QoE, latency, capacity info, and per-AP breakdowns.
"""
def get_site_qoe_summary(site_id) do
alias Towerops.Devices
device_ids =
site_id
|> Devices.list_site_devices()
|> Enum.map(& &1.id)
if device_ids == [] do
%{
avg_qoe: nil,
avg_capacity: nil,
total_subscribers: 0,
aps: [],
aps_with_issues: 0,
total_aps: 0
}
else
aps =
AccessPoint
|> where([ap], ap.device_id in ^device_ids)
|> Repo.all()
qoe_scores = aps |> Enum.map(& &1.qoe_score) |> Enum.reject(&is_nil/1)
capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1)
avg_qoe =
if qoe_scores == [],
do: nil,
else: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1)
avg_capacity =
if capacity_scores == [],
do: nil,
else: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1)
total_subscribers =
aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1) |> Enum.sum()
# APs with issues: QoE < 6.0 (on 1-10 scale) or capacity < 50
aps_with_issues =
Enum.count(aps, fn ap ->
(ap.qoe_score && ap.qoe_score < 6.0) ||
(ap.capacity_score && ap.capacity_score < 50.0)
end)
%{
avg_qoe: avg_qoe,
avg_capacity: avg_capacity,
total_subscribers: total_subscribers,
aps: aps,
aps_with_issues: aps_with_issues,
total_aps: length(aps)
}
end
end
# --- Insight delegations ---
defdelegate list_insights(organization_id, opts \\ []), to: Insights
defdelegate list_device_insights(device_id), to: Insights
defdelegate dismiss_insight(insight_id), to: Insights
defdelegate dismiss_insights(insight_ids), to: Insights
end