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:
parent
02f837ca5b
commit
5a3ede1f71
14 changed files with 888 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
149
lib/towerops/config_changes.ex
Normal file
149
lib/towerops/config_changes.ex
Normal 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
|
||||
44
lib/towerops/config_changes/config_change_event.ex
Normal file
44
lib/towerops/config_changes/config_change_event.ex
Normal 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
|
||||
197
lib/towerops/config_changes/correlator.ex
Normal file
197
lib/towerops/config_changes/correlator.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
290
lib/towerops_web/live/config_timeline_live.ex
Normal file
290
lib/towerops_web/live/config_timeline_live.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue