towerops/lib/towerops/preseem/sync.ex
Graham McIntie db40a3e5b7 feat: show human-readable sync status messages for integrations
- Added last_sync_message field to integrations schema
- Gaiia sync: shows item counts on success, friendly error on failure
- Preseem sync: shows AP/device counts on success, friendly error on failure
- Error messages translated from technical errors to user-friendly text
- Message displayed on integration card next to status badge
- Exposed in REST API response
2026-02-14 14:11:02 -06:00

179 lines
5.8 KiB
Elixir

defmodule Towerops.Preseem.Sync do
@moduledoc """
Orchestrates syncing data from the Preseem API into the local database.
Pulls access point scores from Preseem, upserts them
into `preseem_access_points`, and logs each sync operation.
"""
alias Towerops.Integrations
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Client
alias Towerops.Preseem.DeviceMatcher
alias Towerops.Preseem.SyncLog
alias Towerops.Repo
require Logger
@doc """
Main entry point: syncs all access points for the given integration.
Fetches AP scores from the Preseem API, upserts them, writes a sync log,
and updates the integration's sync status.
Returns `{:ok, %{synced: count, matched: count}}` or `{:error, reason}`.
"""
def sync_organization(%Integrations.Integration{} = integration) do
start_time = System.monotonic_time(:millisecond)
api_key = integration.credentials["api_key"]
org_id = integration.organization_id
case Client.list_access_points(api_key) do
{:ok, ap_list} ->
handle_successful_sync(integration, org_id, ap_list, start_time)
{:error, reason} ->
handle_failed_sync(integration, reason, start_time)
end
end
@doc """
Upserts a single access point from raw API response data.
Maps Preseem scores API fields to schema fields and uses the unique constraint on
`[:organization_id, :preseem_id]` for conflict resolution.
"""
def upsert_access_point(organization_id, ap_data) do
attrs = %{
organization_id: organization_id,
preseem_id: ap_data["element_uuid"],
name: ap_data["name"],
mac_address: ap_data["system_mac_address"],
ip_address: ap_data["management_ip"],
model: ap_data["model_name"],
firmware: ap_data["firmware"],
capacity_score: cast_to_float(ap_data["subscriber_capacity"]),
qoe_score: cast_to_float(ap_data["ap_health_score"]),
rf_score: cast_to_float(ap_data["rf_score"]),
busy_hours: nil,
airtime_utilization: cast_to_float(ap_data["airtime_remaining_tx"]),
subscriber_count: ap_data["connections"],
raw_data: ap_data
}
%AccessPoint{}
|> AccessPoint.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace_all_except, [:id, :organization_id, :preseem_id, :device_id, :match_confidence, :inserted_at]},
conflict_target: [:organization_id, :preseem_id],
returning: true
)
end
@doc """
Inserts a subscriber metric record for the given access point.
"""
def insert_metrics(access_point_id, metrics_data) do
alias Towerops.Preseem.SubscriberMetric
recorded_at =
case metrics_data["recorded_at"] do
nil -> DateTime.truncate(DateTime.utc_now(), :second)
dt_string -> parse_datetime(dt_string)
end
attrs = %{
preseem_access_point_id: access_point_id,
avg_latency: metrics_data["avg_latency"],
avg_jitter: metrics_data["avg_jitter"],
avg_loss: metrics_data["avg_loss"],
avg_throughput: metrics_data["avg_throughput"],
p95_latency: metrics_data["p95_latency"],
subscriber_count: metrics_data["subscriber_count"],
recorded_at: recorded_at
}
%SubscriberMetric{}
|> SubscriberMetric.changeset(attrs)
|> Repo.insert()
end
defp handle_successful_sync(integration, org_id, ap_list, start_time) do
synced_count =
Enum.reduce(ap_list, 0, fn ap_data, count ->
case upsert_access_point(org_id, ap_data) do
{:ok, _ap} ->
count + 1
{:error, changeset} ->
Logger.warning("Failed to upsert Preseem AP: #{inspect(changeset.errors)}")
count
end
end)
matched_count = run_device_matching(org_id)
duration_ms = System.monotonic_time(:millisecond) - start_time
status = if synced_count == length(ap_list), do: "success", else: "partial"
message = "Synced #{synced_count} access points, matched #{matched_count} devices"
log_sync(integration, status, synced_count, %{}, duration_ms)
Integrations.update_sync_status(integration, status, message)
{:ok, %{synced: synced_count, matched: matched_count}}
end
defp handle_failed_sync(integration, reason, start_time) do
duration_ms = System.monotonic_time(:millisecond) - start_time
message = humanize_sync_error(reason)
log_sync(integration, "failed", 0, %{error: inspect(reason)}, duration_ms)
Integrations.update_sync_status(integration, "failed", message)
{:error, reason}
end
defp run_device_matching(org_id) do
if Code.ensure_loaded?(DeviceMatcher) do
case DeviceMatcher.match_unmatched(org_id) do
{:ok, count} -> count
_ -> 0
end
else
0
end
end
defp log_sync(integration, status, records_synced, errors, duration_ms) do
%SyncLog{}
|> SyncLog.changeset(%{
organization_id: integration.organization_id,
integration_id: integration.id,
status: status,
records_synced: records_synced,
errors: errors,
duration_ms: duration_ms,
inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.insert()
end
defp parse_datetime(dt_string) when is_binary(dt_string) do
case DateTime.from_iso8601(dt_string) do
{:ok, dt, _offset} -> DateTime.truncate(dt, :second)
_ -> DateTime.truncate(DateTime.utc_now(), :second)
end
end
defp cast_to_float(nil), do: nil
defp cast_to_float(val) when is_float(val), do: val
defp cast_to_float(val) when is_integer(val), do: val / 1
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key"
defp humanize_sync_error(:timeout), do: "Connection timed out — Preseem may be temporarily unavailable"
defp humanize_sync_error(reason) when is_binary(reason), do: reason
defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync"
end