From 5a3ede1f710cd007aaba0d96cfeedf8b9d0b5d21 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Fri, 13 Feb 2026 17:56:43 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20config=20change=20=E2=86=92=20performan?= =?UTF-8?q?ce=20correlation=20system=20(Roadmap=20#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.txt | 31 ++ lib/towerops/config_changes.ex | 149 +++++++++ .../config_changes/config_change_event.ex | 44 +++ lib/towerops/config_changes/correlator.ex | 197 ++++++++++++ lib/towerops/preseem/insight.ex | 2 +- lib/towerops_web/channels/agent_channel.ex | 34 +- lib/towerops_web/live/config_timeline_live.ex | 290 ++++++++++++++++++ lib/towerops_web/live/dashboard_live.ex | 22 ++ .../live/dashboard_live.html.heex | 30 ++ lib/towerops_web/live/device_live/show.ex | 18 ++ .../live/device_live/show.html.heex | 40 +++ lib/towerops_web/router.ex | 1 + ...0213235031_create_config_change_events.exs | 23 ++ priv/static/changelog.txt | 9 + 14 files changed, 888 insertions(+), 2 deletions(-) create mode 100644 lib/towerops/config_changes.ex create mode 100644 lib/towerops/config_changes/config_change_event.ex create mode 100644 lib/towerops/config_changes/correlator.ex create mode 100644 lib/towerops_web/live/config_timeline_live.ex create mode 100644 priv/repo/migrations/20260213235031_create_config_change_events.exs diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 27a8e41a..5790cb54 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -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) diff --git a/lib/towerops/config_changes.ex b/lib/towerops/config_changes.ex new file mode 100644 index 00000000..6dcd119f --- /dev/null +++ b/lib/towerops/config_changes.ex @@ -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 diff --git a/lib/towerops/config_changes/config_change_event.ex b/lib/towerops/config_changes/config_change_event.ex new file mode 100644 index 00000000..fb6d47fa --- /dev/null +++ b/lib/towerops/config_changes/config_change_event.ex @@ -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 diff --git a/lib/towerops/config_changes/correlator.ex b/lib/towerops/config_changes/correlator.ex new file mode 100644 index 00000000..15bae3f5 --- /dev/null +++ b/lib/towerops/config_changes/correlator.ex @@ -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 diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index d62e9e81..3ca98be4 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -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) diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 46490dea..2ef6eaef 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -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? diff --git a/lib/towerops_web/live/config_timeline_live.ex b/lib/towerops_web/live/config_timeline_live.ex new file mode 100644 index 00000000..8cc31890 --- /dev/null +++ b/lib/towerops_web/live/config_timeline_live.ex @@ -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""" +
+
+
+

Config Timeline

+

+ <.link navigate={~p"/devices/#{@device.id}?tab=overview"} class="link link-hover"> + {@device.name} + + — Config changes vs performance metrics +

+
+
+ +
+
+ + <%!-- Timeline chart area --%> +
+
+

Performance & Config Changes

+ + <%!-- QoE metrics chart placeholder — rendered via hook --%> +
+
+ Loading chart... +
+
+
+
+ + <%!-- Change events list --%> +
+
+

Config Changes ({length(@change_events)})

+ + <%= if Enum.empty?(@change_events) do %> +

No config changes in this period.

+ <% else %> +
+ + + + + + + + + + + + + + + + + +
WhenSectionsSize
+ {Calendar.strftime(event.changed_at, "%b %d, %H:%M UTC")} + + + {section} + + + + {event.change_size} lines + + + <.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 + +
+
+ <% end %> +
+
+ + <%!-- Selected event detail modal --%> + <%= if @selected_event do %> +
+
+
+

+ Change Details — {Calendar.strftime(@selected_event.changed_at, "%b %d, %H:%M UTC")} +

+ +
+ +
+
+ Sections Changed +
+ + {s} + + Unknown +
+
+
+ Lines Changed +

{@selected_event.change_size}

+
+
+ <.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 → + +
+
+ + <%= if @selected_event.diff_summary do %> +
+ Diff Summary +
{@selected_event.diff_summary}
+
+ <% end %> +
+
+ <% end %> +
+ """ + 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 diff --git a/lib/towerops_web/live/dashboard_live.ex b/lib/towerops_web/live/dashboard_live.ex index 5ee5c0d0..87a73db6 100644 --- a/lib/towerops_web/live/dashboard_live.ex +++ b/lib/towerops_web/live/dashboard_live.ex @@ -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 diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index 0357f928..c4c82be5 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -390,6 +390,36 @@ <% end %> + <%!-- Recent Config Changes --%> + <%= if Enum.any?(@recent_config_changes) do %> +
+
+

+ <.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> + Recent Config Changes +

+
+ <%= 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" + > + +
+

+ {if change.device, do: change.device.name, else: "Unknown Device"} +

+

+ {Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join(Enum.take(change.sections_changed, 2), ", ")} +

+
+ + <% end %> +
+
+
+ <% end %> + <%!-- Right: Insights Feed (2 cols) --%>
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 402dda04..bccd7dc4 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 08555236..a7fa563c 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -228,6 +228,18 @@ <% 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" + > + + <.icon name="hero-clock" class="h-3.5 w-3.5 text-orange-500 dark:text-orange-400" /> + Config Timeline + + + <% end %> + <%= if @preseem_access_point do %> <.link patch={~p"/devices/#{@device.id}?tab=preseem"} @@ -586,6 +598,34 @@
<% end %> + + <%!-- Recent Config Changes mini-card --%> + <%= if @device.mikrotik_enabled && assigns[:recent_config_changes] && Enum.any?(@recent_config_changes) do %> +
+
+

+ <.icon name="hero-clock" class="h-4 w-4 text-orange-500" /> Recent Config Changes +

+ <.link navigate={~p"/devices/#{@device.id}/config-timeline"} class="text-xs text-blue-500 hover:underline"> + View Timeline → + +
+
+ <%= for event <- Enum.take(@recent_config_changes, 5) do %> +
+
+ + + {Calendar.strftime(event.changed_at, "%b %d %H:%M")} + + {s} +
+ {event.change_size} lines +
+ <% end %> +
+
+ <% end %>
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index f6cf347a..ae609fef 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260213235031_create_config_change_events.exs b/priv/repo/migrations/20260213235031_create_config_change_events.exs new file mode 100644 index 00000000..23cb867a --- /dev/null +++ b/priv/repo/migrations/20260213235031_create_config_change_events.exs @@ -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 diff --git a/priv/static/changelog.txt b/priv/static/changelog.txt index bbc96c55..3478318a 100644 --- a/priv/static/changelog.txt +++ b/priv/static/changelog.txt @@ -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