towerops/lib/towerops_web/live/config_timeline_live.ex
Graham McIntie 5a3ede1f71 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
2026-02-13 17:56:55 -06:00

290 lines
9.4 KiB
Elixir

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