From db40a3e5b75bad7e5e72f3cfdd2422ebac37dab8 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 14:11:02 -0600 Subject: [PATCH] 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 --- lib/towerops/gaiia/sync.ex | 23 +++++++++++++++++-- lib/towerops/integrations.ex | 5 ++-- lib/towerops/integrations/integration.ex | 4 +++- lib/towerops/preseem/sync.ex | 13 +++++++++-- .../api/v1/integrations_controller.ex | 1 + .../live/org/settings_live.html.heex | 12 ++++++++++ ..._add_last_sync_message_to_integrations.exs | 9 ++++++++ 7 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 priv/repo/migrations/20260214200600_add_last_sync_message_to_integrations.exs diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 9988056a..7baf706b 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -32,7 +32,11 @@ defmodule Towerops.Gaiia.Sync do items_count = upsert_nodes(org_id, items, :inventory_item), {:ok, subs} <- Client.list_billing_subscriptions(api_key, opts) do subs_count = upsert_nodes(org_id, subs, :billing_subscription) - Integrations.update_sync_status(integration, "success") + + message = + "Synced #{accounts_count} accounts, #{sites_count} sites, #{items_count} inventory items, #{subs_count} subscriptions" + + Integrations.update_sync_status(integration, "success", message) {:ok, %{ @@ -43,7 +47,7 @@ defmodule Towerops.Gaiia.Sync do }} else {:error, reason} -> - Integrations.update_sync_status(integration, "failed") + Integrations.update_sync_status(integration, "failed", humanize_sync_error(reason)) {:error, reason} end end @@ -170,4 +174,19 @@ defmodule Towerops.Gaiia.Sync do endpoint -> [endpoint: endpoint] end end + + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key" + + defp humanize_sync_error(:forbidden), do: "Access denied — your API key may not have sufficient permissions" + + defp humanize_sync_error(:not_found), do: "API endpoint not found — the Gaiia API may have changed" + + defp humanize_sync_error(:timeout), do: "Connection timed out — Gaiia may be temporarily unavailable" + + defp humanize_sync_error(%Req.TransportError{reason: :econnrefused}), do: "Connection refused — unable to reach Gaiia" + + defp humanize_sync_error(%Req.TransportError{reason: reason}), do: "Connection error: #{reason}" + + defp humanize_sync_error(reason) when is_binary(reason), do: reason + defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync" end diff --git a/lib/towerops/integrations.ex b/lib/towerops/integrations.ex index 0aa35cc0..d4129465 100644 --- a/lib/towerops/integrations.ex +++ b/lib/towerops/integrations.ex @@ -37,11 +37,12 @@ defmodule Towerops.Integrations do |> Repo.update() end - def update_sync_status(%Integration{} = integration, status) do + def update_sync_status(%Integration{} = integration, status, message \\ nil) do integration |> Integration.changeset(%{ last_sync_status: status, - last_synced_at: DateTime.truncate(DateTime.utc_now(), :second) + last_synced_at: DateTime.truncate(DateTime.utc_now(), :second), + last_sync_message: message }) |> Repo.update() end diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 3e77f545..d57cb46b 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -24,6 +24,7 @@ defmodule Towerops.Integrations.Integration do field :sync_interval_minutes, :integer, default: 10 field :last_synced_at, :utc_datetime field :last_sync_status, :string, default: "never" + field :last_sync_message, :string belongs_to :organization, Towerops.Organizations.Organization @@ -39,7 +40,8 @@ defmodule Towerops.Integrations.Integration do :credentials, :sync_interval_minutes, :last_synced_at, - :last_sync_status + :last_sync_status, + :last_sync_message ]) |> validate_required([:organization_id, :provider]) |> validate_inclusion(:provider, @valid_providers) diff --git a/lib/towerops/preseem/sync.ex b/lib/towerops/preseem/sync.ex index 35180c10..67c25b75 100644 --- a/lib/towerops/preseem/sync.ex +++ b/lib/towerops/preseem/sync.ex @@ -117,8 +117,9 @@ defmodule Towerops.Preseem.Sync do 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) + Integrations.update_sync_status(integration, status, message) {:ok, %{synced: synced_count, matched: matched_count}} end @@ -126,8 +127,9 @@ defmodule Towerops.Preseem.Sync do 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") + Integrations.update_sync_status(integration, "failed", message) {:error, reason} end @@ -167,4 +169,11 @@ defmodule Towerops.Preseem.Sync do 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 diff --git a/lib/towerops_web/controllers/api/v1/integrations_controller.ex b/lib/towerops_web/controllers/api/v1/integrations_controller.ex index f6fc7e82..49e433d9 100644 --- a/lib/towerops_web/controllers/api/v1/integrations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/integrations_controller.ex @@ -103,6 +103,7 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do sync_interval_minutes: integration.sync_interval_minutes, last_synced_at: integration.last_synced_at, last_sync_status: integration.last_sync_status, + last_sync_message: integration.last_sync_message, inserted_at: integration.inserted_at, updated_at: integration.updated_at } diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 8c2ca4cd..accfa4d3 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -866,6 +866,18 @@ <% end %> + <%= if integration.last_sync_message && integration.last_sync_message != "" do %> + + {integration.last_sync_message} + + <% end %> + <%= if integration.last_synced_at do %> · Next sync in ~{next_sync_minutes( diff --git a/priv/repo/migrations/20260214200600_add_last_sync_message_to_integrations.exs b/priv/repo/migrations/20260214200600_add_last_sync_message_to_integrations.exs new file mode 100644 index 00000000..b49061b4 --- /dev/null +++ b/priv/repo/migrations/20260214200600_add_last_sync_message_to_integrations.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddLastSyncMessageToIntegrations do + use Ecto.Migration + + def change do + alter table(:integrations) do + add :last_sync_message, :text + end + end +end