defmodule PhoenixKit.Modules.Sync.Web.ConnectionsLive do
@moduledoc """
LiveView for managing DB Sync permanent connections.
Allows creating, editing, and managing persistent connections between
PhoenixKit instances with access control settings.
"""
use PhoenixKitWeb, :live_view
use Gettext, backend: PhoenixKitWeb.Gettext
require Logger
alias PhoenixKit.Modules.Sync
alias PhoenixKit.Modules.Sync.Connection
alias PhoenixKit.Modules.Sync.ConnectionNotifier
alias PhoenixKit.Modules.Sync.Connections
alias PhoenixKit.Modules.Sync.SchemaInspector
alias PhoenixKit.Settings
alias PhoenixKit.Utils.Date, as: UtilsDate
alias PhoenixKit.Utils.Routes
@impl true
def mount(params, _session, socket) do
locale = params["locale"] || "en"
project_title = Settings.get_project_title()
config = Sync.get_config()
# Subscribe to connection updates
if connected?(socket) do
pubsub = PhoenixKit.Config.pubsub_server()
if pubsub do
Phoenix.PubSub.subscribe(pubsub, "sync:connections")
end
end
socket =
socket
|> assign(:page_title, "Connections")
|> assign(:project_title, project_title)
|> assign(:current_locale, locale)
|> assign(:current_path, Routes.path("/admin/sync/connections", locale: locale))
|> assign(:config, config)
|> assign(:view_mode, :list)
|> assign(:selected_connection, nil)
|> assign(:changeset, nil)
|> assign(:direction_filter, nil)
|> assign(:sender_statuses, %{})
|> load_connections()
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
action = params["action"]
id = params["id"]
socket = handle_action(socket, action, id, params)
{:noreply, socket}
end
defp handle_action(socket, "new", _id, _params) do
changeset = Connection.changeset(%Connection{}, %{"direction" => "sender"})
socket
|> assign(:view_mode, :new)
|> assign(:changeset, changeset)
end
defp handle_action(socket, "edit", id, _params) when not is_nil(id) do
handle_connection_action(socket, id, :edit)
end
defp handle_action(socket, "show", id, _params) when not is_nil(id) do
handle_connection_action(socket, id, :show)
end
defp handle_action(socket, "sync", id, _params) when not is_nil(id) do
handle_sync_action(socket, id)
end
defp handle_action(socket, _action, _id, params) do
socket
|> assign(:view_mode, :list)
|> assign(:selected_connection, nil)
|> assign(:changeset, nil)
|> assign(:direction_filter, params["direction"])
|> load_connections()
end
defp handle_connection_action(socket, id, mode) do
case Connections.get_connection(id) do
nil ->
socket
|> put_flash(:error, "Connection not found")
|> assign(:view_mode, :list)
|> load_connections()
connection ->
setup_connection_view(socket, connection, mode)
end
end
defp setup_connection_view(socket, connection, :edit) do
changeset = Connection.settings_changeset(connection, %{})
socket
|> assign(:view_mode, :edit)
|> assign(:selected_connection, connection)
|> assign(:changeset, changeset)
end
defp setup_connection_view(socket, connection, :show) do
socket
|> assign(:view_mode, :show)
|> assign(:selected_connection, connection)
end
defp handle_sync_action(socket, id) do
case Connections.get_connection(id) do
nil ->
socket
|> put_flash(:error, "Connection not found")
|> assign(:view_mode, :list)
|> load_connections()
connection ->
setup_sync_view(socket, connection)
end
end
defp setup_sync_view(socket, connection) do
send(self(), {:fetch_sender_tables, connection})
socket
|> assign(:view_mode, :sync)
|> assign(:selected_connection, connection)
|> assign(:sync_tables, [])
|> assign(:sync_local_counts, %{})
|> assign(:sync_local_checksums, %{})
|> assign(:sync_loading, true)
|> assign(:sync_error, nil)
|> assign(:selected_sync_tables, MapSet.new())
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, nil)
|> assign(:conflict_strategy, connection.default_conflict_strategy || "skip")
|> assign(:sync_active_tab, :bulk)
|> assign(:selected_detail_table, nil)
|> assign(:detail_table_schema, nil)
|> assign(:detail_filter, %{mode: :all, ids: "", range_start: "", range_end: ""})
|> assign(:detail_preview, nil)
|> assign(:loading_schema, false)
|> assign(:loading_preview, false)
|> assign(:local_table_exists, true)
|> assign(:creating_table, false)
end
defp load_connections(socket, opts \\ []) do
direction = socket.assigns[:direction_filter]
filter_opts = if direction, do: [direction: direction], else: []
sender_connections =
Connections.list_connections(Keyword.put(filter_opts, :direction, "sender"))
receiver_connections =
Connections.list_connections(Keyword.put(filter_opts, :direction, "receiver"))
# Only do async HTTP calls on initial load, not on PubSub updates
# This prevents feedback loops where status queries trigger broadcasts
unless Keyword.get(opts, :skip_async, false) do
# Fetch sender statuses for receiver connections (async)
fetch_sender_statuses(receiver_connections)
# Verify receiver connections still exist for sender connections (async)
# This handles cases where receiver severed but notification was missed
verify_receiver_connections(sender_connections)
end
socket
|> assign(:sender_connections, sender_connections)
|> assign(:receiver_connections, receiver_connections)
end
defp fetch_sender_statuses(receiver_connections) do
pid = self()
Enum.each(receiver_connections, fn conn ->
Task.start(fn ->
case ConnectionNotifier.query_sender_status(conn) do
{:ok, status} when is_binary(status) ->
send(pid, {:sender_status_fetched, conn.uuid, status})
{:ok, :offline} ->
send(pid, {:sender_status_fetched, conn.uuid, "offline"})
{:ok, :not_found} ->
send(pid, {:sender_status_fetched, conn.uuid, "not_found"})
{:error, _reason} ->
send(pid, {:sender_status_fetched, conn.uuid, "error"})
end
end)
end)
end
defp verify_receiver_connections(sender_connections) do
pid = self()
Enum.each(sender_connections, fn conn ->
if should_verify_connection?(conn) do
verify_single_connection(conn, pid)
end
end)
end
defp should_verify_connection?(conn) do
notification_success =
get_in(conn.metadata || %{}, ["remote_notification", "notification_success"])
conn.status in ["active", "pending", "suspended"] && notification_success == true
end
defp verify_single_connection(conn, pid) do
Task.start(fn ->
handle_verification_result(ConnectionNotifier.verify_connection(conn), conn.uuid, pid)
end)
end
defp handle_verification_result({:ok, :not_found}, conn_uuid, pid) do
send(pid, {:receiver_connection_severed, conn_uuid})
end
defp handle_verification_result(_result, _conn_uuid, _pid), do: :ok
@impl true
def handle_event("filter", %{"direction" => direction}, socket) do
params = if direction != "", do: %{direction: direction}, else: %{}
path = path_with_params("/admin/sync/connections", params)
{:noreply, push_patch(socket, to: path)}
end
def handle_event("new_connection", _params, socket) do
path = path_with_params("/admin/sync/connections", %{action: "new"})
{:noreply, push_patch(socket, to: path)}
end
def handle_event("show_connection", %{"uuid" => uuid}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "show", id: uuid})
{:noreply, push_patch(socket, to: path)}
end
def handle_event("edit_connection", %{"uuid" => uuid}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "edit", id: uuid})
{:noreply, push_patch(socket, to: path)}
end
def handle_event("cancel", _params, socket) do
path = Routes.path("/admin/sync/connections")
{:noreply, push_patch(socket, to: path)}
end
def handle_event("validate", %{"connection" => params}, socket) do
changeset =
case socket.assigns.view_mode do
:new ->
%Connection{}
|> Connection.changeset(params)
|> Map.put(:action, :validate)
:edit ->
socket.assigns.selected_connection
|> Connection.settings_changeset(params)
|> Map.put(:action, :validate)
end
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"connection" => params}, socket) do
case socket.assigns.view_mode do
:new -> do_create_connection(socket, params)
:edit -> do_update_connection(socket, params)
end
end
def handle_event("approve_connection", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.approve_connection(connection, current_user.uuid) do
{:ok, updated_connection} ->
# Notify receiver of status change (async)
Task.start(fn ->
ConnectionNotifier.notify_status_change(updated_connection, "active")
end)
socket =
socket
|> put_flash(:info, "Connection approved")
|> load_connections()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to approve connection")}
end
end
def handle_event("suspend_connection", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.suspend_connection(connection, current_user.uuid) do
{:ok, updated_connection} ->
# Notify receiver of status change (async)
Task.start(fn ->
ConnectionNotifier.notify_status_change(updated_connection, "suspended")
end)
socket =
socket
|> put_flash(:info, "Connection suspended")
|> load_connections()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to suspend connection")}
end
end
def handle_event("reactivate_connection", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
case Connections.reactivate_connection(connection) do
{:ok, updated_connection} ->
# Notify receiver of status change (async)
Task.start(fn ->
ConnectionNotifier.notify_status_change(updated_connection, "active")
end)
socket =
socket
|> put_flash(:info, "Connection reactivated")
|> load_connections()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reactivate connection")}
end
end
def handle_event("revoke_connection", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.revoke_connection(connection, current_user.uuid, "Revoked by admin") do
{:ok, updated_connection} ->
# Notify receiver of status change (async)
Task.start(fn ->
ConnectionNotifier.notify_status_change(updated_connection, "revoked")
end)
socket =
socket
|> put_flash(:info, "Connection revoked")
|> load_connections()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to revoke connection")}
end
end
def handle_event("regenerate_token", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
case Connections.regenerate_token(connection) do
{:ok, _connection, _new_token} ->
socket =
socket
|> put_flash(:info, "Token regenerated")
|> load_connections()
{:noreply, socket}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to regenerate token")}
end
end
def handle_event("delete_connection", %{"uuid" => uuid}, socket) do
connection = Connections.get_connection!(uuid)
# If receiver is severing, notify the sender first
if connection.direction == "receiver" do
Task.start(fn ->
ConnectionNotifier.notify_delete(connection)
end)
end
case Connections.delete_connection(connection) do
{:ok, _connection} ->
message =
if connection.direction == "receiver",
do: "Connection severed",
else: "Connection deleted"
socket =
socket
|> put_flash(:info, message)
|> load_connections()
path = Routes.path("/admin/sync/connections")
{:noreply, push_patch(socket, to: path)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to delete connection")}
end
end
def handle_event("start_sync", %{"uuid" => uuid}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "sync", id: uuid})
{:noreply, push_patch(socket, to: path)}
end
def handle_event("refresh_tables", _params, socket) do
connection = socket.assigns.selected_connection
socket = assign(socket, :sync_loading, true)
send(self(), {:fetch_sender_tables, connection})
{:noreply, socket}
end
def handle_event("toggle_table", %{"table" => table_name}, socket) do
selected = socket.assigns.selected_sync_tables
tables = socket.assigns.sync_tables
selected =
if MapSet.member?(selected, table_name) do
MapSet.delete(selected, table_name)
else
# Auto-include FK dependencies (recursively)
deps = get_table_dependencies(table_name, tables)
Enum.reduce([table_name | deps], selected, fn t, acc ->
MapSet.put(acc, t)
end)
end
{:noreply, assign(socket, :selected_sync_tables, selected)}
end
def handle_event("toggle_all_tables", _params, socket) do
tables = socket.assigns.sync_tables
selected = socket.assigns.selected_sync_tables
all_names = Enum.map(tables, fn t -> t.name || t["name"] end) |> MapSet.new()
selected =
if MapSet.equal?(selected, all_names) do
MapSet.new()
else
all_names
end
{:noreply, assign(socket, :selected_sync_tables, selected)}
end
def handle_event("select_all_tables", _params, socket) do
tables = socket.assigns.sync_tables
all_names = Enum.map(tables, fn t -> t.name || t["name"] end) |> MapSet.new()
{:noreply, assign(socket, :selected_sync_tables, all_names)}
end
def handle_event("deselect_all_tables", _params, socket) do
{:noreply, assign(socket, :selected_sync_tables, MapSet.new())}
end
def handle_event("select_different_tables", _params, socket) do
# Select tables that either don't exist locally or have different data
local_counts = socket.assigns.sync_local_counts
local_checksums = socket.assigns.sync_local_checksums
different_tables =
socket.assigns.sync_tables
|> Enum.filter(fn table ->
name = get_table_field(table, :name)
local_count = Map.get(local_counts, name)
if is_nil(local_count) do
true
else
local_cs = Map.get(local_checksums, name)
sender_cs = get_table_field(table, :checksum)
if sender_cs && local_cs do
sender_cs != local_cs
else
sender_count = get_table_field(table, :row_count) || 0
local_count != sender_count
end
end
end)
|> Enum.map(&get_table_field(&1, :name))
{:noreply, assign(socket, :selected_sync_tables, MapSet.new(different_tables))}
end
def handle_event("change_conflict_strategy", %{"strategy" => strategy}, socket) do
{:noreply, assign(socket, :conflict_strategy, strategy)}
end
def handle_event("execute_sync", _params, socket) do
tables = socket.assigns.sync_tables
selected_tables =
socket.assigns.selected_sync_tables
|> MapSet.to_list()
|> sort_by_dependencies(tables)
if Enum.empty?(selected_tables) do
{:noreply, put_flash(socket, :error, "Please select at least one table")}
else
socket =
socket
|> assign(:sync_in_progress, true)
|> assign(:sync_progress, %{
total: length(selected_tables),
current: 0,
tables_done: 0,
records_fetched: 0,
records_skipped: 0,
records_errors: 0,
table_results: [],
current_pass_results: [],
retry_pass: 0,
uuid_remap: %{},
table: nil,
status: :running
})
# Start the sync process
send(self(), {:do_pull_table, selected_tables, 0})
{:noreply, socket}
end
end
# Precise Transfer Tab Events
def handle_event("switch_sync_tab", %{"tab" => "bulk"}, socket) do
{:noreply, assign(socket, :sync_active_tab, :bulk)}
end
def handle_event("switch_sync_tab", %{"tab" => "details"}, socket) do
{:noreply, assign(socket, :sync_active_tab, :details)}
end
def handle_event("select_detail_table", %{"table" => ""}, socket) do
socket =
socket
|> assign(:selected_detail_table, nil)
|> assign(:detail_table_schema, nil)
|> assign(:detail_preview, nil)
|> assign(:detail_filter, %{mode: :all, ids: "", range_start: "", range_end: ""})
{:noreply, socket}
end
def handle_event("select_detail_table", %{"table" => table_name}, socket) do
connection = socket.assigns.selected_connection
socket =
socket
|> assign(:selected_detail_table, table_name)
|> assign(:loading_schema, true)
|> assign(:detail_table_schema, nil)
|> assign(:detail_preview, nil)
|> assign(:detail_filter, %{mode: :all, ids: "", range_start: "", range_end: ""})
# Fetch schema for the selected table
send(self(), {:fetch_table_schema, connection, table_name})
{:noreply, socket}
end
def handle_event("update_detail_filter", params, socket) do
mode =
case params["mode"] do
"all" -> :all
"ids" -> :ids
"range" -> :range
_ -> socket.assigns.detail_filter.mode
end
filter = %{
mode: mode,
ids: params["ids"] || socket.assigns.detail_filter.ids,
range_start: params["range_start"] || socket.assigns.detail_filter.range_start,
range_end: params["range_end"] || socket.assigns.detail_filter.range_end
}
{:noreply, assign(socket, :detail_filter, filter)}
end
def handle_event("preview_detail_records", _params, socket) do
table = socket.assigns.selected_detail_table
connection = socket.assigns.selected_connection
if table do
socket = assign(socket, :loading_preview, true)
send(self(), {:fetch_preview_records, connection, table, socket.assigns.detail_filter})
{:noreply, socket}
else
{:noreply, socket}
end
end
def handle_event("create_detail_table", _params, socket) do
table = socket.assigns.selected_detail_table
schema = socket.assigns.detail_table_schema
if table && schema do
socket = assign(socket, :creating_table, true)
# Create the table locally based on schema
case SchemaInspector.create_table(table, schema) do
:ok ->
socket =
socket
|> assign(:creating_table, false)
|> assign(:local_table_exists, true)
|> put_flash(:info, "Table #{table} created successfully")
{:noreply, socket}
{:error, reason} ->
socket =
socket
|> assign(:creating_table, false)
|> put_flash(:error, "Failed to create table: #{inspect(reason)}")
{:noreply, socket}
end
else
{:noreply, socket}
end
end
def handle_event("transfer_detail_table", _params, socket) do
table = socket.assigns.selected_detail_table
connection = socket.assigns.selected_connection
filter = socket.assigns.detail_filter
if table do
socket =
socket
|> assign(:sync_in_progress, true)
|> assign(:sync_progress, %{
total: 1,
current: 1,
tables_done: 0,
records_fetched: 0,
records_skipped: 0,
records_errors: 0,
table_results: [],
table: table,
status: :running
})
# Start the transfer
send(self(), {:start_detail_sync, connection, table, filter})
{:noreply, socket}
else
{:noreply, socket}
end
end
# ===========================================
# HANDLE_INFO CALLBACKS FOR SYNC
# ===========================================
@impl true
def handle_info({:fetch_sender_tables, connection}, socket) do
liveview_pid = self()
Task.start(fn ->
result = ConnectionNotifier.fetch_sender_tables(connection)
send(liveview_pid, {:sender_tables_result, result})
end)
{:noreply, socket}
end
@impl true
def handle_info({:sender_tables_result, result}, socket) do
socket =
case result do
{:ok, tables} ->
# Get local counts and checksums for comparison
{local_counts, local_checksums} =
Enum.reduce(tables, {%{}, %{}}, fn t, {counts, checksums} ->
name = get_table_field(t, :name)
count =
case SchemaInspector.get_local_count(name) do
{:ok, c} -> c
{:error, _} -> nil
end
checksum =
case SchemaInspector.get_table_checksum(name) do
{:ok, cs} -> cs
_ -> nil
end
{Map.put(counts, name, count), Map.put(checksums, name, checksum)}
end)
socket
|> assign(:sync_tables, tables)
|> assign(:sync_local_counts, local_counts)
|> assign(:sync_local_checksums, local_checksums)
|> assign(:sync_loading, false)
|> assign(:sync_error, nil)
{:error, :offline} ->
socket
|> assign(:sync_loading, false)
|> assign(:sync_error, "Sender site is offline or unreachable")
{:error, reason} ->
socket
|> assign(:sync_loading, false)
|> assign(:sync_error, "Failed to fetch tables: #{inspect(reason)}")
end
{:noreply, socket}
end
@impl true
def handle_info({:fetch_table_schema, connection, table_name}, socket) do
liveview_pid = self()
Task.start(fn ->
result = ConnectionNotifier.fetch_table_schema(connection, table_name)
send(liveview_pid, {:table_schema_result, table_name, result})
end)
{:noreply, socket}
end
@impl true
def handle_info({:table_schema_result, table_name, result}, socket) do
# Only update if still on the same table
if socket.assigns.selected_detail_table == table_name do
socket =
case result do
{:ok, schema} ->
local_exists = SchemaInspector.table_exists?(table_name)
socket
|> assign(:detail_table_schema, schema)
|> assign(:loading_schema, false)
|> assign(:local_table_exists, local_exists)
{:error, reason} ->
socket
|> assign(:detail_table_schema, nil)
|> assign(:loading_schema, false)
|> put_flash(:error, "Failed to load schema: #{inspect(reason)}")
end
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:fetch_preview_records, connection, table_name, filter}, socket) do
liveview_pid = self()
Task.start(fn ->
opts =
case filter.mode do
:all ->
[limit: 10]
:ids ->
[ids: parse_id_list(filter.ids), limit: 10]
:range ->
[id_range: {parse_int(filter.range_start), parse_int(filter.range_end)}, limit: 10]
end
result = ConnectionNotifier.fetch_table_records(connection, table_name, opts)
send(liveview_pid, {:preview_records_result, table_name, result})
end)
{:noreply, socket}
end
@impl true
def handle_info({:preview_records_result, table_name, result}, socket) do
if socket.assigns.selected_detail_table == table_name do
socket =
case result do
{:ok, records} ->
socket
|> assign(:detail_preview, %{records: Enum.take(records, 10), total: length(records)})
|> assign(:loading_preview, false)
{:error, reason} ->
socket
|> assign(:detail_preview, nil)
|> assign(:loading_preview, false)
|> put_flash(:error, "Failed to load preview: #{inspect(reason)}")
end
{:noreply, socket}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:start_detail_sync, connection, table, filter}, socket) do
liveview_pid = self()
strategy = socket.assigns.conflict_strategy
Task.start(fn ->
opts =
case filter.mode do
:all ->
[conflict_strategy: strategy]
:ids ->
[conflict_strategy: strategy, ids: parse_id_list(filter.ids)]
:range ->
[
conflict_strategy: strategy,
id_range: {parse_int(filter.range_start), parse_int(filter.range_end)}
]
end
result = ConnectionNotifier.pull_table_data(connection, table, opts)
send(liveview_pid, {:sync_table_complete, table, result})
end)
{:noreply, socket}
end
@impl true
def handle_info({:do_pull_table, [], _index}, socket) do
progress = socket.assigns.sync_progress
table_results = Map.get(progress, :table_results, [])
retry_pass = Map.get(progress, :retry_pass, 0)
max_retries = 3
# Find tables that had errors
failed_tables = for tr <- table_results, tr.errors > 0, do: tr.table
if failed_tables != [] && retry_pass < max_retries do
# Check if last pass made any progress (imported anything)
pass_results = Map.get(progress, :current_pass_results, [])
made_progress =
retry_pass == 0 ||
Enum.any?(pass_results, fn tr -> tr.imported > 0 end)
if made_progress do
# Retry failed tables
progress =
progress
|> Map.put(:retry_pass, retry_pass + 1)
|> Map.put(:current_pass_results, [])
|> Map.put(:current, 0)
|> Map.put(:total, length(failed_tables))
|> Map.put(:table, nil)
|> Map.put(:status, :retrying)
socket = assign(socket, :sync_progress, progress)
send(self(), {:do_pull_table, failed_tables, 0})
{:noreply, socket}
else
# No progress on last retry — stop
progress = Map.put(progress, :status, :completed)
socket =
socket
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, progress)
{:noreply, socket}
end
else
# No errors or max retries reached
progress = Map.put(progress, :status, :completed)
socket =
socket
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, progress)
{:noreply, socket}
end
end
@impl true
def handle_info({:do_pull_table, [table | rest], index}, socket) do
liveview_pid = self()
connection = socket.assigns.selected_connection
strategy = socket.assigns.conflict_strategy
uuid_remap = Map.get(socket.assigns.sync_progress, :uuid_remap, %{})
progress =
socket.assigns.sync_progress
|> Map.put(:current, index + 1)
|> Map.put(:table, table)
socket = assign(socket, :sync_progress, progress)
Task.start(fn ->
case ConnectionNotifier.pull_table_data_with_remap(
connection,
table,
uuid_remap,
conflict_strategy: strategy
) do
{:ok, import_result, updated_remap} ->
send(
liveview_pid,
{:sync_table_complete, table, {:ok, import_result}, rest, index + 1, updated_remap}
)
{:error, reason, unchanged_remap} ->
send(
liveview_pid,
{:sync_table_complete, table, {:error, reason}, rest, index + 1, unchanged_remap}
)
end
end)
{:noreply, socket}
end
@impl true
def handle_info({:sync_table_complete, table, result, rest, index, updated_remap}, socket) do
progress = Map.put(socket.assigns.sync_progress, :uuid_remap, updated_remap)
socket = assign(socket, :sync_progress, progress)
socket = process_table_sync_result(socket, table, result)
send(self(), {:do_pull_table, rest, index})
{:noreply, socket}
end
@impl true
def handle_info({:sync_table_complete, table, result, rest, index}, socket) do
socket = process_table_sync_result(socket, table, result)
send(self(), {:do_pull_table, rest, index})
{:noreply, socket}
end
@impl true
def handle_info({:sync_table_complete, table, result}, socket) do
# Single table sync (from precise transfer)
socket = process_table_sync_result(socket, table, result)
progress = Map.put(socket.assigns.sync_progress, :status, :completed)
socket =
socket
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, progress)
{:noreply, socket}
end
def handle_info({:sender_status_fetched, connection_uuid, status}, socket) do
# Store the sender's status in the sender_statuses map
sender_statuses = Map.put(socket.assigns.sender_statuses, connection_uuid, status)
{:noreply, assign(socket, :sender_statuses, sender_statuses)}
end
def handle_info({:receiver_connection_severed, connection_uuid}, socket) do
# Receiver severed their connection - delete our sender connection
case Connections.get_connection(connection_uuid) do
nil ->
# Already deleted
{:noreply, socket}
connection ->
case Connections.delete_connection(connection) do
{:ok, _} ->
socket =
socket
|> put_flash(:info, "Connection '#{connection.name}' was severed by remote site")
|> load_connections()
{:noreply, socket}
{:error, _} ->
{:noreply, socket}
end
end
end
# PubSub handlers for real-time updates
# Use skip_async: true to prevent feedback loops - just reload from DB without
# triggering HTTP calls that could cause more broadcasts
def handle_info({:connection_created, _connection_uuid}, socket) do
{:noreply, load_connections(socket, skip_async: true)}
end
def handle_info({:connection_status_changed, _connection_uuid, _status}, socket) do
{:noreply, load_connections(socket, skip_async: true)}
end
defp extract_sync_counts(result) do
case result do
{:ok, %{imported: imported, skipped: skipped, errors: errors}} ->
{imported, skipped, errors, nil}
{:ok, %{imported: count}} ->
{count, 0, 0, nil}
{:error, :offline} ->
{0, 0, 0, "Sender is offline"}
{:error, :unauthorized} ->
{0, 0, 0, "Unauthorized - check connection token"}
{:error, :table_not_found} ->
{0, 0, 0, "Table not found on sender"}
{:error, reason} when is_binary(reason) ->
{0, 0, 0, reason}
{:error, reason} ->
{0, 0, 0, "Sync failed: #{inspect(reason)}"}
_ ->
{0, 0, 0, "Unknown error"}
end
end
# Process a single table's sync result: extract counts, merge with retries, update progress
defp process_table_sync_result(socket, table, result) do
{records_fetched, records_skipped, records_errors, error_message} =
extract_sync_counts(result)
table_result = %{
table: table,
imported: records_fetched,
skipped: records_skipped,
errors: records_errors,
error_message: error_message
}
progress = socket.assigns.sync_progress
retry_pass = Map.get(progress, :retry_pass, 0)
existing_results = Map.get(progress, :table_results, [])
table_results =
if retry_pass > 0 do
# Find previous result for this table and merge counts
case Enum.split_with(existing_results, &(&1.table == table)) do
{[prev], other_results} ->
merged = %{
table: table,
imported: prev.imported + records_fetched,
skipped: prev.skipped + records_skipped,
errors: records_errors,
error_message: error_message,
retried: true
}
other_results ++ [merged]
_ ->
existing_results ++ [table_result]
end
else
existing_results ++ [table_result]
end
# Recalculate totals from table_results
totals =
Enum.reduce(table_results, %{fetched: 0, skipped: 0, errors: 0}, fn tr, acc ->
%{
fetched: acc.fetched + tr.imported,
skipped: acc.skipped + tr.skipped,
errors: acc.errors + tr.errors
}
end)
# tables_done reflects unique tables completed, not current pass count
unique_tables_done =
table_results
|> Enum.map(& &1.table)
|> Enum.uniq()
|> length()
progress =
progress
|> Map.put(:tables_done, unique_tables_done)
|> Map.put(:records_fetched, totals.fetched)
|> Map.put(:records_skipped, totals.skipped)
|> Map.put(:records_errors, totals.errors)
|> Map.put(:table_results, table_results)
|> Map.update(:current_pass_results, [table_result], &(&1 ++ [table_result]))
assign(socket, :sync_progress, progress)
end
# Get all FK dependencies for a table (recursive)
@dialyzer {:nowarn_function, get_table_dependencies: 2}
@dialyzer {:nowarn_function, get_table_dependencies: 3}
defp get_table_dependencies(table_name, tables) do
get_table_dependencies(table_name, tables, MapSet.new())
|> MapSet.to_list()
end
defp get_table_dependencies(table_name, tables, visited) do
if MapSet.member?(visited, table_name) do
visited
else
deps = get_direct_dependencies(table_name, tables)
Enum.reduce(deps, visited, fn dep, acc ->
acc
|> MapSet.put(dep)
|> then(&get_table_dependencies(dep, tables, &1))
end)
end
end
defp get_direct_dependencies(table_name, tables) do
case Enum.find(tables, fn t -> get_table_field(t, :name) == table_name end) do
nil -> []
table -> get_table_field(table, :depends_on) || []
end
end
# Sort tables so dependencies come first (topological sort)
defp sort_by_dependencies(table_names, tables) do
# Build a dependency graph for selected tables only
selected_set = MapSet.new(table_names)
graph =
Enum.reduce(table_names, %{}, fn name, acc ->
deps =
get_direct_dependencies(name, tables)
|> Enum.filter(&MapSet.member?(selected_set, &1))
Map.put(acc, name, deps)
end)
topo_sort(graph)
end
@dialyzer {:nowarn_function, topo_sort: 4}
defp topo_sort(graph) do
topo_sort(graph, Map.keys(graph), [], MapSet.new())
end
defp topo_sort(_graph, [], sorted, _visited), do: sorted
defp topo_sort(graph, [node | rest], sorted, visited) do
if MapSet.member?(visited, node) do
topo_sort(graph, rest, sorted, visited)
else
{sorted, visited} = visit_node(graph, node, sorted, visited, MapSet.new())
topo_sort(graph, rest, sorted, visited)
end
end
@dialyzer {:nowarn_function, visit_node: 5}
defp visit_node(graph, node, sorted, visited, path) do
if MapSet.member?(visited, node) do
{sorted, visited}
else
deps = Map.get(graph, node, [])
# Guard against cycles
if MapSet.member?(path, node) do
{sorted ++ [node], MapSet.put(visited, node)}
else
path = MapSet.put(path, node)
{sorted, visited} =
Enum.reduce(deps, {sorted, visited}, fn dep, {s, v} ->
visit_node(graph, dep, s, v, path)
end)
{sorted ++ [node], MapSet.put(visited, node)}
end
end
end
# ===========================================
# PRIVATE HELPERS FOR SAVE
# ===========================================
defp do_create_connection(socket, params) do
current_user = socket.assigns.phoenix_kit_current_scope.user
params = Map.put(params, "created_by_uuid", current_user.uuid)
direction = params["direction"] || params[:direction]
site_url = params["site_url"] || params[:site_url]
conn_name = params["name"] || params[:name]
Logger.info(
"[Sync.Connections] Creating connection " <>
"| direction=#{direction} " <>
"| name=#{inspect(conn_name)} " <>
"| site_url=#{site_url} " <>
"| created_by=#{current_user.uuid}"
)
case Connections.create_connection(params) do
{:ok, connection, token} ->
Logger.info(
"[Sync.Connections] Connection created " <>
"| uuid=#{connection.uuid} " <>
"| direction=#{connection.direction} " <>
"| site_url=#{connection.site_url} " <>
"| status=#{connection.status} " <>
"| auth_token_hash=#{String.slice(connection.auth_token_hash || "", 0, 8)}…"
)
# Notify the remote site to register this connection (async)
if connection.direction == "sender" do
Logger.info(
"[Sync.Connections] Notifying remote site (async) " <>
"| uuid=#{connection.uuid} " <>
"| remote_url=#{connection.site_url}"
)
Task.start(fn -> log_remote_notification(connection, token) end)
end
socket =
socket
|> put_flash(:info, "Connection created successfully")
|> load_connections()
path = Routes.path("/admin/sync/connections")
{:noreply, push_patch(socket, to: path)}
{:error, changeset} ->
Logger.error(
"[Sync.Connections] Failed to create connection " <>
"| direction=#{direction} " <>
"| site_url=#{site_url} " <>
"| errors=#{inspect(changeset.errors)}"
)
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp do_update_connection(socket, params) do
case Connections.update_connection(socket.assigns.selected_connection, params) do
{:ok, _connection} ->
socket =
socket
|> put_flash(:info, "Connection updated successfully")
|> load_connections()
path = Routes.path("/admin/sync/connections")
{:noreply, push_patch(socket, to: path)}
{:error, changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
@impl true
def render(assigns) do
~H"""
Manage permanent connections for data sync
Connections
Sites that can pull data from this site
<%= if Enum.empty?(@sender_connections) do %>No outgoing connections configured
<% else %>| Name | Site URL | Status | Actions |
|---|---|---|---|
| {conn.name} | {conn.site_url} | <.status_badge status={conn.status} /> | <.connection_actions connection={conn} /> |
Sites this site can pull data from
<%= if Enum.empty?(@receiver_connections) do %>No incoming connections configured
<% else %>| Site URL | Status | Actions |
|---|---|---|
| {conn.site_url} | <.status_badge status={Map.get(@sender_statuses, conn.uuid, "loading")} /> | <.connection_actions connection={conn} sender_status={Map.get(@sender_statuses, conn.uuid)} /> |
{elem(@changeset.errors[:name], 0)}
<% end %>Sender
Allow remote site to pull data from here
Incoming connections are created automatically when remote sites connect
<% else %>{Ecto.Changeset.get_field(@changeset, :direction)}
The URL of the remote site that will connect to pull data
{@connection.direction}
{@connection.site_url}
{@connection.default_conflict_strategy}
<%= if @connection.auto_sync_enabled do %> Enabled <% else %> Disabled <% end %>
{@connection.total_transfers}
{@connection.total_records_transferred}
{format_bytes(@connection.total_bytes_transferred)}
<%= if @connection.last_connected_at do %> {format_time_ago(@connection.last_connected_at)} <% else %> Never <% end %>
<%= if @connection.last_transfer_at do %> {format_time_ago(@connection.last_transfer_at)} <% else %> Never <% end %>
Pull data from {@connection.name}
Fetching available tables...
Auto-retried {Map.get(@progress, :retry_pass, 0)} time(s) to resolve dependencies
<% end %> <%!-- Per-table results --%>| Table | Imported | Skipped | Errors |
|---|---|---|---|
| {tr.table} {if Map.get(tr, :retried), do: " ↻"} | 0, do: "text-success font-semibold", else: "" }> {tr.imported} | 0, do: "text-warning", else: ""}> {tr.skipped} | <%= if tr.errors > 0 do %> {tr.errors} <% else %> 0 <% end %> |
| Total | {format_number(@progress.records_fetched)} | {format_number(Map.get(@progress, :records_skipped, 0))} | {format_number(Map.get(@progress, :records_errors, 0))} |
<%= if Map.get(@progress, :status) == :retrying do %> Retry pass {Map.get(@progress, :retry_pass, 1)} — {@progress.current}/{@progress.total} <% else %> Syncing {@progress.current}/{@progress.total} <% end %>
<%= if @progress.table do %> Processing: {@progress.table} <% else %> Preparing... <% end %>
<% end %>No tables available for sync
| Table | Sender | Local | Size | Checksum | Status | |
|---|---|---|---|---|---|---|
| {table_name} | {format_number(table.row_count || 0)} | <%= if local_count do %> {format_number(local_count)} <% else %> — <% end %> | {format_bytes(table.size_bytes || 0)} | <% short_sender = format_checksum(sender_checksum) %> <% short_local = format_checksum(local_checksum) %> <% cs_match = checksums_match?(sender_checksum, local_checksum) %> {short_sender} / {short_local} | <% sender_count = table.row_count || 0 %> <% data_matches = cond do checksums_match?(sender_checksum, local_checksum) -> true checksums_comparable?(sender_checksum, local_checksum) -> false true -> local_count == sender_count end %> <%= cond do %> <% local_count == nil -> %> <.icon name="hero-plus-circle-mini" class="w-3 h-3" /> New <% data_matches -> %> <.icon name="hero-check-circle-mini" class="w-3 h-3" /> Match <% true -> %> <.icon name="hero-exclamation-triangle-mini" class="w-3 h-3" /> <%= if local_count != sender_count do %> {abs(sender_count - local_count)} diff <% else %> Modified <% end %> <% end %> |
| Table | Imported | Skipped | Errors |
|---|---|---|---|
| {tr.table} | 0, do: "text-success font-semibold", else: "" }> {tr.imported} | 0, do: "text-warning", else: ""}> {tr.skipped} | <%= if tr.errors > 0 do %> {tr.errors} <% else %> 0 <% end %> |
| Total | {format_number(@progress.records_fetched || 0)} | {format_number(Map.get(@progress, :records_skipped, 0))} | {format_number(Map.get(@progress, :records_errors, 0))} |
| Column | Type | Nullable | Default |
|---|---|---|---|
| {col["name"] || col[:name]} <%= if col["primary_key"] || col[:primary_key] do %> PK <% end %> | {col["type"] || col[:type]} | {if col["nullable"] || col[:nullable], do: "Yes", else: "No"} | {col["default"] || col[:default] || "-"} |
The table {@selected_detail_table}
doesn't exist in your local database. Create it first to transfer data.
Preview ({@detail_preview.total} matching records)
<%= if length(@detail_preview.records) > 0 do %>| {key} | <% end %>
|---|
| {inspect(Map.get(record, key)) |> String.slice(0, 50)} | <% end %>
Showing first 10 of {@detail_preview.total} records
<% end %> <% else %>Select a table above to view details and transfer options