Mikrotik backup and gettext start

This commit is contained in:
Graham McIntire 2026-02-02 09:11:22 -06:00
parent a48cb292da
commit f938b263cd
No known key found for this signature in database
33 changed files with 2667 additions and 85 deletions

View file

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

View file

@ -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
- assets are rebuilt automatically on filesystem change
- never try to run npm, it's not included in phoenix

View file

@ -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()

View file

@ -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},

View file

@ -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},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -169,6 +169,25 @@
</.link>
<% 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
]}
>
<span class="flex items-center gap-1.5">
<.icon name="hero-beaker" class="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" />
Backups
</span>
</.link>
<% end %>
<.link
patch={~p"/devices/#{@device.id}?tab=logs"}
class={[
@ -1432,6 +1451,153 @@
</div>
<% end %>
</div>
<% "backups" -> %>
<%= if @device.mikrotik_enabled do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Configuration Backups
<%= if Enum.any?(@mikrotik_backups) do %>
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@mikrotik_backups)})
</span>
<% end %>
</h3>
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100 rounded dark:bg-blue-900 dark:text-blue-200">
<.icon name="hero-beaker" class="h-3 w-3" /> Experimental
</span>
</div>
<%= if @agent_info.agent_token_id do %>
<button
phx-click="backup_now"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Backup Now
</button>
<% else %>
<div class="text-sm text-amber-600 dark:text-amber-400">
Assign an agent to enable backups
</div>
<% end %>
</div>
</div>
<%= if Enum.any?(@mikrotik_backups) do %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 text-left font-medium">Date</th>
<th class="px-4 py-3 text-left font-medium">Original Size</th>
<th class="px-4 py-3 text-left font-medium">Compressed Size</th>
<th class="px-4 py-3 text-left font-medium">Ratio</th>
<th class="px-4 py-3 text-left font-medium">Source</th>
<th class="px-4 py-3 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for backup <- @mikrotik_backups do %>
<% compression_ratio =
if backup.config_size_bytes && backup.config_size_bytes > 0 do
Float.round(
(1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100,
1
)
else
0.0
end %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 text-gray-900 dark:text-white whitespace-nowrap">
{Calendar.strftime(backup.backed_up_at, "%Y-%m-%d %H:%M:%S")}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
{format_bytes(backup.config_size_bytes)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
{format_bytes(backup.compressed_size_bytes)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{compression_ratio}%
</td>
<td class="px-4 py-3">
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
backup.trigger_source == "daily_cron" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
backup.trigger_source == "manual" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
backup.trigger_source == "pre_change" &&
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
]}>
{String.replace(backup.trigger_source, "_", " ")
|> String.capitalize()}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<button
phx-click="download_backup"
phx-value-id={backup.id}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
</button>
<%= if length(@mikrotik_backups) > 1 do %>
<button
phx-click="compare_backup"
phx-value-id={backup.id}
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-300"
>
<.icon name="hero-arrows-right-left" class="h-4 w-4" /> Compare
</button>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="p-8 text-center">
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
No backups yet
</h3>
<%= if @agent_info.agent_token_id do %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour(
7,
@timezone
)}.
</p>
<% else %>
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
Assign an agent to enable automatic backups.
</p>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
<div class="text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
MikroTik API not enabled
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Enable MikroTik API in device settings to enable configuration backups.
</p>
<div class="mt-6">
<.button navigate={~p"/devices/#{@device.id}/edit"}>
Edit Device Settings
</.button>
</div>
</div>
</div>
<% end %>
<% "logs" -> %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">

View file

@ -0,0 +1,121 @@
defmodule ToweropsWeb.MikrotikBackupLive.Compare do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.MikrotikBackups.Differ
alias Towerops.Repo
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"device_id" => device_id, "backup_a" => backup_a_id, "backup_b" => backup_b_id}, _uri, socket) do
organization = socket.assigns.current_scope.organization
# Verify device access
device = Devices.get_device!(device_id)
device_with_site = Repo.preload(device, site: :organization)
if device_with_site.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else
{:noreply,
socket
|> put_flash(:error, "You don't have access to this device")
|> push_navigate(to: ~p"/devices")}
end
end
@impl true
def handle_params(_params, _uri, socket) do
{:noreply,
socket
|> put_flash(:error, "Missing required parameters")
|> push_navigate(to: ~p"/devices")}
end
defp load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) do
# Load backups
backup_a = MikrotikBackups.get_backup!(backup_a_id)
backup_b = MikrotikBackups.get_backup!(backup_b_id)
# Verify backups belong to this device
if backup_a.device_id != device_id || backup_b.device_id != device_id do
{:noreply,
socket
|> put_flash(:error, "Backups do not belong to this device")
|> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")}
else
generate_and_display_diff(socket, device, backup_a, backup_b, device_id)
end
end
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)
# Mask sensitive data
config_a_masked = Differ.mask_sensitive_data(config_a)
config_b_masked = Differ.mask_sensitive_data(config_b)
# Generate diff
case Differ.generate_unified_diff(config_a_masked, config_b_masked) do
{:ok, diff_output} ->
stats = Differ.calculate_diff_stats(diff_output)
{:noreply,
socket
|> assign(:page_title, "Compare Backups - #{device.name}")
|> assign(:device, device)
|> assign(:backup_a, backup_a)
|> assign(:backup_b, backup_b)
|> assign(:diff_output, diff_output)
|> assign(:stats, stats)}
{:error, reason} ->
{:noreply,
socket
|> put_flash(:error, "Failed to generate diff: #{reason}")
|> push_navigate(to: ~p"/devices/#{device_id}?tab=backups")}
end
end
@impl true
def handle_event("download_config", %{"backup" => "a"}, socket) do
backup = socket.assigns.backup_a
config_text = MikrotikBackups.decompress_config(backup)
device = socket.assigns.device
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"})}
end
@impl true
def handle_event("download_config", %{"backup" => "b"}, socket) do
backup = socket.assigns.backup_b
config_text = MikrotikBackups.decompress_config(backup)
device = socket.assigns.device
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"})}
end
# Helper function to format byte sizes
defp format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
cond do
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB"
true -> "#{bytes} B"
end
end
defp format_bytes(_), do: "Invalid byte count"
end

View file

@ -0,0 +1,170 @@
<Layouts.app flash={@flash}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-6">
<nav class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<.link navigate={~p"/devices"} class="hover:text-gray-700 dark:hover:text-gray-300">
Devices
</.link>
<span>/</span>
<.link
navigate={~p"/devices/#{@device.id}"}
class="hover:text-gray-700 dark:hover:text-gray-300"
>
{@device.name}
</.link>
<span>/</span>
<.link
navigate={~p"/devices/#{@device.id}?tab=backups"}
class="hover:text-gray-700 dark:hover:text-gray-300"
>
Backups
</.link>
<span>/</span>
<span class="text-gray-900 dark:text-white">Compare</span>
</nav>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Compare Configuration Backups
</h1>
<.link
navigate={~p"/devices/#{@device.id}?tab=backups"}
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Backups
</.link>
</div>
</div>
<!-- Backup Info Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- Backup A -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Backup A (Older)</h3>
<button
phx-click="download_config"
phx-value-backup="a"
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
</button>
</div>
<dl class="space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Date:</dt>
<dd class="text-gray-900 dark:text-white font-mono">
{Calendar.strftime(@backup_a.backed_up_at, "%Y-%m-%d %H:%M:%S")}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Size:</dt>
<dd class="text-gray-900 dark:text-white font-mono">
{format_bytes(@backup_a.config_size_bytes)}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Source:</dt>
<dd>
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
@backup_a.trigger_source == "daily_cron" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
@backup_a.trigger_source == "manual" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
{String.replace(@backup_a.trigger_source, "_", " ") |> String.capitalize()}
</span>
</dd>
</div>
</dl>
</div>
<!-- Backup B -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">Backup B (Newer)</h3>
<button
phx-click="download_config"
phx-value-backup="b"
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
</button>
</div>
<dl class="space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Date:</dt>
<dd class="text-gray-900 dark:text-white font-mono">
{Calendar.strftime(@backup_b.backed_up_at, "%Y-%m-%d %H:%M:%S")}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Size:</dt>
<dd class="text-gray-900 dark:text-white font-mono">
{format_bytes(@backup_b.config_size_bytes)}
</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">Source:</dt>
<dd>
<span class={[
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
@backup_b.trigger_source == "daily_cron" &&
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
@backup_b.trigger_source == "manual" &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
{String.replace(@backup_b.trigger_source, "_", " ") |> String.capitalize()}
</span>
</dd>
</div>
</dl>
</div>
</div>
<!-- Diff Stats -->
<%= if @diff_output == "" do %>
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 mb-6">
<div class="flex items-center gap-2">
<.icon name="hero-check-circle" class="h-5 w-5 text-green-600 dark:text-green-400" />
<p class="text-sm text-green-800 dark:text-green-200">
These backups are identical. No differences found.
</p>
</div>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 mb-6">
<div class="flex items-center gap-6 text-sm">
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 bg-green-500 rounded-sm"></span>
<span class="text-gray-600 dark:text-gray-400">
{@stats.additions} addition{if @stats.additions != 1, do: "s"}
</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 bg-red-500 rounded-sm"></span>
<span class="text-gray-600 dark:text-gray-400">
{@stats.deletions} deletion{if @stats.deletions != 1, do: "s"}
</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 bg-yellow-500 rounded-sm"></span>
<span class="text-gray-600 dark:text-gray-400">
{@stats.changes} change{if @stats.changes != 1, do: "s"}
</span>
</div>
</div>
</div>
<!-- Unified Diff Display -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-800/75">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Configuration Differences
</h3>
</div>
<div class="overflow-x-auto">
<pre class="p-4 text-xs font-mono leading-relaxed"><code><%= for line <- String.split(@diff_output, "\n") do %><%= cond do %><% String.starts_with?(line, "+") && !String.starts_with?(line, "+++") -> %><span class="block bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200">{line}</span><% String.starts_with?(line, "-") && !String.starts_with?(line, "---") -> %><span class="block bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-200">{line}</span><% String.starts_with?(line, "@@") -> %><span class="block bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold">{line}</span><% String.starts_with?(line, "---") || String.starts_with?(line, "+++") -> %><span class="block text-gray-500 dark:text-gray-500 italic">{line}</span><% true -> %><span class="block text-gray-700 dark:text-gray-300">{line}</span><% end %><% end %></code></pre>
</div>
</div>
<% end %>
</div>
</Layouts.app>

View file

@ -40,6 +40,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
@impl true
def handle_event("toggle_mikrotik", _params, socket) do
# credo:disable-for-next-line Credo.Check.Design.AliasUsage
current_value = Phoenix.HTML.Form.input_value(socket.assigns.form, :mikrotik_enabled)
new_value = !current_value

View file

@ -19,7 +19,13 @@
</p>
</div>
<.form for={@form} id="organization-form" phx-submit="save" data-1p-ignore data-form-type="other">
<.form
for={@form}
id="organization-form"
phx-submit="save"
data-1p-ignore
data-form-type="other"
>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<!-- Organization Name Section -->
<div class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">

View file

@ -329,6 +329,7 @@ defmodule ToweropsWeb.Router do
live "/devices/:id", DeviceLive.Show, :show
live "/devices/:id/edit", DeviceLive.Form, :edit
live "/devices/:id/graph/:sensor_type", GraphLive.Show, :show
live "/devices/:device_id/backups/compare", MikrotikBackupLive.Compare, :compare
# Network map route
live "/network-map", NetworkMapLive, :index

88
priv/gettext/default.pot Normal file
View file

@ -0,0 +1,88 @@
## This file is a PO Template file.
##
## "msgid"s here are often extracted from source code.
## Add new messages manually only if they're dynamic
## messages that can't be statically extracted.
##
## Run "mix gettext.extract" to bring this file up to
## date. Leave "msgstr"s empty as changing them here has no
## effect: edit them in PO (.po) files instead.
#
msgid ""
msgstr ""
#: lib/towerops_web/helpers/time_helpers.ex:123
#, elixir-autogen, elixir-format
msgid "%{count}d ago"
msgid_plural "%{count}d ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:119
#, elixir-autogen, elixir-format
msgid "%{count}h ago"
msgid_plural "%{count}h ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:115
#, elixir-autogen, elixir-format
msgid "%{count}m ago"
msgid_plural "%{count}m ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:127
#, elixir-autogen, elixir-format
msgid "%{count}mo ago"
msgid_plural "%{count}mo ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:111
#, elixir-autogen, elixir-format
msgid "%{count}s ago"
msgid_plural "%{count}s ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:131
#, elixir-autogen, elixir-format
msgid "%{count}y ago"
msgid_plural "%{count}y ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/components/core_components.ex:487
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/towerops_web/components/layouts.ex:552
#: lib/towerops_web/components/layouts.ex:564
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/towerops_web/helpers/time_helpers.ex:103
#: lib/towerops_web/helpers/time_helpers.ex:156
#: lib/towerops_web/helpers/time_helpers.ex:199
#: lib/towerops_web/helpers/time_helpers.ex:235
#, elixir-autogen, elixir-format
msgid "Never"
msgstr ""
#: lib/towerops_web/components/layouts.ex:559
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/towerops_web/components/layouts.ex:547
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
#: lib/towerops_web/components/core_components.ex:94
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""

View file

@ -0,0 +1,88 @@
## "msgid"s in this file come from POT (.pot) files.
###
### Do not add, change, or remove "msgid"s manually here as
### they're tied to the ones in the corresponding POT file
### (with the same domain).
###
### Use "mix gettext.extract --merge" or "mix gettext.merge"
### to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/towerops_web/helpers/time_helpers.ex:123
#, elixir-autogen, elixir-format
msgid "%{count}d ago"
msgid_plural "%{count}d ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:119
#, elixir-autogen, elixir-format
msgid "%{count}h ago"
msgid_plural "%{count}h ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:115
#, elixir-autogen, elixir-format
msgid "%{count}m ago"
msgid_plural "%{count}m ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:127
#, elixir-autogen, elixir-format
msgid "%{count}mo ago"
msgid_plural "%{count}mo ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:111
#, elixir-autogen, elixir-format
msgid "%{count}s ago"
msgid_plural "%{count}s ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/helpers/time_helpers.ex:131
#, elixir-autogen, elixir-format
msgid "%{count}y ago"
msgid_plural "%{count}y ago"
msgstr[0] ""
msgstr[1] ""
#: lib/towerops_web/components/core_components.ex:487
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/towerops_web/components/layouts.ex:552
#: lib/towerops_web/components/layouts.ex:564
#, elixir-autogen, elixir-format
msgid "Attempting to reconnect"
msgstr ""
#: lib/towerops_web/helpers/time_helpers.ex:103
#: lib/towerops_web/helpers/time_helpers.ex:156
#: lib/towerops_web/helpers/time_helpers.ex:199
#: lib/towerops_web/helpers/time_helpers.ex:235
#, elixir-autogen, elixir-format
msgid "Never"
msgstr ""
#: lib/towerops_web/components/layouts.ex:559
#, elixir-autogen, elixir-format
msgid "Something went wrong!"
msgstr ""
#: lib/towerops_web/components/layouts.ex:547
#, elixir-autogen, elixir-format
msgid "We can't find the internet"
msgstr ""
#: lib/towerops_web/components/core_components.ex:94
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Towerops.Repo.Migrations.CreateDeviceMikrotikBackups do
use Ecto.Migration
def change do
create table(:device_mikrotik_backups, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
add :config_compressed, :binary, null: false
add :config_hash, :string, null: false
add :config_hash_normalized, :string, null: false
add :config_size_bytes, :integer
add :compressed_size_bytes, :integer
add :backed_up_at, :utc_datetime, null: false
add :trigger_source, :string, default: "daily_cron"
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:device_mikrotik_backups, [:device_id, :backed_up_at])
end
end

View file

@ -0,0 +1,21 @@
defmodule Towerops.Repo.Migrations.CreateDeviceBackupRequests do
use Ecto.Migration
def change do
create table(:device_backup_requests, primary_key: false) do
add :id, :binary_id, primary_key: true
add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false
add :job_id, :string, null: false
add :requested_at, :utc_datetime, null: false
add :completed_at, :utc_datetime
add :status, :string, null: false
add :error_message, :text
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:device_backup_requests, [:device_id, :status])
create index(:device_backup_requests, [:requested_at])
create index(:device_backup_requests, [:job_id])
end
end

View file

@ -0,0 +1,9 @@
defmodule Towerops.Repo.Migrations.AddTimeFormatPreferenceToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :time_format, :string, default: "24h", null: false
end
end
end

View file

@ -3,6 +3,9 @@ Devices Tested & Working
* Ubiquiti AC, LTU, AirFiber
* Cambium ePMP
2026-02-02
* Experimental mikrotik api and backup
2026-02-01
* Firmware version fetching and notification for Mikrotik devices
* UI cleanup

View file

@ -0,0 +1,227 @@
defmodule Towerops.Devices.BackupRequestsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Devices.BackupRequests
alias Towerops.Organizations
alias Towerops.Sites
describe "create_request/2" do
test "creates backup request with pending status" do
device = insert(:device)
job_id = "backup:#{device.id}:#{DateTime.to_unix(DateTime.utc_now())}"
assert {:ok, request} = BackupRequests.create_request(device.id, job_id)
assert request.device_id == device.id
assert request.job_id == job_id
assert request.status == "pending"
assert request.requested_at
assert is_nil(request.completed_at)
assert is_nil(request.error_message)
end
end
describe "get_request_by_job_id/1" do
test "returns request by job_id" do
device = insert(:device)
job_id = "backup:#{device.id}:12345"
{:ok, request} = BackupRequests.create_request(device.id, job_id)
fetched = BackupRequests.get_request_by_job_id(job_id)
assert fetched.id == request.id
end
test "returns nil when job_id not found" do
assert BackupRequests.get_request_by_job_id("nonexistent") == nil
end
end
describe "update_request_status/3" do
test "updates request to success status" do
device = insert(:device)
{:ok, request} = BackupRequests.create_request(device.id, "job123")
assert {:ok, updated} = BackupRequests.update_request_status(request.id, "success", nil)
assert updated.status == "success"
assert updated.completed_at
assert is_nil(updated.error_message)
end
test "updates request to failed status with error message" do
device = insert(:device)
{:ok, request} = BackupRequests.create_request(device.id, "job123")
assert {:ok, updated} =
BackupRequests.update_request_status(request.id, "failed", "Connection timeout")
assert updated.status == "failed"
assert updated.completed_at
assert updated.error_message == "Connection timeout"
end
test "updates request to timeout status" do
device = insert(:device)
{:ok, request} = BackupRequests.create_request(device.id, "job123")
assert {:ok, updated} =
BackupRequests.update_request_status(request.id, "timeout", "Request timed out")
assert updated.status == "timeout"
assert updated.completed_at
assert updated.error_message == "Request timed out"
end
end
describe "mark_timed_out_requests/1" do
test "marks old pending requests as timeout" do
device = insert(:device)
# Create request from 10 minutes ago
{:ok, old_request} = BackupRequests.create_request(device.id, "job_old")
old_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
# Create recent request
{:ok, _recent_request} = BackupRequests.create_request(device.id, "job_recent")
# Mark requests older than 5 minutes as timeout
cutoff = DateTime.add(DateTime.utc_now(), -300, :second)
count = BackupRequests.mark_timed_out_requests(cutoff)
assert count == 1
# Verify old request is marked timeout
updated = BackupRequests.get_request_by_job_id("job_old")
assert updated.status == "timeout"
assert updated.completed_at
assert updated.error_message == "Request timed out"
end
test "does not mark completed requests as timeout" do
device = insert(:device)
# Create old request that already succeeded
{:ok, old_request} = BackupRequests.create_request(device.id, "job_old")
old_request
|> Ecto.Changeset.change(
requested_at: DateTime.utc_now() |> DateTime.add(-600, :second) |> DateTime.truncate(:second)
)
|> Repo.update!()
BackupRequests.update_request_status(old_request.id, "success", nil)
# Mark old pending requests
cutoff = DateTime.add(DateTime.utc_now(), -300, :second)
count = BackupRequests.mark_timed_out_requests(cutoff)
assert count == 0
end
end
describe "summary_since/1" do
test "returns summary of backup request statuses" do
device = insert(:device)
# Create various requests
Enum.each(1..5, fn i ->
{:ok, req} = BackupRequests.create_request(device.id, "success_#{i}")
BackupRequests.update_request_status(req.id, "success", nil)
end)
Enum.each(1..2, fn i ->
{:ok, req} = BackupRequests.create_request(device.id, "failed_#{i}")
BackupRequests.update_request_status(req.id, "failed", "Error")
end)
{:ok, req} = BackupRequests.create_request(device.id, "timeout_1")
BackupRequests.update_request_status(req.id, "timeout", "Timed out")
{:ok, _pending} = BackupRequests.create_request(device.id, "pending_1")
# Get summary from beginning of time
since = DateTime.add(DateTime.utc_now(), -86_400, :second)
summary = BackupRequests.summary_since(since)
assert summary.success == 5
assert summary.failed == 2
assert summary.timeout == 1
assert summary.pending == 1
end
end
describe "list_failed_devices_since/1" do
test "returns devices with failed or timeout requests" do
device1 = insert(:device)
device2 = insert(:device)
device3 = insert(:device)
# device1: failed
{:ok, req1} = BackupRequests.create_request(device1.id, "job1")
BackupRequests.update_request_status(req1.id, "failed", "SNMP error")
# device2: timeout
{:ok, req2} = BackupRequests.create_request(device2.id, "job2")
BackupRequests.update_request_status(req2.id, "timeout", "No response")
# device3: success (should not appear)
{:ok, req3} = BackupRequests.create_request(device3.id, "job3")
BackupRequests.update_request_status(req3.id, "success", nil)
since = DateTime.add(DateTime.utc_now(), -86_400, :second)
failed_devices = BackupRequests.list_failed_devices_since(since)
assert length(failed_devices) == 2
device_ids = Enum.map(failed_devices, & &1.device_id)
assert device1.id in device_ids
assert device2.id in device_ids
refute device3.id in device_ids
end
test "includes error messages and status" do
device = insert(:device)
{:ok, req} = BackupRequests.create_request(device.id, "job_fail")
BackupRequests.update_request_status(req.id, "failed", "Connection refused")
since = DateTime.add(DateTime.utc_now(), -86_400, :second)
[result] = BackupRequests.list_failed_devices_since(since)
assert result.device_id == device.id
assert result.status == "failed"
assert result.error_message == "Connection refused"
end
end
# Helper functions
defp insert(:device) do
user = user_fixture()
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, device} =
Devices.create_device(
%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
monitoring_enabled: false,
snmp_enabled: false
},
bypass_limits: true
)
device
end
end

View file

@ -0,0 +1,227 @@
defmodule Towerops.Devices.MikrotikBackups.DifferTest do
use ExUnit.Case, async: true
alias Towerops.Devices.MikrotikBackups.Differ
describe "mask_sensitive_data/1" do
test "masks passwords in user set commands" do
config = "/user set admin password=SuperSecret123"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "SuperSecret123")
assert String.contains?(masked, "***MASKED:")
# Hash should be consistent
assert masked == Differ.mask_sensitive_data(config)
end
test "masks passwords in user add commands" do
config = "/user add name=admin password=MyPassword123 group=full"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "MyPassword123")
assert String.contains?(masked, "password=***MASKED:")
end
test "masks SNMP community strings" do
config = "/snmp community set mycommunity name=mycommunity"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "mycommunity")
assert String.contains?(masked, "***MASKED:")
end
test "masks IPsec secrets" do
config = "/ip ipsec peer set peer1 secret=MyVpnSecret123"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "MyVpnSecret123")
assert String.contains?(masked, "secret=***MASKED:")
end
test "masks wireless WPA pre-shared keys" do
config = "/interface wireless security-profiles set profile1 wpa-pre-shared-key=WifiPassword123"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "WifiPassword123")
assert String.contains?(masked, "wpa-pre-shared-key=***MASKED:")
end
test "masks wireless WPA2 pre-shared keys" do
config = "/interface wireless security-profiles set profile1 wpa2-pre-shared-key=WifiPassword456"
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "WifiPassword456")
assert String.contains?(masked, "wpa2-pre-shared-key=***MASKED:")
end
test "preserves non-sensitive configuration" do
config = """
/interface bridge
add name=bridge1
/ip address
add address=192.168.1.1/24 interface=bridge1
"""
masked = Differ.mask_sensitive_data(config)
assert masked == config
end
test "different passwords produce different hashes" do
config1 = "/user set admin password=Password1"
config2 = "/user set admin password=Password2"
masked1 = Differ.mask_sensitive_data(config1)
masked2 = Differ.mask_sensitive_data(config2)
refute masked1 == masked2
end
test "handles multiline configuration with multiple secrets" do
config = """
/user set admin password=AdminPass123
/snmp community set public name=public
/ip ipsec peer set peer1 secret=VpnSecret456
/interface wireless security-profiles set profile1 wpa-pre-shared-key=WifiPass789
"""
masked = Differ.mask_sensitive_data(config)
refute String.contains?(masked, "AdminPass123")
refute String.contains?(masked, "VpnSecret456")
refute String.contains?(masked, "WifiPass789")
assert String.match?(masked, ~r/password=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/)
assert String.match?(masked, ~r/secret=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/)
assert String.match?(masked, ~r/wpa-pre-shared-key=\*\*\*MASKED:[a-f0-9]{8}\*\*\*/)
end
end
describe "generate_unified_diff/2" do
test "generates unified diff for different configs" do
config_a = """
/interface bridge
add name=bridge1
/ip address
add address=192.168.1.1/24 interface=bridge1
"""
config_b = """
/interface bridge
add name=bridge1
/ip address
add address=192.168.2.1/24 interface=bridge1
"""
{:ok, diff} = Differ.generate_unified_diff(config_a, config_b)
assert String.contains?(diff, "-add address=192.168.1.1/24")
assert String.contains?(diff, "+add address=192.168.2.1/24")
end
test "returns empty string for identical configs" do
config = """
/interface bridge
add name=bridge1
"""
{:ok, diff} = Differ.generate_unified_diff(config, config)
assert diff == ""
end
test "shows additions" do
config_a = """
/interface bridge
add name=bridge1
"""
config_b = """
/interface bridge
add name=bridge1
add name=bridge2
"""
{:ok, diff} = Differ.generate_unified_diff(config_a, config_b)
assert String.contains?(diff, "+add name=bridge2")
end
test "shows deletions" do
config_a = """
/interface bridge
add name=bridge1
add name=bridge2
"""
config_b = """
/interface bridge
add name=bridge1
"""
{:ok, diff} = Differ.generate_unified_diff(config_a, config_b)
assert String.contains?(diff, "-add name=bridge2")
end
test "handles empty configs" do
{:ok, diff} = Differ.generate_unified_diff("", "")
assert diff == ""
end
test "handles one empty config" do
config = "/interface bridge\nadd name=bridge1\n"
{:ok, diff} = Differ.generate_unified_diff("", config)
assert String.contains?(diff, "+/interface bridge")
assert String.contains?(diff, "+add name=bridge1")
end
end
describe "calculate_diff_stats/1" do
test "calculates stats from unified diff" do
diff = """
--- a
+++ b
@@ -1,3 +1,3 @@
/interface bridge
-add name=bridge1
+add name=bridge2
/ip address
"""
stats = Differ.calculate_diff_stats(diff)
assert stats.additions == 1
assert stats.deletions == 1
assert stats.changes == 1
end
test "returns zero stats for empty diff" do
stats = Differ.calculate_diff_stats("")
assert stats.additions == 0
assert stats.deletions == 0
assert stats.changes == 0
end
test "counts multiple additions and deletions" do
diff = """
--- a
+++ b
@@ -1,5 +1,6 @@
/interface bridge
-add name=bridge1
-add name=bridge2
+add name=bridge3
+add name=bridge4
+add name=bridge5
/ip address
"""
stats = Differ.calculate_diff_stats(diff)
assert stats.additions == 3
assert stats.deletions == 2
assert stats.changes == 2
end
end
end

View file

@ -0,0 +1,359 @@
defmodule Towerops.Devices.MikrotikBackupsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Devices.MikrotikBackups
alias Towerops.Organizations
alias Towerops.Sites
describe "compress_config/1" do
test "compresses config text with gzip" do
# Use larger config for better compression ratio
config = String.duplicate("# Test config\n/system identity\nset name=Router1\n", 20)
compressed = MikrotikBackups.compress_config(config)
# Compressed should be binary and smaller than original
assert is_binary(compressed)
assert byte_size(compressed) < byte_size(config)
end
test "roundtrip compress and decompress preserves config" do
config = String.duplicate("# Test config\n/system identity\nset name=Router1\n", 10)
compressed = MikrotikBackups.compress_config(config)
decompressed = MikrotikBackups.decompress_config(compressed)
assert decompressed == config
end
end
describe "normalize_config/1" do
test "removes timestamp comments from RouterOS config" do
config = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
# another comment
/interface ethernet
"""
normalized = MikrotikBackups.normalize_config(config)
# Should have no comment lines
refute String.contains?(normalized, "#")
assert String.contains?(normalized, "/system identity")
assert String.contains?(normalized, "set name=Router1")
end
test "trims whitespace and removes empty lines" do
config = """
/system identity
set name=Router1
/interface ethernet
"""
normalized = MikrotikBackups.normalize_config(config)
lines = String.split(normalized, "\n", trim: true)
# Should be 3 lines with no whitespace
assert length(lines) == 3
assert Enum.all?(lines, &(String.trim(&1) == &1))
end
end
describe "calculate_hash/1 and calculate_hash_normalized/1" do
test "calculates SHA256 hash of raw config" do
config = "/system identity\nset name=Router1"
hash = MikrotikBackups.calculate_hash(config)
# Should be 64-character hex string (SHA256)
assert String.length(hash) == 64
assert String.match?(hash, ~r/^[a-f0-9]+$/)
end
test "normalized hash is different from raw hash when comments present" do
config = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
raw_hash = MikrotikBackups.calculate_hash(config)
normalized_hash = MikrotikBackups.calculate_hash_normalized(config)
assert raw_hash != normalized_hash
end
test "normalized hash ignores timestamp changes" do
config1 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
config2 = """
# feb/15/2025 14:22:10 by RouterOS 7.14.1
/system identity
set name=Router1
"""
hash1 = MikrotikBackups.calculate_hash_normalized(config1)
hash2 = MikrotikBackups.calculate_hash_normalized(config2)
# Normalized hashes should be identical (same actual config)
assert hash1 == hash2
end
test "normalized hash detects real config changes" do
config1 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
config2 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router2
"""
hash1 = MikrotikBackups.calculate_hash_normalized(config1)
hash2 = MikrotikBackups.calculate_hash_normalized(config2)
# Different configs should have different hashes
assert hash1 != hash2
end
end
describe "create_backup/3" do
test "creates backup with compressed config" do
device = insert(:device)
config = "/system identity\nset name=Router1\n/interface ethernet\nset name=ether1"
assert {:ok, backup} = MikrotikBackups.create_backup(device.id, config, "daily_cron")
assert backup.device_id == device.id
assert backup.config_compressed
assert backup.config_hash
assert backup.config_hash_normalized
assert backup.config_size_bytes == byte_size(config)
assert backup.compressed_size_bytes < backup.config_size_bytes
assert backup.trigger_source == "daily_cron"
assert backup.backed_up_at
end
test "stores both raw and normalized hashes" do
device = insert(:device)
config = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
assert {:ok, backup} = MikrotikBackups.create_backup(device.id, config, "manual")
# Raw hash should match config with comments
assert backup.config_hash == MikrotikBackups.calculate_hash(config)
# Normalized hash should match config without comments
assert backup.config_hash_normalized == MikrotikBackups.calculate_hash_normalized(config)
end
end
describe "get_latest_backup/1" do
test "returns most recent backup for device" do
device = insert(:device)
_backup1 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-01 10:00:00Z])
_backup2 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-02 10:00:00Z])
backup3 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-03 10:00:00Z])
latest = MikrotikBackups.get_latest_backup(device.id)
assert latest.id == backup3.id
end
test "returns nil when no backups exist" do
device = insert(:device)
assert MikrotikBackups.get_latest_backup(device.id) == nil
end
test "only returns backups for specified device" do
device1 = insert(:device)
device2 = insert(:device)
backup1 = insert(:mikrotik_backup, device_id: device1.id, backed_up_at: ~U[2025-01-01 10:00:00Z])
_backup2 = insert(:mikrotik_backup, device_id: device2.id, backed_up_at: ~U[2025-01-02 10:00:00Z])
latest = MikrotikBackups.get_latest_backup(device1.id)
assert latest.id == backup1.id
end
end
describe "needs_backup?/2" do
test "returns true when no previous backup exists" do
device = insert(:device)
config = "/system identity\nset name=Router1"
assert {:ok, true} = MikrotikBackups.needs_backup?(device.id, config)
end
test "returns false when config hasn't changed (using normalized hash)" do
device = insert(:device)
config1 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
config2 = """
# feb/15/2025 14:22:10 by RouterOS 7.14.1
/system identity
set name=Router1
"""
# Create initial backup
{:ok, _backup} = MikrotikBackups.create_backup(device.id, config1, "daily_cron")
# Second config with different timestamp should not need backup
assert {:ok, false} = MikrotikBackups.needs_backup?(device.id, config2)
end
test "returns true when config has actually changed" do
device = insert(:device)
config1 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router1
"""
config2 = """
# jan/02/1970 00:38:40 by RouterOS 7.14
/system identity
set name=Router2
"""
# Create initial backup
{:ok, _backup} = MikrotikBackups.create_backup(device.id, config1, "daily_cron")
# Second config with actual change should need backup
assert {:ok, true} = MikrotikBackups.needs_backup?(device.id, config2)
end
end
describe "list_device_backups/2" do
test "returns backups ordered by most recent first" do
device = insert(:device)
backup1 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-01 10:00:00Z])
backup2 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-03 10:00:00Z])
backup3 = insert(:mikrotik_backup, device_id: device.id, backed_up_at: ~U[2025-01-02 10:00:00Z])
backups = MikrotikBackups.list_device_backups(device.id)
assert length(backups) == 3
assert hd(backups).id == backup2.id
assert Enum.at(backups, 1).id == backup3.id
assert Enum.at(backups, 2).id == backup1.id
end
test "limits results when limit provided" do
device = insert(:device)
for i <- 1..10 do
insert(:mikrotik_backup, device_id: device.id, backed_up_at: DateTime.add(~U[2025-01-01 10:00:00Z], i, :day))
end
backups = MikrotikBackups.list_device_backups(device.id, limit: 5)
assert length(backups) == 5
end
test "only returns backups for specified device" do
device1 = insert(:device)
device2 = insert(:device)
insert(:mikrotik_backup, device_id: device1.id)
insert(:mikrotik_backup, device_id: device2.id)
insert(:mikrotik_backup, device_id: device2.id)
backups = MikrotikBackups.list_device_backups(device1.id)
assert length(backups) == 1
assert hd(backups).device_id == device1.id
end
end
describe "get_backup!/1" do
test "returns backup by id" do
device = insert(:device)
backup = insert(:mikrotik_backup, device_id: device.id)
fetched = MikrotikBackups.get_backup!(backup.id)
assert fetched.id == backup.id
end
test "raises when backup not found" do
assert_raise Ecto.NoResultsError, fn ->
MikrotikBackups.get_backup!(Ecto.UUID.generate())
end
end
end
# Helper functions
defp insert(:device) do
user = user_fixture()
{:ok, organization} = Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "Test Site", organization_id: organization.id})
{:ok, device} =
Devices.create_device(
%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
monitoring_enabled: false,
snmp_enabled: false
},
bypass_limits: true
)
device
end
defp insert(:mikrotik_backup, attrs) do
config = attrs[:config] || "/system identity\nset name=Router1"
{:ok, backup} =
MikrotikBackups.create_backup(
attrs[:device_id],
config,
attrs[:trigger_source] || "daily_cron"
)
# Update backed_up_at if provided
backup =
if attrs[:backed_up_at] do
backup
|> Ecto.Changeset.change(backed_up_at: attrs[:backed_up_at])
|> Repo.update!()
else
backup
end
backup
end
end