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
This commit is contained in:
Graham McIntire 2026-02-14 14:11:02 -06:00
parent ddae90f06c
commit db40a3e5b7
7 changed files with 60 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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
}

View file

@ -866,6 +866,18 @@
</span>
<% end %>
<%= if integration.last_sync_message && integration.last_sync_message != "" do %>
<span class={[
"text-xs",
if(integration.last_sync_status == "failed",
do: "text-red-600 dark:text-red-400",
else: "text-gray-500 dark:text-gray-400"
)
]}>
{integration.last_sync_message}
</span>
<% end %>
<%= if integration.last_synced_at do %>
<span class="text-xs text-gray-400 dark:text-gray-500">
· Next sync in ~{next_sync_minutes(

View file

@ -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