towerops/lib/towerops/config_changes.ex
Graham McIntie 9e7ee5099d refactor: fix all credo strict issues, format all code, fix broken routes
- Fix broken route paths in dashboard (/discovery, /subscribers/trace, /config-changes)
- Fix insights empty state settings link (org-scoped route)
- Add underscores to all 86400 literals across 6 files
- Alphabetize aliases in search.ex and agent_channel.ex
- Extract changelog parser helper to reduce nesting
- Extract dashboard impact calculation to reduce nesting
- Refactor agent_channel config change detection (pattern match, extract function)
- Combine double Enum.reject into single call in trace.ex
- Fix line length in trace.ex search query
- Replace length/1 > 0 with != [] in trace_live
- Run mix format on all files
2026-02-13 19:34:40 -06:00

175 lines
5.1 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
@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
# Detect which MikroTik config sections were modified based on diff content
defp detect_changed_sections(diff) do
@mikrotik_sections
|> Enum.filter(fn {_name, pattern} -> Regex.match?(pattern, diff) end)
|> Enum.map(fn {name, _} -> name end)
|> Enum.sort()
end
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