diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 28bde58c..71837cda 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -139,19 +139,55 @@ defmodule Towerops.Devices do Returns devices that have MikroTik API enabled, credentials, and an assigned agent. """ def list_mikrotik_devices_with_api do + # Get all MikroTik devices with API enabled and credentials configured + # Agent assignment is resolved hierarchically in backup_device/1 (includes global default) Repo.all( from(d in DeviceSchema, left_join: s in assoc(d, :site), left_join: o in assoc(s, :organization), + left_join: aa in assoc(d, :agent_assignments), where: d.mikrotik_enabled == true, - where: not is_nil(d.agent_token_id), where: not is_nil(d.mikrotik_username) or not is_nil(s.mikrotik_username) or not is_nil(o.mikrotik_username), order_by: [asc: d.name], - preload: [site: {s, organization: o}] + preload: [site: {s, organization: o}, agent_assignments: aa], + distinct: d.id ) ) end + @doc """ + Resolves the agent token ID for a device using hierarchical resolution: + 1. Direct agent assignment (via agent_assignments table) + 2. Site-level agent assignment + 3. Organization default agent + 4. Global default cloud poller + + Returns nil if no agent is assigned. + """ + def resolve_agent_token_id(device) do + # Ensure site and organization are preloaded + device = Repo.preload(device, site: :organization, agent_assignments: []) + + # Try each level in order + get_direct_agent(device) || + get_site_agent(device) || + get_org_default_agent(device) || + get_global_default_agent() + end + + defp get_direct_agent(device) do + case Enum.find(device.agent_assignments, & &1.enabled) do + %{agent_token_id: id} when not is_nil(id) -> id + _ -> nil + end + end + + defp get_site_agent(device), do: device.site.agent_token_id + + defp get_org_default_agent(device), do: device.site.organization.default_agent_token_id + + defp get_global_default_agent, do: Towerops.Settings.get_global_default_cloud_poller() + @doc """ Gets a single device by ID, returns nil if not found. """ @@ -344,6 +380,7 @@ defmodule Towerops.Devices do username: resolve_mikrotik_username(device), password: resolve_mikrotik_password(device), port: resolve_mikrotik_port(device), + ssh_port: resolve_mikrotik_ssh_port(device), use_ssl: resolve_mikrotik_use_ssl(device), enabled: resolve_mikrotik_enabled(device), source: determine_mikrotik_source(device) @@ -369,6 +406,13 @@ defmodule Towerops.Devices do 8729 end + defp resolve_mikrotik_ssh_port(device) do + device.mikrotik_ssh_port || + device.site.mikrotik_ssh_port || + device.site.organization.mikrotik_ssh_port || + 22 + end + defp resolve_mikrotik_use_ssl(device) do if device.mikrotik_use_ssl == nil do device.site.mikrotik_use_ssl || diff --git a/lib/towerops/devices/backup_request.ex b/lib/towerops/devices/backup_request.ex index 7cc6fb8a..32c1dbb9 100644 --- a/lib/towerops/devices/backup_request.ex +++ b/lib/towerops/devices/backup_request.ex @@ -15,6 +15,7 @@ defmodule Towerops.Devices.BackupRequest do field :completed_at, :utc_datetime field :status, :string field :error_message, :string + field :source, :string belongs_to :device, Towerops.Devices.Device @@ -30,10 +31,12 @@ defmodule Towerops.Devices.BackupRequest do :requested_at, :completed_at, :status, - :error_message + :error_message, + :source ]) - |> validate_required([:device_id, :job_id, :requested_at, :status]) + |> validate_required([:device_id, :job_id, :requested_at, :status, :source]) |> validate_inclusion(:status, ["pending", "success", "failed", "timeout"]) + |> validate_inclusion(:source, ["manual", "daily_cron"]) |> foreign_key_constraint(:device_id) end end diff --git a/lib/towerops/devices/backup_requests.ex b/lib/towerops/devices/backup_requests.ex index f2bc9e2c..18c3053c 100644 --- a/lib/towerops/devices/backup_requests.ex +++ b/lib/towerops/devices/backup_requests.ex @@ -11,12 +11,13 @@ defmodule Towerops.Devices.BackupRequests do @doc """ Creates a new backup request for a device. """ - def create_request(device_id, job_id) do + def create_request(device_id, job_id, source \\ "daily_cron") do attrs = %{ device_id: device_id, job_id: job_id, requested_at: DateTime.utc_now(), - status: "pending" + status: "pending", + source: source } %BackupRequest{} diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index 814c2980..24730979 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -43,6 +43,7 @@ defmodule Towerops.Devices.Device do field :mikrotik_username, :string field :mikrotik_password, Towerops.Encrypted.Binary field :mikrotik_port, :integer + field :mikrotik_ssh_port, :integer field :mikrotik_use_ssl, :boolean field :mikrotik_enabled, :boolean field :mikrotik_credential_source, :string, default: "site" @@ -75,6 +76,7 @@ defmodule Towerops.Devices.Device do mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, + mikrotik_ssh_port: integer() | nil, mikrotik_use_ssl: boolean() | nil, mikrotik_enabled: boolean() | nil, mikrotik_credential_source: String.t() | nil, @@ -110,6 +112,7 @@ defmodule Towerops.Devices.Device do :mikrotik_username, :mikrotik_password, :mikrotik_port, + :mikrotik_ssh_port, :mikrotik_use_ssl, :mikrotik_enabled, :mikrotik_credential_source diff --git a/lib/towerops/devices/mikrotik_backups.ex b/lib/towerops/devices/mikrotik_backups.ex index a79afcaa..cf836386 100644 --- a/lib/towerops/devices/mikrotik_backups.ex +++ b/lib/towerops/devices/mikrotik_backups.ex @@ -118,4 +118,11 @@ defmodule Towerops.Devices.MikrotikBackups do def get_backup!(id) do Repo.get!(MikrotikBackup, id) end + + @doc """ + Deletes a backup. + """ + def delete_backup(%MikrotikBackup{} = backup) do + Repo.delete(backup) + end end diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index 4958f58a..89e1cdee 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -33,6 +33,7 @@ defmodule Towerops.Organizations.Organization do field :mikrotik_username, :string field :mikrotik_password, Towerops.Encrypted.Binary field :mikrotik_port, :integer, default: 8729 + field :mikrotik_ssh_port, :integer, default: 22 field :mikrotik_use_ssl, :boolean, default: true field :mikrotik_enabled, :boolean, default: false @@ -55,6 +56,7 @@ defmodule Towerops.Organizations.Organization do mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, + mikrotik_ssh_port: integer() | nil, mikrotik_use_ssl: boolean() | nil, mikrotik_enabled: boolean() | nil, default_agent_token_id: Ecto.UUID.t() | nil, @@ -78,6 +80,7 @@ defmodule Towerops.Organizations.Organization do :mikrotik_username, :mikrotik_password, :mikrotik_port, + :mikrotik_ssh_port, :mikrotik_use_ssl, :mikrotik_enabled ]) diff --git a/lib/towerops/proto/agent.pb.ex b/lib/towerops/proto/agent.pb.ex index b45a3545..bd42bf18 100644 --- a/lib/towerops/proto/agent.pb.ex +++ b/lib/towerops/proto/agent.pb.ex @@ -368,6 +368,7 @@ defmodule Towerops.Agent.MikrotikDevice do field :username, 3, type: :string field :password, 4, type: :string field :use_ssl, 5, type: :bool, json_name: "useSsl" + field :ssh_port, 6, type: :uint32, json_name: "sshPort" end defmodule Towerops.Agent.MikrotikCommand.ArgsEntry do diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index e8e017fc..0225755e 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -30,6 +30,7 @@ defmodule Towerops.Sites.Site do field :mikrotik_username, :string field :mikrotik_password, Towerops.Encrypted.Binary field :mikrotik_port, :integer + field :mikrotik_ssh_port, :integer field :mikrotik_use_ssl, :boolean field :mikrotik_enabled, :boolean @@ -53,6 +54,7 @@ defmodule Towerops.Sites.Site do mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, + mikrotik_ssh_port: integer() | nil, mikrotik_use_ssl: boolean() | nil, mikrotik_enabled: boolean() | nil, organization_id: Ecto.UUID.t(), @@ -83,6 +85,7 @@ defmodule Towerops.Sites.Site do :mikrotik_username, :mikrotik_password, :mikrotik_port, + :mikrotik_ssh_port, :mikrotik_use_ssl, :mikrotik_enabled ]) diff --git a/lib/towerops/workers/mikrotik_backup_worker.ex b/lib/towerops/workers/mikrotik_backup_worker.ex index 54af9f47..a91dcf1d 100644 --- a/lib/towerops/workers/mikrotik_backup_worker.ex +++ b/lib/towerops/workers/mikrotik_backup_worker.ex @@ -54,8 +54,10 @@ defmodule Towerops.Workers.MikrotikBackupWorker do end defp backup_device(device) do - # Check if device has an agent assigned - if is_nil(device.agent_token_id) do + # Resolve which agent this device should use (hierarchical: device -> site -> org -> global) + agent_token_id = Devices.resolve_agent_token_id(device) + + if is_nil(agent_token_id) do Logger.warning("Device has no agent assigned, skipping backup", device_id: device.id) {:skipped, :no_agent} else @@ -64,6 +66,27 @@ defmodule Towerops.Workers.MikrotikBackupWorker do mikrotik_config = Devices.get_mikrotik_config(device) + # Generate unique filename for this backup (without .rsc extension, RouterOS adds it) + backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}" + + # Build chunked read commands + # Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB + # Most MikroTik configs are under this size + chunk_size = 32_768 + num_chunks = 10 + + read_commands = + for i <- 0..(num_chunks - 1) do + %MikrotikCommand{ + command: "/file/read", + args: %{ + "file" => "#{backup_filename}.rsc", + "offset" => to_string(i * chunk_size), + "chunk-size" => to_string(chunk_size) + } + } + end + job = %AgentJob{ job_id: job_id, job_type: :MIKROTIK, @@ -73,14 +96,25 @@ defmodule Towerops.Workers.MikrotikBackupWorker do username: mikrotik_config.username, password: mikrotik_config.password, port: mikrotik_config.port, + ssh_port: mikrotik_config.ssh_port, use_ssl: mikrotik_config.use_ssl }, - mikrotik_commands: [ - %MikrotikCommand{ - command: "/export", - args: %{"compact" => "yes"} - } - ] + mikrotik_commands: + [ + # Step 1: Export config to file on router (compact format, no defaults) + %MikrotikCommand{ + command: "/export", + args: %{"file" => backup_filename, "compact" => ""} + } + ] ++ + read_commands ++ + [ + # Final step: Delete the temporary file + %MikrotikCommand{ + command: "/file/remove", + args: %{"numbers" => "#{backup_filename}.rsc"} + } + ] } # Create tracking request @@ -89,14 +123,14 @@ defmodule Towerops.Workers.MikrotikBackupWorker do # Broadcast to agent via PubSub Phoenix.PubSub.broadcast( Towerops.PubSub, - "agent:#{device.agent_token_id}:backup", + "agent:#{agent_token_id}:backup", {:backup_requested, job} ) Logger.info("Backup job sent to agent", device_id: device.id, job_id: job_id, - agent_token_id: device.agent_token_id + agent_token_id: agent_token_id ) {:ok, job_id} diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 73034859..7f6f7a1f 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -678,14 +678,19 @@ defmodule ToweropsWeb.AgentChannel do end defp handle_backup_request(request, %{error: ""} = result) do - with {:ok, config_text} <- extract_config_from_result(result), - {:ok, needs_backup?} <- MikrotikBackups.needs_backup?(result.device_id, config_text) do - if needs_backup? do - create_and_save_backup(request, result, config_text) - else - mark_backup_skipped(request, result) - end - else + case extract_config_from_result(result) do + {:ok, config_text} -> + # Manual backups always create a snapshot, even if config unchanged + # Automated backups (daily_cron) use deduplication to save storage + should_create_backup = + request.source == "manual" || backup_needed?(result.device_id, config_text) + + if should_create_backup do + create_and_save_backup(request, result, config_text) + else + mark_backup_skipped(request, result) + end + {:error, reason} -> mark_backup_failed(request, result, "Failed to extract config: #{reason}") end @@ -696,16 +701,21 @@ defmodule ToweropsWeb.AgentChannel do end defp create_and_save_backup(request, result, config_text) do - case MikrotikBackups.create_backup(result.device_id, config_text, "daily_cron") do + case MikrotikBackups.create_backup(result.device_id, config_text, request.source) do {:ok, _backup} -> BackupRequests.update_request_status(request.id, "success", nil) - Logger.info("Backup created", device_id: result.device_id) + Logger.info("Backup created", device_id: result.device_id, source: request.source) {:error, reason} -> mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}") end end + defp backup_needed?(device_id, config_text) do + {:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text) + needs_backup? + end + defp mark_backup_skipped(request, result) do BackupRequests.update_request_status(request.id, "success", nil) Logger.info("Backup skipped (no changes)", device_id: result.device_id) @@ -717,31 +727,73 @@ defmodule ToweropsWeb.AgentChannel do end defp extract_config_from_result(result) do - # The /export command returns the config as a single sentence with .after attribute - # containing the full configuration text - config_sentences = - Enum.filter(result.sentences, fn sentence -> - Map.has_key?(sentence.attributes, ".after") - end) + # SSH backups return a single sentence with "config" attribute (new method) + # API backups use /file/read which returns chunks with "data" attribute (old method) - case config_sentences do - [sentence | _] -> - {:ok, Map.get(sentence.attributes, ".after")} + # Try SSH backup format first (single sentence with "config" attribute) + config_direct = + Enum.find_value(result.sentences, fn sentence -> Map.get(sentence.attributes, "config") end) + case config_direct do + nil -> + # Try chunked API backup format + extract_config_from_chunked_data(result) + + config_text when is_binary(config_text) and config_text != "" -> + {:ok, config_text} + + _ -> + {:error, "Config attribute found but empty or invalid"} + end + end + + defp extract_config_from_chunked_data(result) do + # The old API backup workflow uses /file/read which returns chunks with "data" attribute + data_chunks = + result.sentences + |> Enum.filter(fn sentence -> Map.has_key?(sentence.attributes, "data") end) + |> Enum.map(fn sentence -> Map.get(sentence.attributes, "data") end) + + case data_chunks do [] -> - # Try concatenating all sentence attributes if no .after found - # Some RouterOS versions may return config differently - config_text = - result.sentences - |> Enum.map(fn sentence -> Map.values(sentence.attributes) end) - |> List.flatten() - |> Enum.join("\n") + # Fallback: try other methods + extract_config_from_fallback(result) + + chunks -> + # Combine all chunks in order + config_text = Enum.join(chunks, "") if config_text == "" do - {:error, "No config data in result"} + {:error, "No config data in result - all chunks were empty"} else {:ok, config_text} end end end + + defp extract_config_from_fallback(result) do + # Fallback: try to find any sentence with configuration-like content + # Look for sentences with common RouterOS config markers + config_text = + Enum.find_value(result.sentences, fn sentence -> + find_config_in_attributes(sentence.attributes) + end) + + case config_text do + nil -> {:error, "No config data in result - received #{length(result.sentences)} sentences"} + text -> {:ok, text} + end + end + + defp find_config_in_attributes(attributes) do + Enum.find_value(attributes, fn {_key, value} -> + if routeros_config?(value), do: value + end) + end + + defp routeros_config?(value) when is_binary(value) do + String.contains?(value, ["# ", "/", "set "]) and String.length(value) > 100 + end + + defp routeros_config?(_), do: false end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index f317052e..09d70055 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -62,11 +62,15 @@ defmodule ToweropsWeb.DeviceLive.Show do maybe_subscribe_and_schedule_refresh(socket, id) tab = Map.get(params, "tab", "overview") + page = params |> Map.get("page", "1") |> String.to_integer() - socket - |> load_equipment_data(id) - |> assign(:active_tab, tab) - |> then(&{:noreply, &1}) + socket = + socket + |> load_equipment_data(id) + |> assign(:active_tab, tab) + |> apply_backups_pagination(tab, page) + + {:noreply, socket} else {:noreply, socket @@ -283,6 +287,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:agent_info, agent_info) |> assign(:available_firmware, available_firmware) |> assign(:mikrotik_backups, mikrotik_backups) + |> assign(:selected_backup_ids, MapSet.new()) end defp get_available_firmware(nil), do: nil @@ -951,7 +956,7 @@ defmodule ToweropsWeb.DeviceLive.Show do device_with_site = Repo.preload(device, site: :organization) if device_with_site.site.organization_id == socket.assigns.current_scope.organization.id do - config_text = MikrotikBackups.decompress_config(backup) + config_text = MikrotikBackups.decompress_config(backup.config_compressed) filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" {:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})} @@ -961,32 +966,54 @@ defmodule ToweropsWeb.DeviceLive.Show do end @impl true - def handle_event("compare_backup", %{"id" => backup_id}, socket) do - device_id = socket.assigns.device.id - backup = MikrotikBackups.get_backup!(backup_id) + def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do + selected = socket.assigns.selected_backup_ids - case MikrotikBackups.get_latest_backup(device_id) do - nil -> - {:noreply, put_flash(socket, :error, t_equipment("No other backups available for comparison"))} - - latest when latest.id == backup.id -> - # Compare with second-latest - case MikrotikBackups.list_device_backups(device_id, limit: 2) do - [_, second_latest] -> - {:noreply, - push_navigate(socket, - to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{second_latest.id}" - )} - - _ -> - {:noreply, put_flash(socket, :error, t_equipment("No other backups available for comparison"))} + updated_selected = + if MapSet.member?(selected, backup_id) do + MapSet.delete(selected, backup_id) + else + # Limit to 2 selections max + if MapSet.size(selected) >= 2 do + selected + else + MapSet.put(selected, backup_id) end + end - latest -> + {:noreply, assign(socket, :selected_backup_ids, updated_selected)} + end + + @impl true + def handle_event("compare_selected", _params, socket) do + device_id = socket.assigns.device.id + selected = MapSet.to_list(socket.assigns.selected_backup_ids) + + case selected do + [backup_a, backup_b] -> {:noreply, - push_navigate(socket, - to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{latest.id}" - )} + socket + |> assign(:selected_backup_ids, MapSet.new()) + |> push_navigate(to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_a}&backup_b=#{backup_b}")} + + _ -> + {:noreply, put_flash(socket, :error, t_equipment("Please select exactly 2 backups to compare"))} + end + end + + @impl true + def handle_event("clear_selection", _params, socket) do + {:noreply, assign(socket, :selected_backup_ids, MapSet.new())} + end + + @impl true + def handle_event("delete_backup", %{"id" => backup_id}, socket) do + import ToweropsWeb.Permissions + + if owner?(socket) do + do_delete_backup(backup_id, socket) + else + {:noreply, put_flash(socket, :error, t_equipment("Only organization owners can delete backups"))} end end @@ -1017,6 +1044,26 @@ defmodule ToweropsWeb.DeviceLive.Show do job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" mikrotik_config = Devices.get_mikrotik_config(device) + # Generate unique filename for this backup (without .rsc extension, RouterOS adds it) + backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}" + + # Build chunked read commands + # Max chunk size is 32KB (32768 bytes), we'll read 10 chunks to cover 320KB + chunk_size = 32_768 + num_chunks = 10 + + read_commands = + for i <- 0..(num_chunks - 1) do + %MikrotikCommand{ + command: "/file/read", + args: %{ + "file" => "#{backup_filename}.rsc", + "offset" => to_string(i * chunk_size), + "chunk-size" => to_string(chunk_size) + } + } + end + job = %AgentJob{ job_id: job_id, job_type: :MIKROTIK, @@ -1026,17 +1073,28 @@ defmodule ToweropsWeb.DeviceLive.Show do username: mikrotik_config.username, password: mikrotik_config.password, port: mikrotik_config.port, + ssh_port: mikrotik_config.ssh_port, use_ssl: mikrotik_config.use_ssl }, - mikrotik_commands: [ - %MikrotikCommand{ - command: "/export", - args: %{"compact" => "yes"} - } - ] + mikrotik_commands: + [ + # Step 1: Export config to file on router (compact format, no defaults) + %MikrotikCommand{ + command: "/export", + args: %{"file" => backup_filename, "compact" => ""} + } + ] ++ + read_commands ++ + [ + # Final step: Delete the temporary file + %MikrotikCommand{ + command: "/file/remove", + args: %{"numbers" => "#{backup_filename}.rsc"} + } + ] } - case BackupRequests.create_request(device.id, job_id) do + case BackupRequests.create_request(device.id, job_id, "manual") do {:ok, _request} -> Phoenix.PubSub.broadcast( Towerops.PubSub, @@ -1053,4 +1111,54 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, put_flash(socket, :error, t_equipment("Failed to create backup request. Please try again."))} end end + + # Private helper for deleting backups + defp do_delete_backup(backup_id, socket) do + backup = MikrotikBackups.get_backup!(backup_id) + + # Verify the backup belongs to a device in the user's organization + device = Devices.get_device!(backup.device_id) + device_with_site = Repo.preload(device, site: :organization) + + if device_with_site.site.organization_id == socket.assigns.current_scope.organization.id do + case MikrotikBackups.delete_backup(backup) do + {:ok, _} -> + {:noreply, + socket + |> put_flash(:info, t_equipment("Backup deleted successfully")) + |> load_equipment_data(socket.assigns.device.id)} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, t_equipment("Failed to delete backup"))} + end + else + {:noreply, put_flash(socket, :error, t_equipment("You don't have permission to access this backup"))} + end + end + + # Apply pagination to backups list if on the backups tab + defp apply_backups_pagination(socket, "backups", page) when is_map_key(socket.assigns, :mikrotik_backups) do + per_page = 20 + all_backups = socket.assigns.mikrotik_backups + total_count = length(all_backups) + total_pages = ceil(total_count / per_page) + + # Ensure page is within valid range + page = max(1, min(page, max(1, total_pages))) + + # Slice backups for current page + offset = (page - 1) * per_page + backups_page = Enum.slice(all_backups, offset, per_page) + + socket + |> assign(:mikrotik_backups, backups_page) + |> assign(:pagination, %{ + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + }) + end + + defp apply_backups_pagination(socket, _tab, _page), do: socket end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 99729ec0..39bd2fc2 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -1484,10 +1484,25 @@ <%= if Enum.any?(@mikrotik_backups) do %> + <%!-- Instructions for comparing backups --%> + <%= if length(@mikrotik_backups) > 1 do %> +
+
+ <.icon name="hero-information-circle" class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" /> +
+ Compare configurations: + Select any 2 backups using the checkboxes to compare their differences +
+
+
+ <% end %>
+ @@ -1508,8 +1523,30 @@ 0.0 end %> +
+ <.icon name="hero-check-circle" class="h-4 w-4 mx-auto" /> + Date Original Size Compressed Size
+ = 2 + } + title={ + if MapSet.member?(@selected_backup_ids, backup.id), + do: "Selected for comparison", + else: + if(MapSet.size(@selected_backup_ids) >= 2, + do: "Maximum 2 backups can be selected", + else: "Select to compare" + ) + } + class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700" + /> + - {Calendar.strftime(backup.backed_up_at, "%Y-%m-%d %H:%M:%S")} + {ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)} {format_bytes(backup.config_size_bytes)} @@ -1543,13 +1580,14 @@ > <.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download - <%= if length(@mikrotik_backups) > 1 do %> + <%= if ToweropsWeb.Permissions.owner?(@current_scope) do %> <% end %> @@ -1559,6 +1597,58 @@
+ <%!-- Pagination --%> + <%= if assigns[:pagination] do %> +
+ <.pagination + meta={@pagination} + path={~p"/devices/#{@device.id}"} + params={%{"tab" => "backups"}} + /> +
+ <% end %> + <%!-- Floating Compare Button --%> + <%= if MapSet.size(@selected_backup_ids) > 0 do %> +
+
+
+ <%!-- Selection status --%> +
+ <.icon name="hero-check-circle" class="h-5 w-5 text-blue-600 dark:text-blue-400" /> +
+ {MapSet.size(@selected_backup_ids)} + of 2 backups selected +
+
+ <%!-- Instructions when only 1 selected --%> + <%= if MapSet.size(@selected_backup_ids) == 1 do %> +
+ Select one more backup to compare +
+ <% end %> + <%!-- Action buttons --%> +
+ + +
+
+
+
+ <% end %> <% else %>
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" /> diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index d848c171..ab3551bf 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -192,6 +192,10 @@ defmodule ToweropsWeb.HelpLive.Index do interface that makes complex monitoring workflows simple and accessible.

+

+ Towerops is not and has no plans to be a replacement for a full WISP/network billing system. +

+

diff --git a/lib/towerops_web/live/mikrotik_backup_live/compare.ex b/lib/towerops_web/live/mikrotik_backup_live/compare.ex index 895ecd78..3d2c2336 100644 --- a/lib/towerops_web/live/mikrotik_backup_live/compare.ex +++ b/lib/towerops_web/live/mikrotik_backup_live/compare.ex @@ -56,8 +56,8 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do defp generate_and_display_diff(socket, device, backup_a, backup_b, device_id) do # Decompress configs - config_a = MikrotikBackups.decompress_config(backup_a) - config_b = MikrotikBackups.decompress_config(backup_b) + config_a = MikrotikBackups.decompress_config(backup_a.config_compressed) + config_b = MikrotikBackups.decompress_config(backup_b.config_compressed) # Mask sensitive data config_a_masked = Differ.mask_sensitive_data(config_a) @@ -88,7 +88,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do @impl true def handle_event("download_config", %{"backup" => "a"}, socket) do backup = socket.assigns.backup_a - config_text = MikrotikBackups.decompress_config(backup) + config_text = MikrotikBackups.decompress_config(backup.config_compressed) device = socket.assigns.device filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" @@ -99,7 +99,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do @impl true def handle_event("download_config", %{"backup" => "b"}, socket) do backup = socket.assigns.backup_b - config_text = MikrotikBackups.decompress_config(backup) + config_text = MikrotikBackups.decompress_config(backup.config_compressed) device = socket.assigns.device filename = "#{device.name}_backup_#{Calendar.strftime(backup.backed_up_at, "%Y%m%d_%H%M%S")}.rsc" diff --git a/lib/towerops_web/permissions.ex b/lib/towerops_web/permissions.ex new file mode 100644 index 00000000..166819be --- /dev/null +++ b/lib/towerops_web/permissions.ex @@ -0,0 +1,161 @@ +defmodule ToweropsWeb.Permissions do + @moduledoc """ + Authorization helpers for checking user permissions in LiveViews, controllers, and templates. + + Integrates with `Towerops.Organizations.Policy` to provide a simple, reusable API + for permission checks based on organization membership roles. + + ## Usage in LiveViews + + import ToweropsWeb.Permissions + + def handle_event("delete_backup", _params, socket) do + if can?(socket, :delete, :backup) do + # Delete backup + else + {:noreply, put_flash(socket, :error, "You don't have permission to delete backups")} + end + end + + ## Usage in Templates + + <%= if can?(@current_scope, :delete, :backup) do %> + + <% end %> + + ## Usage in Controllers + + import ToweropsWeb.Permissions + + def delete(conn, _params) do + if can?(conn.assigns.current_scope, :delete, :backup) do + # Delete backup + else + conn + |> put_flash(:error, "You don't have permission to delete backups") + |> redirect(to: ~p"/") + end + end + """ + + alias Phoenix.LiveView.Socket + alias Towerops.Accounts.Scope + alias Towerops.Organizations.Policy + + @doc """ + Checks if the current user can perform an action on a resource. + + Accepts either a socket (LiveView) or a Scope struct directly. + + Returns `true` if: + - User is a superuser (can do everything) + - User's organization membership role allows the action per Policy + + ## Examples + + iex> can?(socket, :delete, :backup) + true + + iex> can?(%Scope{}, :edit, :device) + false + """ + def can?(%Socket{} = socket, action, resource) do + can?(socket.assigns.current_scope, action, resource) + end + + def can?(%Scope{} = scope, action, resource) do + cond do + # Superusers can do everything + Scope.superuser?(scope) -> + true + + # No organization context - cannot perform action + is_nil(scope.organization) -> + false + + # Check organization membership role permissions + true -> + membership = get_membership(scope) + Policy.can?(membership, action, resource) + end + end + + def can?(nil, _action, _resource), do: false + + @doc """ + Checks if the current user is an organization owner. + + Accepts either a socket (LiveView) or a Scope struct directly. + + ## Examples + + iex> owner?(socket) + true + + iex> owner?(%Scope{}) + false + """ + def owner?(%Socket{} = socket) do + owner?(socket.assigns.current_scope) + end + + def owner?(%Scope{} = scope) do + cond do + Scope.superuser?(scope) -> + true + + is_nil(scope.organization) -> + false + + true -> + membership = get_membership(scope) + membership && membership.role == :owner + end + end + + def owner?(nil), do: false + + @doc """ + Checks if the current user is an organization owner or admin. + + Accepts either a socket (LiveView) or a Scope struct directly. + + ## Examples + + iex> admin?(socket) + true + + iex> admin?(%Scope{}) + false + """ + def admin?(%Socket{} = socket) do + admin?(socket.assigns.current_scope) + end + + def admin?(%Scope{} = scope) do + cond do + Scope.superuser?(scope) -> + true + + is_nil(scope.organization) -> + false + + true -> + membership = get_membership(scope) + membership && membership.role in [:owner, :admin] + end + end + + def admin?(nil), do: false + + # Private helper to extract membership from scope + defp get_membership(%Scope{organization: org, user: user}) when not is_nil(org) and not is_nil(user) do + # Organization should be preloaded with memberships + if Ecto.assoc_loaded?(org.memberships) do + Enum.find(org.memberships, &(&1.user_id == user.id)) + # If memberships not preloaded, return nil (permission check will fail safely) + end + end + + defp get_membership(_scope), do: nil +end diff --git a/priv/proto/agent.proto b/priv/proto/agent.proto index e26ee0b4..24a133c3 100644 --- a/priv/proto/agent.proto +++ b/priv/proto/agent.proto @@ -107,64 +107,63 @@ message HeartbeatResponse { string status = 1; } -// Channel-based communication messages +// WebSocket-specific messages enum JobType { - DISCOVER = 0; // Full device discovery - POLL = 1; // Poll known sensors/interfaces - MIKROTIK = 2; // MikroTik RouterOS API commands + DISCOVER = 0; + POLL = 1; + MIKROTIK = 2; } enum QueryType { - GET = 0; // SNMP GET operation - WALK = 1; // SNMP WALK operation -} - -message AgentJob { - string job_id = 1; // Unique job identifier (e.g., "discover:eq123") - JobType job_type = 2; // DISCOVER or POLL - string device_id = 3; // Device UUID - SnmpDevice snmp_device = 4; // SNMP connection details - repeated SnmpQuery queries = 5; // Queries to execute - MikrotikDevice mikrotik_device = 6; // MikroTik API connection details - repeated MikrotikCommand mikrotik_commands = 7; // MikroTik commands to execute + GET = 0; + WALK = 1; } message AgentJobList { repeated AgentJob jobs = 1; } +message AgentJob { + string job_id = 1; + JobType job_type = 2; + string device_id = 3; + SnmpDevice snmp_device = 4; + repeated SnmpQuery queries = 5; + MikrotikDevice mikrotik_device = 6; + repeated MikrotikCommand mikrotik_commands = 7; +} + message SnmpDevice { string ip = 1; string community = 2; - string version = 3; // "1", "2c", or "3" + string version = 3; uint32 port = 4; } message SnmpQuery { QueryType query_type = 1; - repeated string oids = 2; // OIDs to query (GET) or walk (WALK) + repeated string oids = 2; } message SnmpResult { string device_id = 1; JobType job_type = 2; - map oid_values = 3; // OID → value mapping - int64 timestamp = 4; // Unix timestamp in seconds + map oid_values = 3; + int64 timestamp = 4; } message AgentHeartbeat { string version = 1; string hostname = 2; uint64 uptime_seconds = 3; - string ip_address = 4; // Agent's IP address + string ip_address = 4; } message AgentError { string device_id = 1; - string job_id = 2; - string message = 3; - int64 timestamp = 4; + string error_message = 2; + int64 timestamp = 3; } // MikroTik RouterOS API messages @@ -174,22 +173,23 @@ message MikrotikDevice { uint32 port = 2; string username = 3; string password = 4; - bool use_ssl = 5; // true = API-SSL (port 8729), false = plain API (port 8728) + bool use_ssl = 5; + uint32 ssh_port = 6; } message MikrotikCommand { - string command = 1; // RouterOS command path (e.g., "/system/identity/print") - map args = 2; // Command arguments + string command = 1; + map args = 2; } message MikrotikResult { string device_id = 1; string job_id = 2; - repeated MikrotikSentence sentences = 3; // Response sentences from RouterOS - string error = 4; // Error message if command failed - int64 timestamp = 5; // Unix timestamp in seconds + repeated MikrotikSentence sentences = 3; + string error = 4; + int64 timestamp = 5; } message MikrotikSentence { - map attributes = 1; // Key-value pairs from RouterOS response + map attributes = 1; } diff --git a/priv/repo/migrations/20260202215155_add_mikrotik_ssh_port.exs b/priv/repo/migrations/20260202215155_add_mikrotik_ssh_port.exs new file mode 100644 index 00000000..a900e7f2 --- /dev/null +++ b/priv/repo/migrations/20260202215155_add_mikrotik_ssh_port.exs @@ -0,0 +1,20 @@ +defmodule Towerops.Repo.Migrations.AddMikrotikSshPort do + use Ecto.Migration + + def change do + # Add SSH port to organizations (default 22 for new orgs) + alter table(:organizations) do + add :mikrotik_ssh_port, :integer, default: 22 + end + + # Add SSH port to sites (nullable for inheritance) + alter table(:sites) do + add :mikrotik_ssh_port, :integer + end + + # Add SSH port to devices (nullable for inheritance) + alter table(:devices) do + add :mikrotik_ssh_port, :integer + end + end +end diff --git a/priv/repo/migrations/20260202221637_add_source_to_backup_requests.exs b/priv/repo/migrations/20260202221637_add_source_to_backup_requests.exs new file mode 100644 index 00000000..d4b4b786 --- /dev/null +++ b/priv/repo/migrations/20260202221637_add_source_to_backup_requests.exs @@ -0,0 +1,9 @@ +defmodule Towerops.Repo.Migrations.AddSourceToBackupRequests do + use Ecto.Migration + + def change do + alter table(:device_backup_requests) do + add :source, :string, default: "daily_cron" + end + end +end