defmodule ToweropsWeb.AgentLive.Index do @moduledoc false use ToweropsWeb, :live_view use Gettext, backend: ToweropsWeb.Gettext import ToweropsWeb.AgentLive.Helpers import ToweropsWeb.GettextHelpers alias Towerops.Accounts.Scope alias Towerops.Agents alias Towerops.Settings @impl true def mount(_params, _session, socket) do organization = socket.assigns.current_scope.organization current_scope = socket.assigns.current_scope is_superuser = Scope.superuser?(current_scope) agent_tokens = Agents.list_organization_agent_tokens(organization.id) # Subscribe to agent health updates for real-time status changes timer_ref = if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health") {:ok, ref} = :timer.send_interval(1000, :tick) ref end # If superadmin (including when impersonating), also load cloud pollers and global default {cloud_pollers, global_default_cloud_poller_id} = if is_superuser do {Agents.list_cloud_pollers(), Settings.get_global_default_cloud_poller()} else {[], nil} end # device counts for each agent (both direct and total with inheritance) equipment_counts = calculate_device_counts(agent_tokens) # device counts for cloud pollers (if superuser) cloud_poller_counts = if is_superuser do calculate_device_counts(cloud_pollers) else %{} end # Get agent image URL from config or use default agent_image = Application.get_env( :towerops, :agent_docker_image, "codeberg.org/towerops-agent/towerops-agent:latest" ) {:ok, socket |> assign(:page_title, t("Remote Agents")) |> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:is_superuser, is_superuser) |> stream(:agent_tokens, agent_tokens, reset: true) |> assign(:has_agents, agent_tokens != []) |> stream(:cloud_pollers, cloud_pollers, reset: true) |> assign(:cloud_pollers_list, cloud_pollers) |> assign(:has_cloud_pollers, cloud_pollers != []) |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id) |> assign(:selected_global_default, global_default_cloud_poller_id || "") |> assign(:device_counts, equipment_counts) |> assign(:cloud_poller_counts, cloud_poller_counts) |> assign(:agent_image, agent_image) |> assign(:new_token, nil) |> assign(:show_token_modal, false) |> assign(:now, DateTime.utc_now()) |> assign(:timer_ref, timer_ref) |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))} end @impl true def handle_params(params, _url, socket) do # Read modal state from URL modal = params["modal"] socket = socket |> assign(:show_token_modal, modal == "setup") |> apply_action(socket.assigns.live_action, params) {:noreply, socket} end @impl true def handle_event("create_agent", params, socket) do {name, is_cloud_poller} = parse_agent_params(params) with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope, is_cloud_poller), {:ok, agent_token, token} <- create_agent(socket.assigns.current_scope.organization, name, is_cloud_poller) do handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) else {:error, :unauthorized} -> {:noreply, put_flash(socket, :error, t_equipment("Only superadmins can create cloud pollers"))} {:error, _changeset} -> error_message = if is_cloud_poller, do: t_equipment("Failed to create cloud poller"), else: t_equipment("Failed to create agent") {:noreply, put_flash(socket, :error, error_message)} end end @impl true def handle_event("close_token_modal", _params, socket) do {:noreply, socket |> assign(:new_token, nil) |> push_patch(to: ~p"/agents")} end @impl true def handle_event("show_setup", %{"id" => id}, socket) do agent_token = Agents.get_agent_token!(id) {:noreply, socket |> assign(:new_token, %{agent_token: agent_token, token: agent_token.token}) |> push_patch(to: ~p"/agents?modal=setup")} end @impl true def handle_event("delete_agent", %{"id" => id}, socket) do agent_token = Agents.get_agent_token!(id) if agent_authorized_for_deletion?(socket, agent_token) do handle_agent_deletion(socket, id) else {:noreply, put_flash(socket, :error, t_equipment("Agent not found"))} end end @impl true def handle_event("update_selected_global_default", %{"agent_token_id" => agent_token_id}, socket) do # Just update the selected value in the dropdown, don't save yet {:noreply, assign(socket, :selected_global_default, agent_token_id)} end @impl true def handle_event("save_global_default", _params, socket) do current_scope = socket.assigns.current_scope if Scope.superuser?(current_scope) do agent_token_id = socket.assigns.selected_global_default # Handle empty string as nil agent_token_id = if agent_token_id == "", do: nil, else: agent_token_id # Validate that the agent exists if not nil case validate_and_save_global_default(agent_token_id) do {:ok, validated_id} -> handle_global_default_success(socket, validated_id) {:error, :agent_not_found} -> {:noreply, put_flash(socket, :error, t_equipment("Selected agent no longer exists. Please choose another agent."))} {:error, _} -> {:noreply, put_flash(socket, :error, t_equipment("Failed to update global default cloud poller"))} end else {:noreply, put_flash(socket, :error, t_equipment("Only superadmins can set the global default cloud poller"))} end end defp apply_action(socket, :index, _params) do socket end defp parse_agent_params(params) do case params do %{"agent_form" => %{"name" => n, "is_cloud_poller" => cp}} -> {n, cp in ["true", "on", true]} %{"agent_form" => %{"name" => n}} -> {n, false} %{"name" => n, "is_cloud_poller" => cp} -> {n, cp in ["true", "on", true]} %{"name" => n} -> {n, false} end end defp validate_cloud_poller_permission(scope, is_cloud_poller) do if is_cloud_poller && !Scope.superuser?(scope) do {:error, :unauthorized} else :ok end end defp create_agent(organization, name, is_cloud_poller) do if is_cloud_poller do Agents.create_cloud_poller(name) else Agents.create_agent_token(organization.id, name) end end defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do organization = socket.assigns.current_scope.organization agent_tokens = Agents.list_organization_agent_tokens(organization.id) cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope) equipment_counts = calculate_device_counts(agent_tokens) cloud_poller_counts = calculate_device_counts(cloud_pollers) success_message = get_success_message(is_cloud_poller) {:noreply, socket |> stream(:agent_tokens, agent_tokens, reset: true) |> assign(:has_agents, agent_tokens != []) |> stream(:cloud_pollers, cloud_pollers, reset: true) |> assign(:cloud_pollers_list, cloud_pollers) |> assign(:has_cloud_pollers, cloud_pollers != []) |> assign(:device_counts, equipment_counts) |> assign(:cloud_poller_counts, cloud_poller_counts) |> assign(:new_token, %{agent_token: agent_token, token: token}) |> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false})) |> put_flash(:info, success_message) |> push_patch(to: ~p"/agents?modal=setup")} end defp load_cloud_pollers_if_superuser(scope) do if Scope.superuser?(scope), do: Agents.list_cloud_pollers(), else: [] end defp calculate_device_counts(agent_tokens) do token_ids = Enum.map(agent_tokens, & &1.id) direct_counts = Agents.count_assigned_devices_batch(token_ids) Map.new(agent_tokens, fn t -> direct = Map.get(direct_counts, t.id, 0) total = Agents.count_agent_polling_targets(t.id) {t.id, %{direct: direct, total: total}} end) end defp get_success_message(is_cloud_poller) do if is_cloud_poller, do: t_equipment("Cloud poller created successfully"), else: t_equipment("Agent created successfully") end defp validate_and_save_global_default(nil) do # Clearing the default is always valid case Settings.set_global_default_cloud_poller(nil) do {:ok, _} -> {:ok, nil} error -> error end end defp validate_and_save_global_default(agent_token_id) do # Verify the agent exists before saving %Agents.AgentToken{} = Agents.get_agent_token!(agent_token_id) case Settings.set_global_default_cloud_poller(agent_token_id) do {:ok, _} -> {:ok, agent_token_id} error -> error end rescue Ecto.NoResultsError -> {:error, :agent_not_found} end defp handle_global_default_success(socket, agent_token_id) do message = if agent_token_id, do: t_equipment("Global default cloud poller set successfully"), else: t_equipment("Global default cloud poller cleared") {:noreply, socket |> assign(:global_default_cloud_poller_id, agent_token_id) |> assign(:selected_global_default, agent_token_id || "") |> put_flash(:info, message)} end @impl true def handle_info(:tick, socket) do {:noreply, assign(socket, :now, DateTime.utc_now())} end # Handle real-time agent status updates # Only process events for this organization's agents, or cloud pollers if superuser @impl true def handle_info({:agent_connected, agent_token_id, organization_id}, socket) do {:noreply, maybe_update_agent(socket, agent_token_id, organization_id)} end @impl true def handle_info({:agent_disconnected, agent_token_id, organization_id}, socket) do {:noreply, maybe_update_agent(socket, agent_token_id, organization_id)} end @impl true def handle_info({:agent_heartbeat, agent_token_id, organization_id}, socket) do {:noreply, maybe_update_agent(socket, agent_token_id, organization_id)} end @impl true def handle_info({:agents_stale, _stale_agents}, socket) do {:noreply, reload_agent_data(socket)} end defp reload_agent_data(socket) do organization = socket.assigns.current_scope.organization agent_tokens = Agents.list_organization_agent_tokens(organization.id) cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope) equipment_counts = calculate_device_counts(agent_tokens) cloud_poller_counts = calculate_device_counts(cloud_pollers) socket |> stream(:agent_tokens, agent_tokens, reset: true) |> assign(:has_agents, agent_tokens != []) |> stream(:cloud_pollers, cloud_pollers, reset: true) |> assign(:cloud_pollers_list, cloud_pollers) |> assign(:has_cloud_pollers, cloud_pollers != []) |> assign(:device_counts, equipment_counts) |> assign(:cloud_poller_counts, cloud_poller_counts) end defp maybe_update_agent(socket, agent_token_id, organization_id) do my_org_id = socket.assigns.current_scope.organization.id agent_token = Agents.get_agent_token!(agent_token_id) cond do agent_token.is_cloud_poller -> # Cloud pollers only visible to superusers if socket.assigns.is_superuser do socket |> stream_insert(:cloud_pollers, agent_token) |> update_health_stats() else socket end organization_id == my_org_id -> # Regular agent belonging to this organization socket |> stream_insert(:agent_tokens, agent_token) |> update_health_stats() true -> # Agent from another organization, ignore socket end rescue Ecto.NoResultsError -> # Agent was deleted, just refresh stats update_health_stats(socket) end defp update_health_stats(socket) do socket end defp agent_authorized_for_deletion?(socket, agent_token) do organization = socket.assigns.current_scope.organization is_superuser = Scope.superuser?(socket.assigns.current_scope) is_cloud_poller = is_nil(agent_token.organization_id) belongs_to_org = agent_token.organization_id == organization.id belongs_to_org or (is_superuser and is_cloud_poller) end defp handle_agent_deletion(socket, id) do case Agents.delete_agent_token(id) do {:ok, _} -> handle_agent_deletion_success(socket, id) {:error, _} -> {:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))} end end defp handle_agent_deletion_success(socket, deleted_id) do maybe_clear_global_default(socket, deleted_id) organization = socket.assigns.current_scope.organization agent_tokens = Agents.list_organization_agent_tokens(organization.id) cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope) global_default_cloud_poller_id = reload_global_default(socket.assigns.current_scope, socket.assigns) equipment_counts = calculate_device_counts(agent_tokens) cloud_poller_counts = calculate_device_counts(cloud_pollers) {:noreply, socket |> stream(:agent_tokens, agent_tokens, reset: true) |> assign(:has_agents, agent_tokens != []) |> stream(:cloud_pollers, cloud_pollers, reset: true) |> assign(:cloud_pollers_list, cloud_pollers) |> assign(:has_cloud_pollers, cloud_pollers != []) |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id) |> assign(:selected_global_default, global_default_cloud_poller_id || "") |> assign(:device_counts, equipment_counts) |> assign(:cloud_poller_counts, cloud_poller_counts) |> put_flash( :info, t_equipment("Agent deleted successfully. Devices now fall back to site/org defaults or cloud polling.") )} end defp maybe_clear_global_default(socket, deleted_id) do if Scope.superuser?(socket.assigns.current_scope) && socket.assigns.global_default_cloud_poller_id == deleted_id do Settings.set_global_default_cloud_poller(nil) end end defp reload_global_default(scope, assigns) do if Scope.superuser?(scope) do Settings.get_global_default_cloud_poller() else assigns.global_default_cloud_poller_id end end @impl true def terminate(_reason, socket) do _ = if timer_ref = socket.assigns[:timer_ref] do _ = :timer.cancel(timer_ref) end :ok end end