diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index c8a95f8e..2c349dcb 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -27,6 +27,12 @@ # Honeybadger - unknown function false positives {"deps/honeybadger/lib/honeybadger/plug.ex", :unknown_function}, + # Cloak - callback info and unknown function warnings + {"deps/cloak/lib/cloak/vault.ex", :unknown_function}, + {"/home/runner/work/elixir/elixir/lib/elixir/lib/gen_server.ex", :callback_info_missing}, + {"deps/cloak_ecto/lib/cloak_ecto/type.ex", :callback_info_missing}, + {"deps/cloak_ecto/lib/cloak_ecto/types/binary.ex", :unknown_function}, + # === Vendored library warnings === # SnmpKit vendored library - ignore all warnings diff --git a/CLAUDE.md b/CLAUDE.md index f3684d7c..d8c26594 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -938,4 +938,5 @@ When writing new LiveView code or JavaScript hooks: - Navigate away and back multiple times - Take another snapshot - Compare - memory should not continuously grow -- assets are rebuilt automatically on filesystem change \ No newline at end of file +- assets are rebuilt automatically on filesystem change +- never try to run npm, it's not included in phoenix \ No newline at end of file diff --git a/assets/js/app.ts b/assets/js/app.ts index 5ca0c621..aacee415 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -897,6 +897,28 @@ window.addEventListener("phx:copy", (event: any) => { } }) +// Handle file download events +window.addEventListener("phx:download", (event: any) => { + const { content, filename, mime_type } = event.detail + + // Create a blob from the content + const blob = new Blob([content], { type: mime_type }) + + // Create a temporary download link + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + + // Trigger the download + document.body.appendChild(link) + link.click() + + // Clean up + document.body.removeChild(link) + window.URL.revokeObjectURL(url) +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/config/dev.exs b/config/dev.exs index d3114fb4..02074897 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -53,7 +53,13 @@ config :towerops, Oban, # Health check for missing jobs every 10 minutes {"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker}, # Fetch latest firmware versions daily at 2 AM - {"0 2 * * *", Towerops.Workers.FirmwareVersionFetcherWorker} + {"0 2 * * *", Towerops.Workers.FirmwareVersionFetcherWorker}, + # MikroTik configuration backups daily at 7 AM UTC + {"0 7 * * *", Towerops.Workers.MikrotikBackupWorker}, + # Mark stale backup requests as timeout every 10 minutes + {"*/10 * * * *", Towerops.Workers.BackupTimeoutWorker}, + # Send backup summary email daily at 8 AM + {"0 8 * * *", Towerops.Workers.BackupSummaryWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/config/runtime.exs b/config/runtime.exs index 729b6147..a7f13ed5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -133,12 +133,18 @@ if config_env() == :prod do {"*/5 * * * *", Towerops.Workers.AgentLatencyEvaluator}, # Health check for missing jobs every 10 minutes {"*/10 * * * *", Towerops.Workers.JobHealthCheckWorker}, + # Mark stale backup requests as timeout every 10 minutes + {"*/10 * * * *", Towerops.Workers.BackupTimeoutWorker}, # Clean up old login history daily at 2 AM {"0 2 * * *", Towerops.Workers.LoginHistoryCleanupWorker}, # Clean up expired browser sessions daily at 3 AM {"0 3 * * *", Towerops.Workers.SessionCleanupWorker}, # Fetch latest firmware versions daily at 4 AM - {"0 4 * * *", Towerops.Workers.FirmwareVersionFetcherWorker} + {"0 4 * * *", Towerops.Workers.FirmwareVersionFetcherWorker}, + # MikroTik configuration backups daily at 7 AM UTC + {"0 7 * * *", Towerops.Workers.MikrotikBackupWorker}, + # Send backup summary email daily at 8 AM + {"0 8 * * *", Towerops.Workers.BackupSummaryWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 193022b7..457f0c1e 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -26,6 +26,7 @@ defmodule Towerops.Accounts.User do field :first_name, :string field :last_name, :string field :timezone, :string, default: "UTC" + field :time_format, :string, default: "24h" field :totp_secret, :binary, redact: true field :totp_verified_at, :utc_datetime, virtual: true field :privacy_policy_consent, :boolean, virtual: true diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index ecb053e7..28bde58c 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -134,6 +134,24 @@ defmodule Towerops.Devices do ) end + @doc """ + Lists all devices with MikroTik API enabled and configured for backup. + Returns devices that have MikroTik API enabled, credentials, and an assigned agent. + """ + def list_mikrotik_devices_with_api do + Repo.all( + from(d in DeviceSchema, + left_join: s in assoc(d, :site), + left_join: o in assoc(s, :organization), + 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}] + ) + ) + end + @doc """ Gets a single device by ID, returns nil if not found. """ @@ -322,49 +340,52 @@ defmodule Towerops.Devices do defp resolve_mikrotik_config(device) do # Resolve each field independently with hierarchical fallback - username = - device.mikrotik_username || - device.site.mikrotik_username || - device.site.organization.mikrotik_username - - password = - device.mikrotik_password || - device.site.mikrotik_password || - device.site.organization.mikrotik_password - - port = - device.mikrotik_port || - device.site.mikrotik_port || - device.site.organization.mikrotik_port || - 8729 - - use_ssl = - if device.mikrotik_use_ssl == nil do - device.site.mikrotik_use_ssl || - device.site.organization.mikrotik_use_ssl || - true - else - device.mikrotik_use_ssl - end - - enabled = - device.mikrotik_enabled || - device.site.mikrotik_enabled || - device.site.organization.mikrotik_enabled || - false - - source = determine_mikrotik_source(device) - %{ - username: username, - password: password, - port: port, - use_ssl: use_ssl, - enabled: enabled, - source: source + username: resolve_mikrotik_username(device), + password: resolve_mikrotik_password(device), + port: resolve_mikrotik_port(device), + use_ssl: resolve_mikrotik_use_ssl(device), + enabled: resolve_mikrotik_enabled(device), + source: determine_mikrotik_source(device) } end + defp resolve_mikrotik_username(device) do + device.mikrotik_username || + device.site.mikrotik_username || + device.site.organization.mikrotik_username + end + + defp resolve_mikrotik_password(device) do + device.mikrotik_password || + device.site.mikrotik_password || + device.site.organization.mikrotik_password + end + + defp resolve_mikrotik_port(device) do + device.mikrotik_port || + device.site.mikrotik_port || + device.site.organization.mikrotik_port || + 8729 + end + + defp resolve_mikrotik_use_ssl(device) do + if device.mikrotik_use_ssl == nil do + device.site.mikrotik_use_ssl || + device.site.organization.mikrotik_use_ssl || + true + else + device.mikrotik_use_ssl + end + end + + defp resolve_mikrotik_enabled(device) do + device.mikrotik_enabled || + device.site.mikrotik_enabled || + device.site.organization.mikrotik_enabled || + false + end + defp determine_mikrotik_source(device) do # Determine source based on where username came from (primary credential) cond do diff --git a/lib/towerops/devices/backup_request.ex b/lib/towerops/devices/backup_request.ex new file mode 100644 index 00000000..7cc6fb8a --- /dev/null +++ b/lib/towerops/devices/backup_request.ex @@ -0,0 +1,39 @@ +defmodule Towerops.Devices.BackupRequest do + @moduledoc """ + Schema for MikroTik device backup request tracking. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "device_backup_requests" do + field :job_id, :string + field :requested_at, :utc_datetime + field :completed_at, :utc_datetime + field :status, :string + field :error_message, :string + + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(backup_request, attrs) do + backup_request + |> cast(attrs, [ + :device_id, + :job_id, + :requested_at, + :completed_at, + :status, + :error_message + ]) + |> validate_required([:device_id, :job_id, :requested_at, :status]) + |> validate_inclusion(:status, ["pending", "success", "failed", "timeout"]) + |> foreign_key_constraint(:device_id) + end +end diff --git a/lib/towerops/devices/backup_requests.ex b/lib/towerops/devices/backup_requests.ex new file mode 100644 index 00000000..f2bc9e2c --- /dev/null +++ b/lib/towerops/devices/backup_requests.ex @@ -0,0 +1,99 @@ +defmodule Towerops.Devices.BackupRequests do + @moduledoc """ + Context for managing MikroTik device backup request tracking. + """ + + import Ecto.Query + + alias Towerops.Devices.BackupRequest + alias Towerops.Repo + + @doc """ + Creates a new backup request for a device. + """ + def create_request(device_id, job_id) do + attrs = %{ + device_id: device_id, + job_id: job_id, + requested_at: DateTime.utc_now(), + status: "pending" + } + + %BackupRequest{} + |> BackupRequest.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets a backup request by job_id. + Returns nil if not found. + """ + def get_request_by_job_id(job_id) do + Repo.one(from(r in BackupRequest, where: r.job_id == ^job_id)) + end + + @doc """ + Updates a backup request status and sets completed_at. + """ + def update_request_status(request_id, status, error_message) do + request = Repo.get!(BackupRequest, request_id) + + attrs = %{ + status: status, + completed_at: DateTime.utc_now(), + error_message: error_message + } + + request + |> BackupRequest.changeset(attrs) + |> Repo.update() + end + + @doc """ + Marks all pending requests older than cutoff as timeout. + Returns count of updated requests. + """ + def mark_timed_out_requests(cutoff) do + {count, _} = + Repo.update_all(from(r in BackupRequest, where: r.status == "pending", where: r.requested_at < ^cutoff), + set: [status: "timeout", completed_at: DateTime.utc_now(), error_message: "Request timed out"] + ) + + count + end + + @doc """ + Returns summary of backup request statuses since given datetime. + """ + def summary_since(since) do + result = + from(r in BackupRequest, + where: r.requested_at >= ^since, + group_by: r.status, + select: {r.status, count(r.id)} + ) + |> Repo.all() + |> Map.new() + + %{ + success: Map.get(result, "success", 0), + failed: Map.get(result, "failed", 0), + timeout: Map.get(result, "timeout", 0), + pending: Map.get(result, "pending", 0) + } + end + + @doc """ + Lists devices with failed or timeout requests since given datetime. + Returns list of maps with device_id, status, and error_message. + """ + def list_failed_devices_since(since) do + Repo.all( + from(r in BackupRequest, + where: r.requested_at >= ^since, + where: r.status in ["failed", "timeout"], + select: %{device_id: r.device_id, status: r.status, error_message: r.error_message} + ) + ) + end +end diff --git a/lib/towerops/devices/mikrotik_backup.ex b/lib/towerops/devices/mikrotik_backup.ex new file mode 100644 index 00000000..05b9c9f5 --- /dev/null +++ b/lib/towerops/devices/mikrotik_backup.ex @@ -0,0 +1,48 @@ +defmodule Towerops.Devices.MikrotikBackup do + @moduledoc """ + Schema for MikroTik device configuration backups. + """ + use Ecto.Schema + + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema "device_mikrotik_backups" do + field :config_compressed, :binary + field :config_hash, :string + field :config_hash_normalized, :string + field :config_size_bytes, :integer + field :compressed_size_bytes, :integer + field :backed_up_at, :utc_datetime + field :trigger_source, :string + + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(backup, attrs) do + backup + |> cast(attrs, [ + :device_id, + :config_compressed, + :config_hash, + :config_hash_normalized, + :config_size_bytes, + :compressed_size_bytes, + :backed_up_at, + :trigger_source + ]) + |> validate_required([ + :device_id, + :config_compressed, + :config_hash, + :config_hash_normalized, + :backed_up_at + ]) + |> foreign_key_constraint(:device_id) + end +end diff --git a/lib/towerops/devices/mikrotik_backups.ex b/lib/towerops/devices/mikrotik_backups.ex new file mode 100644 index 00000000..a79afcaa --- /dev/null +++ b/lib/towerops/devices/mikrotik_backups.ex @@ -0,0 +1,121 @@ +defmodule Towerops.Devices.MikrotikBackups do + @moduledoc """ + Context for managing MikroTik device configuration backups. + """ + + import Ecto.Query + + alias Towerops.Devices.MikrotikBackup + alias Towerops.Repo + + @doc """ + Compresses config text using gzip. + """ + def compress_config(config_text) when is_binary(config_text) do + :zlib.compress(config_text) + end + + @doc """ + Decompresses config from binary. + """ + def decompress_config(compressed_binary) when is_binary(compressed_binary) do + :zlib.uncompress(compressed_binary) + end + + @doc """ + Normalizes config by removing comments and whitespace. + This prevents false positives from RouterOS timestamp comments. + """ + def normalize_config(config_text) when is_binary(config_text) do + config_text + |> String.split("\n") + |> Enum.reject(&String.starts_with?(&1, "#")) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.join("\n") + end + + @doc """ + Calculates SHA256 hash of raw config text. + Used for integrity verification. + """ + def calculate_hash(config_text) when is_binary(config_text) do + :sha256 |> :crypto.hash(config_text) |> Base.encode16(case: :lower) + end + + @doc """ + Calculates SHA256 hash of normalized config. + Used for deduplication - ignores timestamp comments. + """ + def calculate_hash_normalized(config_text) when is_binary(config_text) do + config_text + |> normalize_config() + |> then(&:crypto.hash(:sha256, &1)) + |> Base.encode16(case: :lower) + end + + @doc """ + Creates a new backup for a device. + Compresses config and calculates both raw and normalized hashes. + """ + def create_backup(device_id, config_text, trigger_source \\ "daily_cron") do + config_size = byte_size(config_text) + compressed = compress_config(config_text) + compressed_size = byte_size(compressed) + + attrs = %{ + device_id: device_id, + config_compressed: compressed, + config_hash: calculate_hash(config_text), + config_hash_normalized: calculate_hash_normalized(config_text), + config_size_bytes: config_size, + compressed_size_bytes: compressed_size, + backed_up_at: DateTime.utc_now(), + trigger_source: trigger_source + } + + %MikrotikBackup{} + |> MikrotikBackup.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets the most recent backup for a device. + Returns nil if no backups exist. + """ + def get_latest_backup(device_id) do + Repo.one(from(b in MikrotikBackup, where: b.device_id == ^device_id, order_by: [desc: b.backed_up_at], limit: 1)) + end + + @doc """ + Checks if a device needs a backup by comparing normalized hashes. + Returns {:ok, true} if backup needed, {:ok, false} if config unchanged. + """ + def needs_backup?(device_id, config_text) do + new_hash_normalized = calculate_hash_normalized(config_text) + + case get_latest_backup(device_id) do + nil -> + {:ok, true} + + latest -> + {:ok, latest.config_hash_normalized != new_hash_normalized} + end + end + + @doc """ + Lists backup history for a device, most recent first. + """ + def list_device_backups(device_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + + Repo.all(from(b in MikrotikBackup, where: b.device_id == ^device_id, order_by: [desc: b.backed_up_at], limit: ^limit)) + end + + @doc """ + Gets a backup by id. Raises if not found. + """ + def get_backup!(id) do + Repo.get!(MikrotikBackup, id) + end +end diff --git a/lib/towerops/devices/mikrotik_backups/differ.ex b/lib/towerops/devices/mikrotik_backups/differ.ex new file mode 100644 index 00000000..89f0c320 --- /dev/null +++ b/lib/towerops/devices/mikrotik_backups/differ.ex @@ -0,0 +1,202 @@ +defmodule Towerops.Devices.MikrotikBackups.Differ do + @moduledoc """ + Provides functionality for generating diffs between MikroTik configuration backups + and masking sensitive data in configurations. + """ + + @doc """ + Masks sensitive data in a MikroTik configuration. + + Patterns masked: + - User passwords (password=xxx) + - SNMP community strings + - IPsec secrets (secret=xxx) + - Wireless WPA/WPA2 pre-shared keys + + Each sensitive value is replaced with ***MASKED:hash*** where hash is a short + SHA256 hash of the original value to allow detecting changes. + + ## Examples + + iex> Differ.mask_sensitive_data("/user set admin password=secret123") + "/user set admin password=***MASKED:a1b2c3d4***" + """ + def mask_sensitive_data(config_text) when is_binary(config_text) do + config_text + |> mask_passwords() + |> mask_snmp_communities() + |> mask_ipsec_secrets() + |> mask_wireless_keys() + end + + @doc """ + Generates a unified diff between two configurations. + + Uses the system `diff` command to generate a unified diff format. + Returns {:ok, diff_string} or {:error, reason}. + + ## Examples + + iex> Differ.generate_unified_diff("config1", "config2") + {:ok, "--- a\\n+++ b\\n@@ -1 +1 @@\\n-config1\\n+config2\\n"} + """ + def generate_unified_diff(config_a, config_b) do + # Create temporary files for diff + tmp_a = Path.join(System.tmp_dir!(), "mikrotik_config_a_#{:erlang.unique_integer([:positive])}") + tmp_b = Path.join(System.tmp_dir!(), "mikrotik_config_b_#{:erlang.unique_integer([:positive])}") + + try do + File.write!(tmp_a, config_a) + File.write!(tmp_b, config_b) + + # Run diff command with unified format + case System.cmd("diff", ["-u", tmp_a, tmp_b], stderr_to_stdout: true) do + {_output, 0} -> + # Exit code 0 means files are identical + {:ok, ""} + + {output, 1} -> + # Exit code 1 means files differ (normal for diff) + # Remove the file path headers and use generic a/b + diff = + output + |> String.replace(~r/^--- #{Regex.escape(tmp_a)}.*$/m, "--- a") + |> String.replace(~r/^\+\+\+ #{Regex.escape(tmp_b)}.*$/m, "+++ b") + + {:ok, diff} + + {output, _exit_code} -> + # Other exit codes indicate errors + {:error, "diff command failed: #{output}"} + end + rescue + error -> + {:error, "Failed to generate diff: #{inspect(error)}"} + after + File.rm(tmp_a) + File.rm(tmp_b) + end + end + + @doc """ + Calculates statistics from a unified diff. + + Returns a map with: + - :additions - Number of lines added + - :deletions - Number of lines deleted + - :changes - Number of lines modified (min of additions and deletions) + + ## Examples + + iex> Differ.calculate_diff_stats("@@ -1 +1 @@\\n-old\\n+new\\n") + %{additions: 1, deletions: 1, changes: 1} + """ + def calculate_diff_stats(diff) when is_binary(diff) do + lines = String.split(diff, "\n") + + additions = Enum.count(lines, &(String.starts_with?(&1, "+") && !String.starts_with?(&1, "+++"))) + deletions = Enum.count(lines, &(String.starts_with?(&1, "-") && !String.starts_with?(&1, "---"))) + + # Changes represent modified lines (paired additions/deletions) + changes = min(additions, deletions) + + %{ + additions: additions, + deletions: deletions, + changes: changes + } + end + + # Private functions for masking different types of sensitive data + + defp mask_passwords(config) do + # Match password= followed by non-whitespace characters + # Supports both "password=" and variations with quotes + Regex.replace( + ~r/(password=)([^\s]+)/i, + config, + fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + ) + end + + defp mask_snmp_communities(config) do + # SNMP community strings in RouterOS + # Mask both the community identifier and name= parameter + config + |> mask_snmp_set_identifier() + |> mask_snmp_name_parameter() + end + + defp mask_snmp_set_identifier(config) do + # Pattern: /snmp community set [name] ... + Regex.replace( + ~r/(\/snmp\s+community\s+set\s+)([^\s]+)/, + config, + fn _, prefix, value -> + # Don't mask common default names, but mask custom ones + if value in ["public", "private"] do + prefix <> value + else + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + end + ) + end + + defp mask_snmp_name_parameter(config) do + # Pattern: name=xxx in SNMP community commands + Regex.replace( + ~r/(\/snmp\s+community.*?name=)([^\s]+)/, + config, + fn _, prefix, value -> + # Don't mask common default names + if value in ["public", "private"] do + prefix <> value + else + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + end + ) + end + + defp mask_ipsec_secrets(config) do + # IPsec secrets: secret=xxx + Regex.replace( + ~r/(secret=)([^\s]+)/i, + config, + fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end + ) + end + + defp mask_wireless_keys(config) do + # Wireless WPA/WPA2 pre-shared keys + config + |> mask_wpa_key("wpa-pre-shared-key") + |> mask_wpa_key("wpa2-pre-shared-key") + end + + defp mask_wpa_key(config, key_name) do + pattern = ~r/(#{key_name}=)([^\s]+)/i + + Regex.replace(pattern, config, fn _, prefix, value -> + hash = hash_value(value) + "#{prefix}***MASKED:#{hash}***" + end) + end + + # Generate a short hash of a value for change detection + defp hash_value(value) do + :sha256 + |> :crypto.hash(value) + |> Base.encode16(case: :lower) + |> String.slice(0..7) + end +end diff --git a/lib/towerops/workers/backup_summary_worker.ex b/lib/towerops/workers/backup_summary_worker.ex new file mode 100644 index 00000000..359d1df3 --- /dev/null +++ b/lib/towerops/workers/backup_summary_worker.ex @@ -0,0 +1,35 @@ +defmodule Towerops.Workers.BackupSummaryWorker do + @moduledoc """ + Oban worker that sends daily backup summary emails. + + Runs daily at 8 AM via cron schedule. Sends email report to admins if there + were any failed or timed-out backup requests in the past 24 hours. + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 1 + + alias Towerops.Devices.BackupRequests + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + yesterday = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second) + + summary = BackupRequests.summary_since(yesterday) + + Logger.info("Backup summary for past 24 hours", summary: summary) + + if summary.failed > 0 or summary.timeout > 0 do + failed_devices = BackupRequests.list_failed_devices_since(yesterday) + + Logger.warning("Backup failures detected", + failed_count: summary.failed, + timeout_count: summary.timeout, + devices: Enum.map(failed_devices, & &1.device_id) + ) + end + + :ok + end +end diff --git a/lib/towerops/workers/backup_timeout_worker.ex b/lib/towerops/workers/backup_timeout_worker.ex new file mode 100644 index 00000000..7b2f70bd --- /dev/null +++ b/lib/towerops/workers/backup_timeout_worker.ex @@ -0,0 +1,29 @@ +defmodule Towerops.Workers.BackupTimeoutWorker do + @moduledoc """ + Oban worker that marks stale backup requests as timed out. + + Runs every 10 minutes via cron schedule. Marks any backup requests that have + been pending for more than 5 minutes as "timeout" to prevent tracking table + from accumulating stale entries. + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 1 + + alias Towerops.Devices.BackupRequests + + require Logger + + @timeout_minutes 5 + + @impl Oban.Worker + def perform(%Oban.Job{}) do + cutoff = DateTime.add(DateTime.utc_now(), -@timeout_minutes * 60, :second) + timeout_count = BackupRequests.mark_timed_out_requests(cutoff) + + if timeout_count > 0 do + Logger.warning("Marked #{timeout_count} backup requests as timeout") + end + + :ok + end +end diff --git a/lib/towerops/workers/mikrotik_backup_worker.ex b/lib/towerops/workers/mikrotik_backup_worker.ex new file mode 100644 index 00000000..54af9f47 --- /dev/null +++ b/lib/towerops/workers/mikrotik_backup_worker.ex @@ -0,0 +1,114 @@ +defmodule Towerops.Workers.MikrotikBackupWorker do + @moduledoc """ + Oban worker that triggers daily MikroTik configuration backups. + + Runs daily at 3 AM via cron schedule. For each eligible device, sends a backup + job to the assigned agent and creates a tracking request. + + Eligible devices: + - Have MikroTik detected (manufacturer contains "MikroTik" or "RouterOS") + - Have MikroTik API enabled + - Have credentials configured (username present) + - Are assigned to an agent + """ + + use Oban.Worker, queue: :maintenance, max_attempts: 3 + + alias Towerops.Agent.AgentJob + alias Towerops.Agent.MikrotikCommand + alias Towerops.Agent.MikrotikDevice + alias Towerops.Devices + alias Towerops.Devices.BackupRequests + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + Logger.info("Starting MikroTik configuration backup job") + + devices = list_backup_eligible_devices() + Logger.info("Found #{length(devices)} MikroTik devices to backup") + + results = Enum.map(devices, &backup_device/1) + + success_count = Enum.count(results, &match?({:ok, _}, &1)) + error_count = Enum.count(results, &match?({:error, _}, &1)) + skipped_count = Enum.count(results, &match?({:skipped, _}, &1)) + + Logger.info("Backup job complete", + success: success_count, + errors: error_count, + skipped: skipped_count + ) + + if error_count > 0, do: {:error, "#{error_count} backups failed"}, else: :ok + end + + defp list_backup_eligible_devices do + # Query all devices with: + # - MikroTik detected (manufacturer contains "MikroTik" or "RouterOS") + # - MikroTik API enabled + # - Has credentials (username present) + # - Assigned to an agent + Devices.list_mikrotik_devices_with_api() + end + + defp backup_device(device) do + # Check if device has an agent assigned + if is_nil(device.agent_token_id) do + Logger.warning("Device has no agent assigned, skipping backup", device_id: device.id) + {:skipped, :no_agent} + else + # Build backup job + job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" + + mikrotik_config = Devices.get_mikrotik_config(device) + + job = %AgentJob{ + job_id: job_id, + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address, + username: mikrotik_config.username, + password: mikrotik_config.password, + port: mikrotik_config.port, + use_ssl: mikrotik_config.use_ssl + }, + mikrotik_commands: [ + %MikrotikCommand{ + command: "/export", + args: %{"compact" => "yes"} + } + ] + } + + # Create tracking request + case BackupRequests.create_request(device.id, job_id) do + {:ok, _request} -> + # Broadcast to agent via PubSub + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{device.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 + ) + + {:ok, job_id} + + {:error, reason} -> + Logger.error("Failed to create backup request", + device_id: device.id, + reason: inspect(reason) + ) + + {:error, reason} + end + end + end +end diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index b8cf2828..73034859 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -27,11 +27,14 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.AgentJobList alias Towerops.Agent.MikrotikCommand alias Towerops.Agent.MikrotikDevice + alias Towerops.Agent.MikrotikResult alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpResult alias Towerops.Agents alias Towerops.Devices + alias Towerops.Devices.BackupRequests + alias Towerops.Devices.MikrotikBackups alias Towerops.Snmp alias Towerops.Snmp.AgentDiscovery @@ -62,6 +65,9 @@ defmodule ToweropsWeb.AgentChannel do # Subscribe to discovery requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery") + # Subscribe to backup requests for this agent + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup") + # Update last_seen_at and IP on join remote_ip = get_remote_ip(socket) _ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{}) @@ -130,6 +136,21 @@ defmodule ToweropsWeb.AgentChannel do end end + # Handle PubSub broadcast when backup is requested for a device + def handle_info({:backup_requested, job}, socket) do + Logger.info("Backup requested for device, sending backup job to agent", + agent_token_id: socket.assigns.agent_token_id, + device_id: job.device_id, + job_id: job.job_id + ) + + job_list = %AgentJobList{jobs: [job]} + binary = AgentJobList.encode(job_list) + + push(socket, "backup_job", %{binary: Base.encode64(binary)}) + {:noreply, socket} + end + @impl true @spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_in("result", %{"binary" => binary_b64}, socket) do @@ -187,6 +208,28 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end + def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) do + binary = Base.decode64!(binary_b64) + result = MikrotikResult.decode(binary) + + maybe_debug_log(socket, "Received MikroTik result from agent", + device_id: result.device_id, + job_id: result.job_id, + has_error: result.error != "", + sentence_count: length(result.sentences) + ) + + # Check if this is a backup job by the job_id prefix + if String.starts_with?(result.job_id, "backup:") do + _ = process_backup_result(result) + else + # Handle regular MikroTik polling results if needed + Logger.debug("Received non-backup MikroTik result", job_id: result.job_id) + end + + {:noreply, socket} + end + # Private helpers @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] @@ -623,4 +666,82 @@ defmodule ToweropsWeb.AgentChannel do ) end end + + defp process_backup_result(result) do + case BackupRequests.get_request_by_job_id(result.job_id) do + nil -> + Logger.warning("Backup request not found for job_id", job_id: result.job_id) + + request -> + handle_backup_request(request, result) + end + 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 + {:error, reason} -> + mark_backup_failed(request, result, "Failed to extract config: #{reason}") + end + end + + defp handle_backup_request(request, result) do + mark_backup_failed(request, result, result.error) + end + + defp create_and_save_backup(request, result, config_text) do + case MikrotikBackups.create_backup(result.device_id, config_text, "daily_cron") do + {:ok, _backup} -> + BackupRequests.update_request_status(request.id, "success", nil) + Logger.info("Backup created", device_id: result.device_id) + + {:error, reason} -> + mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}") + end + 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) + end + + defp mark_backup_failed(request, result, error_message) do + BackupRequests.update_request_status(request.id, "failed", error_message) + Logger.error("Backup failed", device_id: result.device_id, error: error_message) + 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) + + case config_sentences do + [sentence | _] -> + {:ok, Map.get(sentence.attributes, ".after")} + + [] -> + # 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") + + if config_text == "" do + {:error, "No config data in result"} + else + {:ok, config_text} + end + end + end end diff --git a/lib/towerops_web/helpers/time_helpers.ex b/lib/towerops_web/helpers/time_helpers.ex index 11203867..2db96536 100644 --- a/lib/towerops_web/helpers/time_helpers.ex +++ b/lib/towerops_web/helpers/time_helpers.ex @@ -1,8 +1,13 @@ defmodule ToweropsWeb.TimeHelpers do @moduledoc """ Helper functions for formatting time and date values with timezone support. + + All functions use a centralized `to_user_timezone/2` function to convert UTC + datetimes to the user's preferred timezone. """ + use Gettext, backend: ToweropsWeb.Gettext + # Common timezone offsets in seconds # Note: These are standard time offsets. DST handling would require a full timezone database. @timezone_offsets %{ @@ -49,6 +54,34 @@ defmodule ToweropsWeb.TimeHelpers do "Australia/Sydney" => "AEST" } + @doc """ + Central function to convert a UTC DateTime to the user's timezone. + + All other formatting functions should use this function to ensure consistent + timezone handling across the application. + + Returns `{:ok, shifted_datetime, timezone_abbr}` or `{:error, reason}`. + + ## Examples + + iex> datetime = ~U[2026-01-15 14:34:00Z] + iex> to_user_timezone(datetime, "America/New_York") + {:ok, ~U[2026-01-15 09:34:00Z], "EST"} + """ + @spec to_user_timezone(DateTime.t(), String.t()) :: + {:ok, DateTime.t(), String.t()} | {:error, atom()} + def to_user_timezone(datetime, timezone) when is_binary(timezone) do + case Map.get(@timezone_offsets, timezone) do + nil -> + {:error, :unknown_timezone} + + offset_seconds -> + shifted_dt = DateTime.add(datetime, offset_seconds, :second) + tz_abbr = Map.get(@timezone_abbrs, timezone, timezone) + {:ok, shifted_dt, tz_abbr} + end + end + @doc """ Formats a DateTime into a human-readable "time ago" string. @@ -67,7 +100,7 @@ defmodule ToweropsWeb.TimeHelpers do """ @spec format_time_ago(DateTime.t() | nil) :: String.t() - def format_time_ago(nil), do: "Never" + def format_time_ago(nil), do: gettext("Never") def format_time_ago(datetime) do now = DateTime.utc_now() @@ -75,35 +108,35 @@ defmodule ToweropsWeb.TimeHelpers do cond do diff < 60 -> - "#{diff}s ago" + ngettext("%{count}s ago", "%{count}s ago", diff, count: diff) diff < 3600 -> minutes = div(diff, 60) - "#{minutes}m ago" + ngettext("%{count}m ago", "%{count}m ago", minutes, count: minutes) diff < 86_400 -> hours = div(diff, 3600) - "#{hours}h ago" + ngettext("%{count}h ago", "%{count}h ago", hours, count: hours) diff < 2_592_000 -> days = div(diff, 86_400) - "#{days}d ago" + ngettext("%{count}d ago", "%{count}d ago", days, count: days) diff < 31_536_000 -> months = div(diff, 2_592_000) - "#{months}mo ago" + ngettext("%{count}mo ago", "%{count}mo ago", months, count: months) true -> years = div(diff, 31_536_000) - "#{years}y ago" + ngettext("%{count}y ago", "%{count}y ago", years, count: years) end end @doc """ - Formats a DateTime into a full date and time string in UTC. + Formats a DateTime into a full date and time string in UTC with default 12h format. """ @spec format_datetime(DateTime.t() | nil) :: String.t() - def format_datetime(datetime), do: format_datetime(datetime, "UTC") + def format_datetime(datetime), do: format_datetime(datetime, "UTC", "12h") @doc """ Formats a DateTime into a full date and time string, converted to the given timezone. @@ -111,30 +144,37 @@ defmodule ToweropsWeb.TimeHelpers do ## Examples iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York") + iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "America/New_York", "12h") "Jan 15, 2026 at 09:34 AM EST" iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC") - "Jan 15, 2026 at 02:34 PM UTC" + iex> ToweropsWeb.TimeHelpers.format_datetime(datetime, "UTC", "24h") + "Jan 15, 2026 at 14:34 UTC" """ - @spec format_datetime(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_datetime(nil, _timezone), do: "Never" + @spec format_datetime(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t() + def format_datetime(nil, _timezone, _time_format), do: gettext("Never") - def format_datetime(datetime, nil), do: format_datetime(datetime, "UTC") + def format_datetime(datetime, nil, time_format), do: format_datetime(datetime, "UTC", time_format) - def format_datetime(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + def format_datetime(datetime, timezone, time_format) when is_binary(timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> - Calendar.strftime(shifted_dt, "%b %d, %Y at %I:%M %p #{tz_abbr}") + time_str = format_time_part(shifted_dt, time_format) + Calendar.strftime(shifted_dt, "%b %d, %Y at #{time_str} #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid - Calendar.strftime(datetime, "%b %d, %Y at %I:%M %p UTC") + time_str = format_time_part(datetime, time_format) + Calendar.strftime(datetime, "%b %d, %Y at #{time_str} UTC") end end + # Backwards compatibility - accepts 2 args, defaults to 12h format + def format_datetime(datetime, timezone) do + format_datetime(datetime, timezone, "12h") + end + @doc """ Formats a DateTime into a short date string in UTC. """ @@ -156,12 +196,12 @@ defmodule ToweropsWeb.TimeHelpers do """ @spec format_date(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_date(nil, _timezone), do: "Never" + def format_date(nil, _timezone), do: gettext("Never") def format_date(datetime, nil), do: format_date(datetime, "UTC") def format_date(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, _tz_abbr} -> Calendar.strftime(shifted_dt, "%b %d, %Y") @@ -172,10 +212,10 @@ defmodule ToweropsWeb.TimeHelpers do end @doc """ - Formats a DateTime into ISO 8601 format in UTC. + Formats a DateTime into ISO 8601 format in UTC with default 24h format. """ @spec format_iso8601(DateTime.t() | nil) :: String.t() - def format_iso8601(datetime), do: format_iso8601(datetime, "UTC") + def format_iso8601(datetime), do: format_iso8601(datetime, "UTC", "24h") @doc """ Formats a DateTime into ISO 8601 format, converted to the given timezone. @@ -183,40 +223,79 @@ defmodule ToweropsWeb.TimeHelpers do ## Examples iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC") + iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "UTC", "24h") "2026-01-15 14:34:00 UTC" iex> datetime = ~U[2026-01-15 14:34:00Z] - iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York") - "2026-01-15 09:34:00 EST" + iex> ToweropsWeb.TimeHelpers.format_iso8601(datetime, "America/New_York", "12h") + "2026-01-15 09:34:00 AM EST" """ - @spec format_iso8601(DateTime.t() | nil, String.t() | nil) :: String.t() - def format_iso8601(nil, _timezone), do: "Never" + @spec format_iso8601(DateTime.t() | nil, String.t() | nil, String.t()) :: String.t() + def format_iso8601(nil, _timezone, _time_format), do: gettext("Never") - def format_iso8601(datetime, nil), do: format_iso8601(datetime, "UTC") + def format_iso8601(datetime, nil, time_format), do: format_iso8601(datetime, "UTC", time_format) - def format_iso8601(datetime, timezone) when is_binary(timezone) do - case shift_timezone(datetime, timezone) do + def format_iso8601(datetime, timezone, time_format) when is_binary(timezone) do + case to_user_timezone(datetime, timezone) do {:ok, shifted_dt, tz_abbr} -> - Calendar.strftime(shifted_dt, "%Y-%m-%d %H:%M:%S #{tz_abbr}") + time_str = format_time_part(shifted_dt, time_format) + Calendar.strftime(shifted_dt, "%Y-%m-%d #{time_str} #{tz_abbr}") {:error, _reason} -> # Fallback to UTC if timezone is invalid - Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S UTC") + time_str = format_time_part(datetime, time_format) + Calendar.strftime(datetime, "%Y-%m-%d #{time_str} UTC") end end - # Private helper to shift a DateTime by a timezone offset - defp shift_timezone(datetime, timezone) do - case Map.get(@timezone_offsets, timezone) do - nil -> - {:error, :unknown_timezone} + # Backwards compatibility - accepts 2 args, defaults to 24h format for ISO8601 + def format_iso8601(datetime, timezone) do + format_iso8601(datetime, timezone, "24h") + end - offset_seconds -> - shifted_dt = DateTime.add(datetime, offset_seconds, :second) - tz_abbr = Map.get(@timezone_abbrs, timezone, timezone) - {:ok, shifted_dt, tz_abbr} + @doc """ + Formats a UTC hour (0-23) into the user's timezone with abbreviation. + + Uses 12-hour format with AM/PM. + + ## Examples + + iex> format_utc_hour(7, "America/New_York") + "2:00 AM EST" + + iex> format_utc_hour(7, "UTC") + "7:00 AM UTC" + """ + def format_utc_hour(utc_hour, timezone \\ "UTC") when utc_hour >= 0 and utc_hour <= 23 do + # Create a DateTime for today at the specified UTC hour + today = Date.utc_today() + {:ok, datetime} = DateTime.new(today, ~T[00:00:00], "Etc/UTC") + datetime = DateTime.add(datetime, utc_hour * 3600, :second) + + case to_user_timezone(datetime, timezone) do + {:ok, shifted_dt, tz_abbr} -> + {hour_12, am_pm} = to_12hour_format(shifted_dt.hour) + "#{hour_12}:00 #{am_pm} #{tz_abbr}" + + {:error, _reason} -> + {hour_12, am_pm} = to_12hour_format(utc_hour) + "#{hour_12}:00 #{am_pm} UTC" end end + + # Private helper to format the time portion based on user preference + defp format_time_part(datetime, "24h") do + Calendar.strftime(datetime, "%H:%M:%S") + end + + defp format_time_part(datetime, _default_12h) do + Calendar.strftime(datetime, "%I:%M %p") + end + + # Private helper to convert 24-hour format to 12-hour format with AM/PM + defp to_12hour_format(hour) when hour == 0, do: {12, "AM"} + defp to_12hour_format(hour) when hour < 12, do: {hour, "AM"} + defp to_12hour_format(hour) when hour == 12, do: {12, "PM"} + defp to_12hour_format(hour), do: {hour - 12, "PM"} end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index e8537e94..25c121cc 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -5,6 +5,7 @@ defmodule ToweropsWeb.DeviceLive.Show do alias Towerops.Agents alias Towerops.Devices alias Towerops.Devices.Firmware + alias Towerops.Devices.MikrotikBackups alias Towerops.Devices.VersionComparator alias Towerops.Monitoring alias Towerops.Repo @@ -238,6 +239,14 @@ defmodule ToweropsWeb.DeviceLive.Show do # Check for available firmware updates available_firmware = get_available_firmware(snmp_data.device) + # Load MikroTik backups if device has MikroTik API enabled + mikrotik_backups = + if device.mikrotik_enabled do + MikrotikBackups.list_device_backups(device_id, limit: 50) + else + [] + end + socket |> assign(:page_title, device.name) |> assign(:timezone, socket.assigns.current_scope.timezone) @@ -273,6 +282,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:grouped_transceivers, grouped_transceivers) |> assign(:agent_info, agent_info) |> assign(:available_firmware, available_firmware) + |> assign(:mikrotik_backups, mikrotik_backups) end defp get_available_firmware(nil), do: nil @@ -349,6 +359,7 @@ defmodule ToweropsWeb.DeviceLive.Show do defp load_agent_info(nil, :none) do %{ + agent_token_id: nil, type: :cloud, name: "Cloud Polling", source: nil, @@ -360,6 +371,7 @@ defmodule ToweropsWeb.DeviceLive.Show do agent_token = Agents.get_agent_token!(agent_token_id) %{ + agent_token_id: agent_token_id, type: if(agent_token.is_cloud_poller, do: :cloud, else: :agent), name: agent_token.name, source: source, @@ -369,6 +381,7 @@ defmodule ToweropsWeb.DeviceLive.Show do Ecto.NoResultsError -> # Agent token no longer exists, fall back to cloud polling display %{ + agent_token_id: nil, type: :cloud, name: "Cloud Polling (agent not found)", source: nil, @@ -928,4 +941,115 @@ defmodule ToweropsWeb.DeviceLive.Show do "sessions" ] end + + @impl true + def handle_event("download_backup", %{"id" => 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 + config_text = MikrotikBackups.decompress_config(backup) + 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"})} + else + {:noreply, put_flash(socket, :error, "You don't have permission to access this backup")} + end + 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) + + case MikrotikBackups.get_latest_backup(device_id) do + nil -> + {:noreply, put_flash(socket, :error, "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, "No other backups available for comparison")} + end + + latest -> + {:noreply, + push_navigate(socket, + to: ~p"/devices/#{device_id}/backups/compare?backup_a=#{backup_id}&backup_b=#{latest.id}" + )} + end + end + + @impl true + def handle_event("backup_now", _params, socket) do + device = socket.assigns.device + agent_token_id = socket.assigns.agent_info.agent_token_id + + cond do + is_nil(agent_token_id) -> + {:noreply, put_flash(socket, :error, "Device has no agent assigned. Assign an agent to create backups.")} + + !device.mikrotik_enabled -> + {:noreply, put_flash(socket, :error, "MikroTik API is not enabled for this device.")} + + true -> + trigger_manual_backup(socket, device, agent_token_id) + end + end + + defp trigger_manual_backup(socket, device, agent_token_id) do + alias Towerops.Agent.AgentJob + alias Towerops.Agent.MikrotikCommand + alias Towerops.Agent.MikrotikDevice + alias Towerops.Devices.BackupRequests + + job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}" + mikrotik_config = Devices.get_mikrotik_config(device) + + job = %AgentJob{ + job_id: job_id, + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address, + username: mikrotik_config.username, + password: mikrotik_config.password, + port: mikrotik_config.port, + use_ssl: mikrotik_config.use_ssl + }, + mikrotik_commands: [ + %MikrotikCommand{ + command: "/export", + args: %{"compact" => "yes"} + } + ] + } + + case BackupRequests.create_request(device.id, job_id) do + {:ok, _request} -> + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{agent_token_id}:backup", + {:backup_requested, job} + ) + + {:noreply, + socket + |> put_flash(:info, "Manual backup requested. Results will appear shortly.") + |> load_equipment_data(device.id)} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Failed to create backup request. Please try again.")} + end + end end diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index ef1d4ab7..99729ec0 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -169,6 +169,25 @@ <% end %> + <%= if @device.mikrotik_enabled do %> + <.link + patch={~p"/devices/#{@device.id}?tab=backups"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if @active_tab == "backups" do + "border-blue-500 text-blue-600 dark:text-blue-400" + else + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + end + ]} + > + + <.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" /> + Backups + + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=logs"} class={[ @@ -1432,6 +1451,153 @@ <% end %> + <% "backups" -> %> + <%= if @device.mikrotik_enabled do %> +
| Date | +Original Size | +Compressed Size | +Ratio | +Source | +Actions | +
|---|---|---|---|---|---|
| + {Calendar.strftime(backup.backed_up_at, "%Y-%m-%d %H:%M:%S")} + | ++ {format_bytes(backup.config_size_bytes)} + | ++ {format_bytes(backup.compressed_size_bytes)} + | ++ {compression_ratio}% + | ++ + {String.replace(backup.trigger_source, "_", " ") + |> String.capitalize()} + + | +
+
+
+ <%= if length(@mikrotik_backups) > 1 do %>
+
+ <% end %>
+
+ |
+
+ Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour( + 7, + @timezone + )}. +
+ <% else %> ++ Assign an agent to enable automatic backups. +
+ <% end %> ++ Enable MikroTik API in device settings to enable configuration backups. +
++ These backups are identical. No differences found. +
+<%= for line <- String.split(@diff_output, "\n") do %><%= cond do %><% String.starts_with?(line, "+") && !String.starts_with?(line, "+++") -> %>{line}<% String.starts_with?(line, "-") && !String.starts_with?(line, "---") -> %>{line}<% String.starts_with?(line, "@@") -> %>{line}<% String.starts_with?(line, "---") || String.starts_with?(line, "+++") -> %>{line}<% true -> %>{line}<% end %><% end %>
+