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-check-circle" class="h-4 w-4 mx-auto" /> + | Date | Original Size | Compressed Size | @@ -1508,8 +1523,30 @@ 0.0 end %>
|---|---|---|---|
| + = 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 @@ |
+ Towerops is not and has no plans to be a replacement for a full WISP/network billing system. +
+