293 lines
10 KiB
Elixir
293 lines
10 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.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"}
|
|
]} />
|
|
<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-gray-900/60 dark:text-gray-100/60">
|
|
<.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}
|
|
</.link>
|
|
— Config changes vs performance metrics
|
|
</p>
|
|
</div>
|
|
<div class="inline-flex">
|
|
<button
|
|
:for={r <- ["24h", "7d", "30d"]}
|
|
class={"btn text-sm !py-1 !px-3 #{if @range == r, do: "bg-red-500 text-white hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700", else: "bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700"}"}
|
|
phx-click="select_range"
|
|
phx-value-range={r}
|
|
>
|
|
{r}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Timeline chart area --%>
|
|
<div class="card shadow-sm">
|
|
<div class="p-4 sm:p-6">
|
|
<h2 class="text-lg font-semibold">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-gray-900/40 dark:text-gray-100/40">
|
|
Loading chart...
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Change events list --%>
|
|
<div class="card shadow-sm">
|
|
<div class="p-4 sm:p-6">
|
|
<h2 class="text-lg font-semibold">Config Changes ({length(@change_events)})</h2>
|
|
|
|
<%= if Enum.empty?(@change_events) do %>
|
|
<p class="text-gray-900/50 dark:text-gray-100/50 py-4">
|
|
No config changes in this period.
|
|
</p>
|
|
<% else %>
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full text-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-gray-100/50 dark:hover:bg-gray-800/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 text-gray-700 dark:text-gray-300 mr-1"
|
|
>
|
|
{section}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class={"badge bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300 #{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 text-xs !py-0.5 !px-2 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
>
|
|
View Diff
|
|
</.link>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- Selected event detail modal --%>
|
|
<%= if @selected_event do %>
|
|
<div class="card shadow-sm border border-red-500/20 dark:border-red-600/20">
|
|
<div class="p-4 sm:p-6">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">
|
|
Change Details — {Calendar.strftime(@selected_event.changed_at, "%b %d, %H:%M UTC")}
|
|
</h2>
|
|
<button
|
|
class="btn text-sm !py-1 !px-3 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700"
|
|
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-gray-900/60 dark:text-gray-100/60">
|
|
Sections Changed
|
|
</span>
|
|
<div class="mt-1">
|
|
<span
|
|
:for={s <- @selected_event.sections_changed}
|
|
class="badge border border-gray-300 bg-transparent dark:border-gray-600 mr-1"
|
|
>
|
|
{s}
|
|
</span>
|
|
<span
|
|
:if={Enum.empty?(@selected_event.sections_changed)}
|
|
class="text-gray-900/40 dark:text-gray-100/40"
|
|
>
|
|
Unknown
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="text-sm font-medium text-gray-900/60 dark:text-gray-100/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 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 →
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
|
|
<%= if @selected_event.diff_summary do %>
|
|
<div class="mt-4">
|
|
<span class="text-sm font-medium text-gray-900/60 dark:text-gray-100/60">
|
|
Diff Summary
|
|
</span>
|
|
<pre class="bg-gray-100 dark:bg-gray-800 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
|
|
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
|