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.Routes
@impl true
def mount(params, _session, socket) do
locale = params["locale"] || "en"
project_title = Settings.get_setting("project_title", "PhoenixKit")
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(String.to_integer(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(String.to_integer(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_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.id, status})
{:ok, :offline} ->
send(pid, {:sender_status_fetched, conn.id, "offline"})
{:ok, :not_found} ->
send(pid, {:sender_status_fetched, conn.id, "not_found"})
{:error, _reason} ->
send(pid, {:sender_status_fetched, conn.id, "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.id, pid)
end)
end
defp handle_verification_result({:ok, :not_found}, conn_id, pid) do
send(pid, {:receiver_connection_severed, conn_id})
end
defp handle_verification_result(_result, _conn_id, _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", %{"id" => id}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "show", id: id})
{:noreply, push_patch(socket, to: path)}
end
def handle_event("edit_connection", %{"id" => id}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "edit", id: id})
{: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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.approve_connection(connection, current_user.id) 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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.suspend_connection(connection, current_user.id) 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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
current_user = socket.assigns.phoenix_kit_current_scope.user
case Connections.revoke_connection(connection, current_user.id, "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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
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", %{"id" => id}, socket) do
connection = Connections.get_connection!(String.to_integer(id))
# 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", %{"id" => id}, socket) do
path = path_with_params("/admin/sync/connections", %{action: "sync", id: id})
{: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
selected =
if MapSet.member?(selected, table_name) do
MapSet.delete(selected, table_name)
else
MapSet.put(selected, table_name)
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 counts
different_tables =
socket.assigns.sync_tables
|> Enum.filter(fn table ->
name = table.name || table["name"]
sender_count = table.row_count || table["row_count"] || 0
local_count = Map.get(socket.assigns.sync_local_counts, name)
# Select if: no local table, or counts differ
is_nil(local_count) or local_count != sender_count
end)
|> Enum.map(fn t -> t.name || t["name"] end)
{: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
selected_tables = socket.assigns.selected_sync_tables |> MapSet.to_list()
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,
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,
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 for comparison
local_counts =
tables
|> Enum.map(fn t ->
name = t.name || t["name"]
count =
case SchemaInspector.get_local_count(name) do
{:ok, c} -> c
{:error, _} -> nil
end
{name, count}
end)
|> Enum.into(%{})
socket
|> assign(:sync_tables, tables)
|> assign(:sync_local_counts, local_counts)
|> 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
# All tables done
progress = Map.put(socket.assigns.sync_progress, :status, :completed)
socket =
socket
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, progress)
{:noreply, socket}
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
progress =
socket.assigns.sync_progress
|> Map.put(:current, index + 1)
|> Map.put(:table, table)
socket = assign(socket, :sync_progress, progress)
Task.start(fn ->
result = ConnectionNotifier.pull_table_data(connection, table, conflict_strategy: strategy)
send(liveview_pid, {:sync_table_complete, table, result, rest, index + 1})
end)
{:noreply, socket}
end
@impl true
def handle_info({:sync_table_complete, _table, result, rest, index}, socket) do
records_fetched =
case result do
{:ok, %{imported: count}} -> count
_ -> 0
end
progress =
socket.assigns.sync_progress
|> Map.update(:tables_done, 1, &(&1 + 1))
|> Map.update(:records_fetched, records_fetched, &(&1 + records_fetched))
socket = assign(socket, :sync_progress, progress)
# Continue with next table
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)
records_fetched =
case result do
{:ok, %{imported: count}} -> count
_ -> 0
end
progress =
socket.assigns.sync_progress
|> Map.put(:tables_done, 1)
|> Map.put(:records_fetched, records_fetched)
|> Map.put(:status, :completed)
socket =
socket
|> assign(:sync_in_progress, false)
|> assign(:sync_progress, progress)
{:noreply, socket}
end
def handle_info({:sender_status_fetched, connection_id, status}, socket) do
# Store the sender's status in the sender_statuses map
sender_statuses = Map.put(socket.assigns.sender_statuses, connection_id, status)
{:noreply, assign(socket, :sender_statuses, sender_statuses)}
end
def handle_info({:receiver_connection_severed, connection_id}, socket) do
# Receiver severed their connection - delete our sender connection
case Connections.get_connection(connection_id) 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_id}, socket) do
{:noreply, load_connections(socket, skip_async: true)}
end
def handle_info({:connection_status_changed, _connection_id, _status}, socket) do
{:noreply, load_connections(socket, skip_async: true)}
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", current_user.id)
case Connections.create_connection(params) do
{:ok, connection, token} ->
# Notify the remote site to register this connection (async)
if connection.direction == "sender" do
Task.start(fn ->
result = ConnectionNotifier.notify_remote_site(connection, token)
Logger.info("Sync: Remote notification result: #{inspect(result)}")
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} ->
{: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 sender 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 receiver connections configured
<% else %>| Site URL | Status | Actions |
|---|---|---|
| {conn.site_url} | <.status_badge status={Map.get(@sender_statuses, conn.id, "loading")} /> | <.connection_actions connection={conn} sender_status={Map.get(@sender_statuses, conn.id)} /> |
{elem(@changeset.errors[:name], 0)}
<% end %>Sender
Allow remote site to pull data from here
Receiver 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...
Syncing {@progress.current}/{@progress.total}
<%= if @progress.table do %> Processing: {@progress.table} <% else %> Preparing... <% end %>
<% end %>No tables available for sync
| Table | Sender | Local | Size | 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)} | <%= cond do %> <% local_count == nil -> %> <.icon name="hero-plus-circle-mini" class="w-3 h-3" /> New <% local_count == (table.row_count || 0) -> %> <.icon name="hero-check-circle-mini" class="w-3 h-3" /> Match <% true -> %> <.icon name="hero-exclamation-triangle-mini" class="w-3 h-3" /> {abs((table.row_count || 0) - local_count)} diff <% end %> |
| 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