- 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
179 lines
5.8 KiB
Elixir
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
|