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.Monitoring alias Towerops.Preseem alias ToweropsWeb.Live.Helpers.AccessControl @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 = case Devices.get_device(device_id) do nil -> raise Ecto.NoResultsError, queryable: Towerops.Devices.DeviceSchema device -> device end {:ok, assign(socket, device: device, page_title: "Config Timeline — #{device.name}")} {:error, _} -> {:ok, socket |> put_flash(:error, t("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""" <.breadcrumb items={[ %{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Devices", navigate: ~p"/devices"}, %{label: @device.name, navigate: ~p"/devices/#{@device.id}"}, %{label: "Config Timeline"} ]} />

Config Timeline

<.link navigate={~p"/devices/#{@device.id}?tab=overview"} class="text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300" > {@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 %>
When Sections Size
{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 text-xs !py-0.5 !px-2 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700" > 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 text-sm !py-1 !px-3 bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700" > 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 Preseem.get_device_qoe_data(device_id, since) end defp load_check_data(device_id, since) do Monitoring.get_device_check_data(device_id, since) end @doc false def timeline_event(event) do %{ id: event.id, t: DateTime.to_iso8601(event.changed_at), sections: event.sections_changed, size: event.change_size } end @doc false def change_size_class(size) when is_integer(size) and size > 50, do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" def change_size_class(size) when is_integer(size) and size > 20, do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" def change_size_class(_), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" end