Mikrotik backup and gettext start
This commit is contained in:
parent
a48cb292da
commit
f938b263cd
33 changed files with 2667 additions and 85 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
lib/towerops/devices/backup_request.ex
Normal file
39
lib/towerops/devices/backup_request.ex
Normal 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
|
||||
99
lib/towerops/devices/backup_requests.ex
Normal file
99
lib/towerops/devices/backup_requests.ex
Normal 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
|
||||
48
lib/towerops/devices/mikrotik_backup.ex
Normal file
48
lib/towerops/devices/mikrotik_backup.ex
Normal 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
|
||||
121
lib/towerops/devices/mikrotik_backups.ex
Normal file
121
lib/towerops/devices/mikrotik_backups.ex
Normal 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
|
||||
202
lib/towerops/devices/mikrotik_backups/differ.ex
Normal file
202
lib/towerops/devices/mikrotik_backups/differ.ex
Normal 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
|
||||
35
lib/towerops/workers/backup_summary_worker.ex
Normal file
35
lib/towerops/workers/backup_summary_worker.ex
Normal 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
|
||||
29
lib/towerops/workers/backup_timeout_worker.ex
Normal file
29
lib/towerops/workers/backup_timeout_worker.ex
Normal 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
|
||||
114
lib/towerops/workers/mikrotik_backup_worker.ex
Normal file
114
lib/towerops/workers/mikrotik_backup_worker.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
121
lib/towerops_web/live/mikrotik_backup_live/compare.ex
Normal file
121
lib/towerops_web/live/mikrotik_backup_live/compare.ex
Normal 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
|
||||
170
lib/towerops_web/live/mikrotik_backup_live/compare.html.heex
Normal file
170
lib/towerops_web/live/mikrotik_backup_live/compare.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
88
priv/gettext/default.pot
Normal 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 ""
|
||||
88
priv/gettext/en/LC_MESSAGES/default.po
Normal file
88
priv/gettext/en/LC_MESSAGES/default.po
Normal 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 ""
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
227
test/towerops/devices/backup_requests_test.exs
Normal file
227
test/towerops/devices/backup_requests_test.exs
Normal 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
|
||||
227
test/towerops/devices/mikrotik_backups/differ_test.exs
Normal file
227
test/towerops/devices/mikrotik_backups/differ_test.exs
Normal 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
|
||||
359
test/towerops/devices/mikrotik_backups_test.exs
Normal file
359
test/towerops/devices/mikrotik_backups_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue