feat: config change → performance correlation system (Roadmap #2)

- New config_change_events table linking backup diffs to time windows
- ConfigChanges context with record/list functions and date filters
- Auto-detect changed sections from MikroTik config diffs
- Agent channel emits change events after backup creation
- Correlator engine: 2h before / 4h after QoE metric comparison
- Creates suspect_config_change insights on significant degradation
- Config Timeline LiveView with chart data, change list, detail view
- Device page: Config Timeline tab + Recent Config Changes card
- Dashboard: Recent Config Changes section with impact indicators
- Fix pre-existing integrations_live.html.heex scope bug
This commit is contained in:
Graham McIntire 2026-02-13 17:56:43 -06:00
parent 02f837ca5b
commit 5a3ede1f71
14 changed files with 888 additions and 2 deletions

View file

@ -1,3 +1,34 @@
2026-02-13
feat: config change → performance correlation system (Roadmap #2)
- New `config_change_events` table + migration linking backup diffs to time windows
- Schema: device_id, organization_id, backup_before_id, backup_after_id, changed_at,
diff_summary, sections_changed (array), change_size
- `Towerops.ConfigChanges` context: record_change_event/3, list_device_changes/2,
list_org_changes/2 with date range filters (:after, :before, :limit)
- Automatic section detection from diffs (firewall, interface, routing, wireless, queue,
nat, address, dns, dhcp, bridge, vlan, snmp, user, system, ppp)
- Enhanced agent_channel backup flow: after MikrotikBackup creation, automatically
emits config_change_event when hash differs from previous backup
- `Towerops.ConfigChanges.Correlator` engine: compares Preseem QoE metrics in a
2h-before / 4h-after window around config changes
- Calculates metric deltas (latency, jitter, loss, throughput) and scores significance
(latency >20%, loss >50%, throughput drop >15%)
- Creates "suspect_config_change" insight with severity (warning/critical) and full
metric delta data when correlation detected
- Added "suspect_config_change" to valid insight types
- New Config Timeline LiveView at /devices/:device_id/config-timeline
- Date range selector (24h, 7d, 30d)
- QoE metrics data (latency, throughput, loss) passed to chart hook
- Check results (up/down) status data for status bar
- Config change events as clickable table rows with diff summary detail view
- Links to full backup compare view for each change
- Device page integration: "Config Timeline" tab in device detail navigation,
"Recent Config Changes" mini-card in overview with color-coded size indicators
- Dashboard integration: "Recent Config Changes" section showing org-wide changes
with device name, timestamp, sections changed, and color-coded impact dots
- Fixed pre-existing bug: integrations_live.html.heex sync schedule block was
outside the `if integration` scope, causing compilation error
2026-02-13
feat: unified insights page, integration nav improvements, sync transparency
- Created top-level Insights LiveView at /insights (replaces buried settings page)

View file

@ -0,0 +1,149 @@
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 """
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

View file

@ -0,0 +1,44 @@
defmodule Towerops.ConfigChanges.ConfigChangeEvent do
@moduledoc """
Schema for config change events linking MikroTik backup diffs to time windows.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "config_change_events" do
field :changed_at, :utc_datetime
field :diff_summary, :string
field :sections_changed, {:array, :string}, default: []
field :change_size, :integer, default: 0
belongs_to :device, Towerops.Devices.Device
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :backup_before, Towerops.Devices.MikrotikBackup
belongs_to :backup_after, Towerops.Devices.MikrotikBackup
timestamps(type: :utc_datetime, updated_at: false)
end
def changeset(event, attrs) do
event
|> cast(attrs, [
:device_id,
:organization_id,
:backup_before_id,
:backup_after_id,
:changed_at,
:diff_summary,
:sections_changed,
:change_size
])
|> validate_required([:device_id, :organization_id, :changed_at])
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:backup_before_id)
|> foreign_key_constraint(:backup_after_id)
end
end

View file

@ -0,0 +1,197 @@
defmodule Towerops.ConfigChanges.Correlator do
@moduledoc """
Performance correlation engine for config change events.
Compares QoE metrics from Preseem in a window around a config change:
2 hours before and 4 hours after. Detects significant degradation and
creates insights when correlation is found.
"""
import Ecto.Query
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insights
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
require Logger
@pre_window_hours 2
@post_window_hours 4
# Thresholds for "significant" impact
@latency_increase_threshold 0.20
@loss_increase_threshold 0.50
@throughput_drop_threshold 0.15
@doc """
Correlate a config change event with QoE metrics.
Returns `{:ok, :no_impact}`, `{:ok, :no_data}`, or `{:ok, insight}` when
significant degradation is detected.
"""
def correlate(%ConfigChangeEvent{} = event) do
# Find Preseem access points matched to this device
ap_ids = get_matched_ap_ids(event.device_id)
if Enum.empty?(ap_ids) do
{:ok, :no_data}
else
pre_start = DateTime.add(event.changed_at, -@pre_window_hours * 3600, :second)
post_end = DateTime.add(event.changed_at, @post_window_hours * 3600, :second)
pre_metrics = avg_metrics(ap_ids, pre_start, event.changed_at)
post_metrics = avg_metrics(ap_ids, event.changed_at, post_end)
case {pre_metrics, post_metrics} do
{nil, _} -> {:ok, :no_data}
{_, nil} -> {:ok, :no_data}
{pre, post} -> evaluate_impact(event, pre, post)
end
end
end
@doc """
Correlate all recent uncorrelated change events for an organization.
"""
def correlate_recent(organization_id, opts \\ []) do
hours_back = Keyword.get(opts, :hours_back, 24)
since = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
events =
ConfigChangeEvent
|> where(organization_id: ^organization_id)
|> where([e], e.changed_at >= ^since)
|> Repo.all()
results = Enum.map(events, &correlate/1)
{:ok, results}
end
# Find Preseem AP IDs matched to a device
defp get_matched_ap_ids(device_id) do
AccessPoint
|> where(device_id: ^device_id)
|> select([ap], ap.id)
|> Repo.all()
end
# Average metrics for given APs in a time window
defp avg_metrics(ap_ids, from, to) do
result =
SubscriberMetric
|> where([m], m.preseem_access_point_id in ^ap_ids)
|> where([m], m.recorded_at >= ^from and m.recorded_at < ^to)
|> select([m], %{
avg_latency: avg(m.avg_latency),
avg_jitter: avg(m.avg_jitter),
avg_loss: avg(m.avg_loss),
avg_throughput: avg(m.avg_throughput)
})
|> Repo.one()
if is_nil(result) || is_nil(result.avg_latency) do
nil
else
result
end
end
defp evaluate_impact(event, pre, post) do
deltas = %{
latency_delta: safe_pct_change(pre.avg_latency, post.avg_latency),
jitter_delta: safe_pct_change(pre.avg_jitter, post.avg_jitter),
loss_delta: safe_pct_change(pre.avg_loss, post.avg_loss),
throughput_delta: safe_pct_change(pre.avg_throughput, post.avg_throughput)
}
significant? =
deltas.latency_delta > @latency_increase_threshold ||
deltas.loss_delta > @loss_increase_threshold ||
deltas.throughput_delta < -@throughput_drop_threshold
if significant? do
create_suspect_insight(event, deltas, pre, post)
else
{:ok, :no_impact}
end
end
defp create_suspect_insight(event, deltas, pre, post) do
severity = determine_severity(deltas)
attrs = %{
organization_id: event.organization_id,
device_id: event.device_id,
type: "suspect_config_change",
urgency: severity,
channel: "proactive",
source: "system",
title: "Config change may have degraded performance",
description: build_description(deltas, event),
metadata: %{
"change_event_id" => event.id,
"metric_deltas" => %{
"latency_pct" => Float.round(deltas.latency_delta * 100, 1),
"jitter_pct" => Float.round(deltas.jitter_delta * 100, 1),
"loss_pct" => Float.round(deltas.loss_delta * 100, 1),
"throughput_pct" => Float.round(deltas.throughput_delta * 100, 1)
},
"pre_metrics" => stringify_metrics(pre),
"post_metrics" => stringify_metrics(post),
"sections_changed" => event.sections_changed
}
}
Insights.insert_insight_if_new(attrs)
end
defp determine_severity(deltas) do
cond do
deltas.latency_delta > 0.50 || deltas.loss_delta > 1.0 || deltas.throughput_delta < -0.30 ->
"critical"
deltas.latency_delta > @latency_increase_threshold ||
deltas.loss_delta > @loss_increase_threshold ||
deltas.throughput_delta < -@throughput_drop_threshold ->
"warning"
true ->
"info"
end
end
defp build_description(deltas, event) do
parts =
[]
|> maybe_add("latency +#{round(deltas.latency_delta * 100)}%", deltas.latency_delta > 0.10)
|> maybe_add("loss +#{round(deltas.loss_delta * 100)}%", deltas.loss_delta > 0.10)
|> maybe_add("throughput #{round(deltas.throughput_delta * 100)}%", deltas.throughput_delta < -0.05)
sections =
if Enum.any?(event.sections_changed),
do: " (sections: #{Enum.join(event.sections_changed, ", ")})",
else: ""
"After config change#{sections}: #{Enum.join(parts, ", ")}."
end
defp maybe_add(list, text, true), do: list ++ [text]
defp maybe_add(list, _text, false), do: list
defp safe_pct_change(old, new) when is_number(old) and is_number(new) and old > 0 do
(new - old) / old
end
defp safe_pct_change(_, _), do: 0.0
defp stringify_metrics(metrics) do
%{
"avg_latency" => metrics.avg_latency && Float.round(metrics.avg_latency, 2),
"avg_jitter" => metrics.avg_jitter && Float.round(metrics.avg_jitter, 2),
"avg_loss" => metrics.avg_loss && Float.round(metrics.avg_loss, 4),
"avg_throughput" => metrics.avg_throughput && Float.round(metrics.avg_throughput, 2)
}
end
end

View file

@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend)
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)

View file

@ -1803,15 +1803,47 @@ defmodule ToweropsWeb.AgentChannel do
defp create_and_save_backup(request, result, config_text) do
case MikrotikBackups.create_backup(result.device_id, config_text, request.source) do
{:ok, _backup} ->
{:ok, new_backup} ->
BackupRequests.update_request_status(request.id, "success", nil)
Logger.info("Backup created", device_id: result.device_id, source: request.source)
# Emit config change event if there was a previous backup
maybe_emit_config_change_event(result.device_id, new_backup)
{:error, reason} ->
mark_backup_failed(request, result, "Failed to create backup: #{inspect(reason)}")
end
end
defp maybe_emit_config_change_event(device_id, new_backup) do
# Find the previous backup (second most recent)
backups = MikrotikBackups.list_device_backups(device_id, limit: 2)
case backups do
[_new, previous | _] ->
if previous.config_hash_normalized != new_backup.config_hash_normalized do
case Towerops.ConfigChanges.record_change_event(device_id, previous, new_backup) do
{:ok, %Towerops.ConfigChanges.ConfigChangeEvent{} = event} ->
Logger.info("Config change event recorded", device_id: device_id, event_id: event.id)
# Async correlate with performance metrics
Task.start(fn ->
Towerops.ConfigChanges.Correlator.correlate(event)
end)
{:ok, :no_changes} ->
:ok
{:error, reason} ->
Logger.warning("Failed to record config change event: #{inspect(reason)}", device_id: device_id)
end
end
_ ->
:ok
end
end
defp backup_needed?(device_id, config_text) do
{:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text)
needs_backup?

View file

@ -0,0 +1,290 @@
defmodule ToweropsWeb.ConfigTimelineLive do
@moduledoc """
Timeline visualization showing config change events overlaid with
QoE metrics and check results for a device.
"""
use ToweropsWeb, :live_view
alias Towerops.ConfigChanges
alias Towerops.Devices
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
alias ToweropsWeb.Live.Helpers.AccessControl
import Ecto.Query
@ranges %{
"24h" => 24,
"7d" => 24 * 7,
"30d" => 24 * 30
}
@impl true
def mount(%{"device_id" => device_id}, _session, socket) do
organization = socket.assigns.current_scope.organization
case AccessControl.verify_device_access(device_id, organization.id) do
{:ok, _} ->
device = Devices.get_device!(device_id)
{:ok, assign(socket, device: device, page_title: "Config Timeline — #{device.name}")}
{:error, _} ->
{:ok,
socket
|> put_flash(:error, "Device not found")
|> redirect(to: ~p"/devices")}
end
end
@impl true
def handle_params(params, _url, socket) do
range = Map.get(params, "range", "7d")
hours = Map.get(@ranges, range, 24 * 7)
since = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
device = socket.assigns.device
change_events = ConfigChanges.list_device_changes(device.id, after: since, limit: 100)
qoe_data = load_qoe_data(device.id, since)
check_data = load_check_data(device.id, since)
{:noreply,
socket
|> assign(:range, range)
|> assign(:change_events, change_events)
|> assign(:qoe_data, qoe_data)
|> assign(:check_data, check_data)
|> assign(:selected_event, nil)}
end
@impl true
def handle_event("select_range", %{"range" => range}, socket) do
{:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}/config-timeline?range=#{range}")}
end
@impl true
def handle_event("select_event", %{"id" => event_id}, socket) do
event = ConfigChanges.get_change_event_with_preloads!(event_id)
{:noreply, assign(socket, :selected_event, event)}
end
@impl true
def handle_event("close_event", _, socket) do
{:noreply, assign(socket, :selected_event, nil)}
end
@impl true
def render(assigns) do
~H"""
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">Config Timeline</h1>
<p class="text-base-content/60">
<.link navigate={~p"/devices/#{@device.id}?tab=overview"} class="link link-hover">
{@device.name}
</.link>
Config changes vs performance metrics
</p>
</div>
<div class="join">
<button
:for={r <- ["24h", "7d", "30d"]}
class={"join-item btn btn-sm #{if @range == r, do: "btn-primary", else: "btn-ghost"}"}
phx-click="select_range"
phx-value-range={r}
>
{r}
</button>
</div>
</div>
<%!-- Timeline chart area --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Performance & Config Changes</h2>
<%!-- QoE metrics chart placeholder rendered via hook --%>
<div
id="config-timeline-chart"
phx-hook="ConfigTimelineChart"
data-qoe={Jason.encode!(@qoe_data)}
data-changes={Jason.encode!(Enum.map(@change_events, &timeline_event/1))}
data-checks={Jason.encode!(@check_data)}
class="h-64 w-full"
>
<div class="flex items-center justify-center h-full text-base-content/40">
Loading chart...
</div>
</div>
</div>
</div>
<%!-- Change events list --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg">Config Changes ({length(@change_events)})</h2>
<%= if Enum.empty?(@change_events) do %>
<p class="text-base-content/50 py-4">No config changes in this period.</p>
<% else %>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>When</th>
<th>Sections</th>
<th>Size</th>
<th></th>
</tr>
</thead>
<tbody>
<tr :for={event <- @change_events} class="hover:bg-base-200/50 cursor-pointer" phx-click="select_event" phx-value-id={event.id}>
<td class="whitespace-nowrap">
{Calendar.strftime(event.changed_at, "%b %d, %H:%M UTC")}
</td>
<td>
<span :for={section <- event.sections_changed} class="badge badge-sm badge-ghost mr-1">
{section}
</span>
</td>
<td>
<span class={"badge badge-sm #{change_size_class(event.change_size)}"}>
{event.change_size} lines
</span>
</td>
<td>
<.link
navigate={~p"/devices/#{@device.id}/backups/compare?before=#{event.backup_before_id}&after=#{event.backup_after_id}"}
class="btn btn-xs btn-ghost"
>
View Diff
</.link>
</td>
</tr>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
<%!-- Selected event detail modal --%>
<%= if @selected_event do %>
<div class="card bg-base-100 shadow-sm border border-primary/20">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-lg">
Change Details {Calendar.strftime(@selected_event.changed_at, "%b %d, %H:%M UTC")}
</h2>
<button class="btn btn-sm btn-ghost" phx-click="close_event"></button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<span class="text-sm font-medium text-base-content/60">Sections Changed</span>
<div class="mt-1">
<span :for={s <- @selected_event.sections_changed} class="badge badge-sm badge-outline mr-1">
{s}
</span>
<span :if={Enum.empty?(@selected_event.sections_changed)} class="text-base-content/40">Unknown</span>
</div>
</div>
<div>
<span class="text-sm font-medium text-base-content/60">Lines Changed</span>
<p class="font-mono">{@selected_event.change_size}</p>
</div>
<div>
<.link
navigate={~p"/devices/#{@device.id}/backups/compare?before=#{@selected_event.backup_before_id}&after=#{@selected_event.backup_after_id}"}
class="btn btn-sm btn-primary"
>
Full Diff View
</.link>
</div>
</div>
<%= if @selected_event.diff_summary do %>
<div class="mt-4">
<span class="text-sm font-medium text-base-content/60">Diff Summary</span>
<pre class="bg-base-200 p-3 rounded-lg mt-1 text-xs overflow-x-auto max-h-48 overflow-y-auto"><code>{@selected_event.diff_summary}</code></pre>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
"""
end
# -- Data loaders --
defp load_qoe_data(device_id, since) do
ap_ids =
AccessPoint
|> where(device_id: ^device_id)
|> select([ap], ap.id)
|> Repo.all()
if Enum.empty?(ap_ids) do
[]
else
SubscriberMetric
|> where([m], m.preseem_access_point_id in ^ap_ids)
|> where([m], m.recorded_at >= ^since)
|> order_by(asc: :recorded_at)
|> select([m], %{
t: m.recorded_at,
latency: m.avg_latency,
throughput: m.avg_throughput,
loss: m.avg_loss
})
|> Repo.all()
|> Enum.map(fn m ->
%{
t: DateTime.to_iso8601(m.t),
latency: m.latency,
throughput: m.throughput,
loss: m.loss
}
end)
end
end
defp load_check_data(device_id, since) do
check_ids =
Towerops.Monitoring.Check
|> where(device_id: ^device_id)
|> select([c], c.id)
|> Repo.all()
if Enum.empty?(check_ids) do
[]
else
Towerops.Monitoring.CheckResult
|> where([r], r.check_id in ^check_ids)
|> where([r], r.checked_at >= ^since)
|> order_by(asc: :checked_at)
|> select([r], %{t: r.checked_at, status: r.status})
|> limit(1000)
|> Repo.all()
|> Enum.map(fn r ->
%{t: DateTime.to_iso8601(r.t), status: r.status}
end)
end
end
defp timeline_event(event) do
%{
id: event.id,
t: DateTime.to_iso8601(event.changed_at),
sections: event.sections_changed,
size: event.change_size
}
end
defp change_size_class(size) when size > 50, do: "badge-error"
defp change_size_class(size) when size > 20, do: "badge-warning"
defp change_size_class(_), do: "badge-info"
end

View file

@ -131,6 +131,20 @@ defmodule ToweropsWeb.DashboardLive do
|> assign(:active_incidents, active_incidents)
|> assign(:site_impact_summaries, site_impact_summaries)
|> load_insights(organization_id)
|> load_recent_config_changes(organization_id)
end
defp load_recent_config_changes(socket, organization_id) do
alias Towerops.ConfigChanges
changes =
ConfigChanges.list_org_changes(organization_id,
limit: 10,
after: DateTime.add(DateTime.utc_now(), -7 * 24 * 3600, :second),
preload: [:device]
)
assign(socket, :recent_config_changes, changes)
end
defp load_insights(socket, organization_id) do
@ -168,6 +182,14 @@ defmodule ToweropsWeb.DashboardLive do
defp source_classes("system"), do: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
defp config_change_impact_color(change) do
cond do
change.change_size > 50 -> "bg-red-500"
change.change_size > 20 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = amount) do

View file

@ -390,6 +390,36 @@
<% end %>
</div>
<%!-- Recent Config Changes --%>
<%= if Enum.any?(@recent_config_changes) do %>
<div class="lg:col-span-5">
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5 mb-3">
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" />
Recent Config Changes
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
<%= for change <- @recent_config_changes do %>
<.link
navigate={~p"/devices/#{change.device_id}/config-timeline"}
class="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<span class={"w-2.5 h-2.5 rounded-full flex-shrink-0 #{config_change_impact_color(change)}"} />
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{if change.device, do: change.device.name, else: "Unknown Device"}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join(Enum.take(change.sections_changed, 2), ", ")}
</p>
</div>
</.link>
<% end %>
</div>
</div>
</div>
<% end %>
<%!-- Right: Insights Feed (2 cols) --%>
<div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">

View file

@ -431,6 +431,16 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:grouped_transceivers, grouped_transceivers)
|> assign(:available_firmware, available_firmware)
|> assign(:connected_devices, Towerops.Topology.list_connected_devices(device_id))
|> assign(:recent_config_changes, load_recent_config_changes(device))
end
defp load_recent_config_changes(device) do
if device.mikrotik_enabled do
since = DateTime.add(DateTime.utc_now(), -30 * 24 * 3600, :second)
Towerops.ConfigChanges.list_device_changes(device.id, after: since, limit: 5)
else
[]
end
end
# Ports tab: interfaces grouped by type.
@ -1611,4 +1621,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
TimeHelpers.format_time_ago(datetime)
end
defp config_change_dot_color(event) do
cond do
event.change_size > 50 -> "bg-red-500"
event.change_size > 20 -> "bg-yellow-500"
true -> "bg-green-500"
end
end
end

View file

@ -228,6 +228,18 @@
</.link>
<% end %>
<%= if @device.mikrotik_enabled do %>
<.link
navigate={~p"/devices/#{@device.id}/config-timeline"}
class="whitespace-nowrap py-4 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
>
<span class="flex items-center gap-1.5">
<.icon name="hero-clock" class="h-3.5 w-3.5 text-orange-500 dark:text-orange-400" />
Config Timeline
</span>
</.link>
<% end %>
<%= if @preseem_access_point do %>
<.link
patch={~p"/devices/#{@device.id}?tab=preseem"}
@ -586,6 +598,34 @@
</div>
</div>
<% end %>
<%!-- Recent Config Changes mini-card --%>
<%= if @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) 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 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-1.5">
<.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> Recent Config Changes
</h3>
<.link navigate={~p"/devices/#{@device.id}/config-timeline"} class="text-xs text-blue-500 hover:underline">
View Timeline →
</.link>
</div>
<div class="p-3 space-y-2">
<%= for event <- Enum.take(@recent_config_changes, 5) do %>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<span class={"w-2 h-2 rounded-full #{config_change_dot_color(event)}"} />
<span class="text-gray-600 dark:text-gray-400">
{Calendar.strftime(event.changed_at, "%b %d %H:%M")}
</span>
<span :for={s <- Enum.take(event.sections_changed, 3)} class="badge badge-xs badge-ghost">{s}</span>
</div>
<span class="text-gray-500 text-xs">{event.change_size} lines</span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<!-- Right Column: Graphs and Metrics -->
<div class="space-y-6">

View file

@ -360,6 +360,7 @@ defmodule ToweropsWeb.Router do
live "/devices/:id/graph/check", GraphLive.Show, :show
live "/devices/:id/graph/:sensor_type", GraphLive.Show, :show
live "/devices/:device_id/backups/compare", MikrotikBackupLive.Compare, :compare
live "/devices/:device_id/config-timeline", ConfigTimelineLive, :index
# Insights route
live "/insights", InsightsLive.Index, :index

View file

@ -0,0 +1,23 @@
defmodule Towerops.Repo.Migrations.CreateConfigChangeEvents do
use Ecto.Migration
def change do
create table(:config_change_events, 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 :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false
add :backup_before_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
add :backup_after_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all)
add :changed_at, :utc_datetime, null: false
add :diff_summary, :text
add :sections_changed, {:array, :string}, default: []
add :change_size, :integer, default: 0
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:config_change_events, [:device_id, :changed_at])
create index(:config_change_events, [:organization_id, :changed_at])
create index(:config_change_events, [:changed_at])
end
end

View file

@ -1,3 +1,12 @@
2026-02-13 — Config Change Performance Correlation
* New Config Timeline page for each device — see config changes overlaid with performance metrics
* Automatically detects when a config change may have impacted network performance
(latency, packet loss, throughput) by comparing before/after QoE metrics
* Config changes are auto-detected and categorized by section (firewall, routing, wireless, etc.)
* Dashboard now shows recent config changes across all devices with impact indicators
* Device overview shows recent config changes with quick links to timeline view
* Color-coded impact: green = no impact, yellow = minor changes, red = large changes
Devices Tested & Working
* Mikrotik RB5009UG+S+, CCR1009-7G-1C-1S+, CCR2004-16G-2S+
* Ubiquiti AC, LTU, AirFiber