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"""
<%!-- Header Section --%>
<.link navigate={Routes.path("/admin/sync", locale: @current_locale)} class="btn btn-outline btn-primary btn-sm absolute left-0 top-0 -mb-12" > <.icon name="hero-arrow-left" class="w-4 h-4 mr-2" /> Back to DB Sync

Connections

Manage permanent connections for data sync

<%= if not @config.enabled do %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> DB Sync module is disabled.
<% end %> <%= case @view_mode do %> <% :list -> %> <.connections_list sender_connections={@sender_connections} receiver_connections={@receiver_connections} direction_filter={@direction_filter} sender_statuses={@sender_statuses} /> <% :new -> %> <.connection_form changeset={@changeset} action={:new} /> <% :edit -> %> <.connection_form changeset={@changeset} action={:edit} connection={@selected_connection} /> <% :show -> %> <.connection_details connection={@selected_connection} /> <% :sync -> %> <.sync_view connection={@selected_connection} tables={@sync_tables} local_counts={@sync_local_counts} loading={@sync_loading} error={@sync_error} selected_tables={@selected_sync_tables} sync_in_progress={@sync_in_progress} progress={@sync_progress} conflict_strategy={@conflict_strategy} active_tab={@sync_active_tab} selected_detail_table={@selected_detail_table} detail_table_schema={@detail_table_schema} detail_filter={@detail_filter} detail_preview={@detail_preview} loading_schema={@loading_schema} loading_preview={@loading_preview} local_table_exists={@local_table_exists} creating_table={@creating_table} /> <% end %>
""" end # =========================================== # LIST VIEW # =========================================== defp connections_list(assigns) do ~H"""
<%!-- Actions Bar --%>
<%!-- Sender Connections --%> <%= if @direction_filter != "receiver" do %>

<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Sender Connections {length(@sender_connections)}

Sites that can pull data from this site

<%= if Enum.empty?(@sender_connections) do %>

No sender connections configured

<% else %>
<%= for conn <- @sender_connections do %> <% end %>
Name Site URL Status Actions
{conn.name} {conn.site_url} <.status_badge status={conn.status} /> <.connection_actions connection={conn} />
<% end %>
<% end %> <%!-- Receiver Connections --%> <%= if @direction_filter != "sender" do %>

<.icon name="hero-arrow-down-tray" class="w-5 h-5" /> Receiver Connections {length(@receiver_connections)}

Sites this site can pull data from

<%= if Enum.empty?(@receiver_connections) do %>

No receiver connections configured

<% else %>
<%= for conn <- @receiver_connections do %> <% end %>
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)} />
<% end %>
<% end %>
""" end defp connection_actions(assigns) do # Default sender_status to nil if not provided assigns = assign_new(assigns, :sender_status, fn -> nil end) ~H"""
<%!-- Sync button only for receivers when sender status is active --%> <%= if @connection.direction == "receiver" and @sender_status == "active" do %> <% end %> <%!-- Edit button only for senders (receivers get info from sender) --%> <%= if @connection.direction == "sender" do %> <% end %> <%!-- Delete/Sever connection button --%> <%!-- Status controls only for sender connections (receivers sync from sender) --%> <%= if @connection.direction == "sender" do %> <%= if @connection.status == "active" do %> <% end %> <%= if @connection.status == "suspended" do %> <% end %> <% end %>
""" end # =========================================== # FORM VIEW # =========================================== defp connection_form(assigns) do assigns = assign_new(assigns, :connection, fn -> nil end) ~H"""

<%= if @action == :new do %> New Connection <% else %> Edit Connection <% end %>

<.form for={@changeset} phx-submit="save" class="space-y-6" > <%!-- Name Field --%>
<%= if @changeset.action && @changeset.errors[:name] do %>

{elem(@changeset.errors[:name], 0)}

<% end %>
<%!-- Direction Field --%>
<%= if @action == :new do %>

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

<% end %>
<%!-- Site URL Field --%>

The URL of the remote site that will connect to pull data

<%!-- Receiver-specific settings --%> <%= if Ecto.Changeset.get_field(@changeset, :direction) == "receiver" do %>
Import Settings
<% end %> <%!-- Actions --%>
""" end # =========================================== # DETAILS VIEW # =========================================== defp connection_details(assigns) do ~H"""

{@connection.name}

<.status_badge status={@connection.status} />
<%!-- Basic Info --%>

{@connection.direction}

{@connection.site_url}

<%!-- Receiver Settings --%> <%= if @connection.direction == "receiver" do %>
Import Settings

{@connection.default_conflict_strategy}

<%= if @connection.auto_sync_enabled do %> Enabled <% else %> Disabled <% end %>

<% end %> <%!-- Statistics --%>
Statistics

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

<%!-- Actions --%>
Actions
<%!-- Status controls only for sender connections (receivers sync status from sender) --%> <%= if @connection.direction == "sender" do %> <%= if @connection.status == "active" do %> <% end %> <%= if @connection.status == "suspended" do %> <% end %> <% end %> <%= if @connection.status not in ["revoked"] do %> <% end %>
<%!-- Back Button --%>
""" end # =========================================== # SYNC VIEW # =========================================== defp sync_view(assigns) do ~H"""

<.icon name="hero-arrow-path" class="w-6 h-6" /> Sync Data

Pull data from {@connection.name}

<%!-- Tab Navigation --%>
<%!-- Error State --%> <%= if @error do %>
<.icon name="hero-exclamation-circle" class="w-5 h-5" /> {@error}
<% end %> <%!-- Loading State --%> <%= if @loading do %>

Fetching available tables...

<% else %> <%= if @active_tab == :bulk do %> <%!-- ========== BULK TRANSFER TAB ========== --%> <%= if @progress && Map.get(@progress, :status) == :completed do %>
🎉

Sync Complete!

Tables Synced
{@progress.tables_done}
Records Imported
{format_number(@progress.records_fetched)}
<% else %> <%= if @sync_in_progress do %>
<%= if @progress do %>

Syncing {@progress.current}/{@progress.total}

<%= if @progress.table do %> Processing: {@progress.table} <% else %> Preparing... <% end %>

<% end %>
<% else %> <%= if Enum.empty?(@tables) do %>
<.icon name="hero-table-cells" class="w-12 h-12 mx-auto mb-2 opacity-30" />

No tables available for sync

<% else %> <%!-- Legend --%>
Record counts: Sender = remote data Local = your database <.icon name="hero-exclamation-triangle" class="w-3 h-3" /> = differs
<%!-- Selection buttons and conflict strategy --%>
{MapSet.size(@selected_tables)} of {length(@tables)} selected
Conflict:
<%= for table <- @tables do %> <% table_name = table.name || table["name"] %> <% is_selected = MapSet.member?(@selected_tables, table_name) %> <% local_count = Map.get(@local_counts, table_name) %> <% end %>
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 %>
<%!-- Summary Stats --%>
<.table_summary_stat label="New tables" count={count_new_tables(@tables, @local_counts)} color="info" /> <.table_summary_stat label="Different" count={count_different_tables(@tables, @local_counts)} color="warning" /> <.table_summary_stat label="Match" count={count_same_tables(@tables, @local_counts)} color="success" />
{MapSet.size(@selected_tables)} table(s) selected
<% end %> <% end %> <% end %> <% else %> <%!-- ========== PRECISE TRANSFER TAB ========== --%> <%= if @progress && Map.get(@progress, :status) == :completed do %>
🎉

Transfer Complete!

Table
{@progress[:table] || @selected_detail_table || "1 table"}
Records Imported
{format_number(@progress.records_fetched || 0)}
<% else %>
<%= if @selected_detail_table do %> <%= if @loading_schema do %>
Loading table schema...
<% else %>

{@selected_detail_table}

<% local_count = Map.get(@local_counts, @selected_detail_table) %> <% table_info = Enum.find(@tables, fn t -> (t.name || t["name"]) == @selected_detail_table end) %> <% sender_count = if table_info, do: table_info.row_count || table_info["row_count"] || 0, else: 0 %>
Sender: {format_number(sender_count)} records <%= if local_count do %> | Local: {format_number(local_count)} records <% else %> | New table <% end %>
<%!-- Table Schema Display --%> <%= if @detail_table_schema do %> <% schema_columns = get_schema_columns(@detail_table_schema) %> <%= if length(schema_columns) > 0 do %>

Table Schema

<%= for col <- schema_columns do %> <% 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] || "-"}
<% end %> <% end %> <%!-- Create Table Button (when table doesn't exist locally) --%> <%= if not @local_table_exists do %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />

Table doesn't exist locally

The table {@selected_detail_table} doesn't exist in your local database. Create it first to transfer data.

<% end %>
<%= if @detail_filter.mode == :ids do %> <% end %> <%= if @detail_filter.mode == :range do %>
to
<% end %>
<%!-- Preview Button --%>
<%!-- Preview Results --%> <%= if @detail_preview do %>

Preview ({@detail_preview.total} matching records)

<%= if length(@detail_preview.records) > 0 do %>
<%= for key <- Map.keys(List.first(@detail_preview.records)) |> Enum.sort() |> Enum.take(6) do %> <% end %> <%= for record <- @detail_preview.records do %> <%= for key <- Map.keys(record) |> Enum.sort() |> Enum.take(6) do %> <% end %> <% end %>
{key}
{inspect(Map.get(record, key)) |> String.slice(0, 50)}
<%= if @detail_preview.total > 10 do %>

Showing first 10 of {@detail_preview.total} records

<% end %> <% else %>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" /> No records match your filter criteria.
<% end %>
<% end %>
<% end %> <% else %>
<.icon name="hero-table-cells" class="w-12 h-12 mx-auto mb-2 opacity-30" />

Select a table above to view details and transfer options

<% end %>
<% end %> <% end %> <% end %>
""" end # =========================================== # HELPER COMPONENTS # =========================================== defp status_badge(assigns) do {color, label} = case assigns.status do "pending" -> {"badge-warning", "Pending"} "active" -> {"badge-success", "Active"} "suspended" -> {"badge-error", "Suspended"} "revoked" -> {"badge-ghost", "Revoked"} "expired" -> {"badge-ghost", "Expired"} "loading" -> {"badge-ghost animate-pulse", "Loading..."} "offline" -> {"badge-warning", "Sender Offline"} "not_found" -> {"badge-error", "Not Found"} "error" -> {"badge-error", "Error"} _ -> {"badge-ghost", String.capitalize(to_string(assigns.status))} end assigns = assigns |> assign(:color, color) |> assign(:label, label) ~H""" {@label} """ end # =========================================== # HELPER FUNCTIONS # =========================================== defp format_time_ago(datetime) do now = DateTime.utc_now() diff = DateTime.diff(now, datetime, :second) cond do diff < 60 -> "just now" diff < 3600 -> "#{div(diff, 60)}m ago" diff < 86_400 -> "#{div(diff, 3600)}h ago" diff < 604_800 -> "#{div(diff, 86_400)}d ago" true -> Calendar.strftime(datetime, "%b %d") end end defp format_bytes(bytes) when is_nil(bytes) or bytes == 0, do: "0 B" defp format_bytes(bytes) do cond do bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB" bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB" bytes >= 1024 -> "#{Float.round(bytes / 1024, 1)} KB" true -> "#{bytes} B" end end defp path_with_params(base_path, params) when map_size(params) == 0 do Routes.path(base_path) end defp path_with_params(base_path, params) do query_string = URI.encode_query(params) "#{Routes.path(base_path)}?#{query_string}" end defp format_number(num) when is_integer(num) do num |> Integer.to_string() |> String.reverse() |> String.replace(~r/.{3}(?=.)/, "\\0,") |> String.reverse() end defp format_number(num), do: "#{num}" defp parse_id_list(ids_string) when is_binary(ids_string) do ids_string |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.filter(&(&1 != "")) |> Enum.map(fn id -> case Integer.parse(id) do {int, _} -> int :error -> nil end end) |> Enum.filter(&(&1 != nil)) end defp parse_id_list(_), do: [] defp parse_int(val) when is_binary(val) do case Integer.parse(val) do {int, _} -> int :error -> nil end end defp parse_int(val) when is_integer(val), do: val defp parse_int(_), do: nil # Get schema columns, handling both atom and string keys defp get_schema_columns(nil), do: [] defp get_schema_columns(schema) when is_map(schema) do Map.get(schema, :columns) || Map.get(schema, "columns") || [] end defp get_schema_columns(_), do: [] # Table summary stat component defp table_summary_stat(assigns) do ~H"""
{@count} {@label}
""" end # Count tables that don't exist locally defp count_new_tables(tables, local_counts) do Enum.count(tables, fn table -> name = table.name || table["name"] not Map.has_key?(local_counts, name) end) end # Count tables with different counts defp count_different_tables(tables, local_counts) do Enum.count(tables, fn table -> name = table.name || table["name"] local_count = Map.get(local_counts, name) sender_count = table.row_count || table["row_count"] || 0 not is_nil(local_count) and local_count != sender_count end) end # Count tables that match (same count) defp count_same_tables(tables, local_counts) do Enum.count(tables, fn table -> name = table.name || table["name"] local_count = Map.get(local_counts, name) sender_count = table.row_count || table["row_count"] || 0 not is_nil(local_count) and local_count == sender_count end) end end