Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
185 lines
5.6 KiB
Elixir
185 lines
5.6 KiB
Elixir
defmodule Towerops.ConfigChanges do
|
|
@moduledoc """
|
|
Context for managing config change events — linking MikroTik backup diffs
|
|
to time windows for performance correlation analysis.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias Towerops.ConfigChanges.ConfigChangeEvent
|
|
alias Towerops.Devices.MikrotikBackups
|
|
alias Towerops.Devices.MikrotikBackups.Differ
|
|
alias Towerops.Repo
|
|
|
|
require Logger
|
|
|
|
# Patterns allow optional unified-diff line prefixes (+, -, " ") before the
|
|
# leading "/" so they match both raw MikroTik configs and unified-diff output.
|
|
@mikrotik_sections %{
|
|
"firewall" => ~r/^[+\- ]?\/(ip )?firewall/m,
|
|
"interface" => ~r/^[+\- ]?\/interface/m,
|
|
"routing" => ~r/^[+\- ]?\/(ip |routing )(route|ospf|bgp|rip)/m,
|
|
"wireless" => ~r/^[+\- ]?\/interface wireless/m,
|
|
"queue" => ~r/^[+\- ]?\/queue/m,
|
|
"nat" => ~r/^[+\- ]?\/(ip )?firewall nat/m,
|
|
"address" => ~r/^[+\- ]?\/ip address/m,
|
|
"dns" => ~r/^[+\- ]?\/ip dns/m,
|
|
"dhcp" => ~r/^[+\- ]?\/ip dhcp/m,
|
|
"bridge" => ~r/^[+\- ]?\/interface bridge/m,
|
|
"vlan" => ~r/^[+\- ]?\/interface vlan/m,
|
|
"snmp" => ~r/^[+\- ]?\/snmp/m,
|
|
"user" => ~r/^[+\- ]?\/user/m,
|
|
"system" => ~r/^[+\- ]?\/system/m,
|
|
"ppp" => ~r/^[+\- ]?\/ppp/m
|
|
}
|
|
|
|
@doc """
|
|
Records a config change event from two backup records.
|
|
|
|
Generates a diff, detects changed sections, and stores the event.
|
|
"""
|
|
def record_change_event(device_id, before_backup, after_backup) do
|
|
config_before =
|
|
before_backup.config_compressed
|
|
|> MikrotikBackups.decompress_config()
|
|
|> Differ.mask_sensitive_data()
|
|
|
|
config_after =
|
|
after_backup.config_compressed
|
|
|> MikrotikBackups.decompress_config()
|
|
|> Differ.mask_sensitive_data()
|
|
|
|
case Differ.generate_unified_diff(config_before, config_after) do
|
|
{:ok, ""} ->
|
|
{:ok, :no_changes}
|
|
|
|
{:ok, diff} ->
|
|
stats = Differ.calculate_diff_stats(diff)
|
|
sections = detect_changed_sections(diff)
|
|
|
|
# Truncate diff_summary to first 2000 chars for storage
|
|
summary = String.slice(diff, 0, 2000)
|
|
|
|
device = Repo.get!(Towerops.Devices.Device, device_id)
|
|
|
|
attrs = %{
|
|
device_id: device_id,
|
|
organization_id: device.organization_id,
|
|
backup_before_id: before_backup.id,
|
|
backup_after_id: after_backup.id,
|
|
changed_at: after_backup.backed_up_at,
|
|
diff_summary: summary,
|
|
sections_changed: sections,
|
|
change_size: stats.additions + stats.deletions
|
|
}
|
|
|
|
%ConfigChangeEvent{}
|
|
|> ConfigChangeEvent.changeset(attrs)
|
|
|> Repo.insert()
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Failed to generate diff for config change event: #{inspect(reason)}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Lists config change events for a device, most recent first.
|
|
Supports `:after` and `:before` date range filters and `:limit`.
|
|
"""
|
|
def list_device_changes(device_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 50)
|
|
|
|
ConfigChangeEvent
|
|
|> where(device_id: ^device_id)
|
|
|> apply_date_filters(opts)
|
|
|> order_by(desc: :changed_at)
|
|
|> limit(^limit)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Lists config change events for an organization, most recent first.
|
|
Supports `:after` and `:before` date range filters, `:limit`, and `:preload`.
|
|
"""
|
|
def list_org_changes(organization_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 50)
|
|
preloads = Keyword.get(opts, :preload, [])
|
|
|
|
ConfigChangeEvent
|
|
|> where(organization_id: ^organization_id)
|
|
|> apply_date_filters(opts)
|
|
|> order_by(desc: :changed_at)
|
|
|> limit(^limit)
|
|
|> Repo.all()
|
|
|> Repo.preload(preloads)
|
|
end
|
|
|
|
@doc """
|
|
Lists config change events for devices at a specific site, most recent first.
|
|
Supports `:after`, `:before`, `:limit`, and `:preload` options.
|
|
"""
|
|
def list_site_changes(site_id, opts \\ []) do
|
|
limit = Keyword.get(opts, :limit, 20)
|
|
preloads = Keyword.get(opts, :preload, [:device])
|
|
|
|
device_ids =
|
|
site_id
|
|
|> Towerops.Devices.list_site_devices()
|
|
|> Enum.map(& &1.id)
|
|
|
|
if device_ids == [] do
|
|
[]
|
|
else
|
|
ConfigChangeEvent
|
|
|> where([e], e.device_id in ^device_ids)
|
|
|> apply_date_filters(opts)
|
|
|> order_by(desc: :changed_at)
|
|
|> limit(^limit)
|
|
|> Repo.all()
|
|
|> Repo.preload(preloads)
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Gets a single config change event by ID.
|
|
"""
|
|
def get_change_event!(id), do: Repo.get!(ConfigChangeEvent, id)
|
|
|
|
@doc """
|
|
Gets a config change event with preloaded associations.
|
|
"""
|
|
def get_change_event_with_preloads!(id) do
|
|
ConfigChangeEvent
|
|
|> Repo.get!(id)
|
|
|> Repo.preload([:device, :backup_before, :backup_after])
|
|
end
|
|
|
|
@doc """
|
|
Detects which MikroTik config sections were modified in a unified diff.
|
|
|
|
Returns a sorted list of section names (strings) matching sections like
|
|
`firewall`, `interface`, `routing`, etc.
|
|
"""
|
|
@spec detect_changed_sections(String.t()) :: [String.t()]
|
|
def detect_changed_sections(diff) when is_binary(diff) do
|
|
@mikrotik_sections
|
|
|> Enum.filter(fn {_name, pattern} -> Regex.match?(pattern, diff) end)
|
|
|> Enum.map(fn {name, _} -> name end)
|
|
|> Enum.sort()
|
|
end
|
|
|
|
def detect_changed_sections(_), do: []
|
|
|
|
defp apply_date_filters(query, opts) do
|
|
query
|
|
|> maybe_filter_after(Keyword.get(opts, :after))
|
|
|> maybe_filter_before(Keyword.get(opts, :before))
|
|
end
|
|
|
|
defp maybe_filter_after(query, nil), do: query
|
|
defp maybe_filter_after(query, dt), do: where(query, [e], e.changed_at >= ^dt)
|
|
|
|
defp maybe_filter_before(query, nil), do: query
|
|
defp maybe_filter_before(query, dt), do: where(query, [e], e.changed_at <= ^dt)
|
|
end
|