towerops/lib/towerops_web/live/site_live/show.ex
Graham McIntire 20f5f48372
feat: command center dashboard with unified insights and contextual enrichment
Transform the dashboard into a single-pane-of-glass command center that
blends operational metrics with business context from Gaiia subscriber data.

- Extend insight schema with multi-source support (snmp, gaiia, system)
- Add Oban workers for automated insight generation (device health, system, gaiia)
- New Dashboard context with health score computation and site summaries
- Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid
- Add source filter pills for insights with URL state management
- Add metrics bar to site detail pages (device count, alerts, subscribers, MRR)
- Add subscriber/MRR context to alert list site links
- Add subscriber badges to device list site headers
- Real-time PubSub updates for alerts on dashboard
2026-02-13 14:52:49 -06:00

124 lines
3.4 KiB
Elixir

defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
organization = socket.assigns.current_scope.organization
site = Sites.get_organization_site!(organization.id, id)
device = Devices.list_site_devices(site.id)
latency_chart_data = load_site_latency_chart_data(device)
site_summary = Dashboard.get_site_summary(site.id)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, device)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:site_summary, site_summary)}
end
@impl true
def handle_event("force_rediscover_all", _params, socket) do
devices = socket.assigns.device
snmp_devices = Enum.filter(devices, & &1.snmp_enabled)
if Enum.empty?(snmp_devices) do
{:noreply, put_flash(socket, :error, t_equipment("No SNMP-enabled devices at this site"))}
else
# Enqueue discovery for all SNMP-enabled devices
Enum.each(snmp_devices, fn device ->
enqueue_discovery(device.id)
end)
count = length(snmp_devices)
message =
t_equipment("Discovery started for %{count} device", count: count) <>
if(count == 1, do: "", else: "s")
{:noreply, put_flash(socket, :info, message)}
end
end
defp load_site_latency_chart_data(devices) do
if Enum.empty?(devices) do
nil
else
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
datasets =
devices
|> Enum.map(fn device ->
checks =
device.id
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
|> Enum.reverse()
%{
label: device.name,
data: Enum.map(checks, &latency_check_to_data_point/1)
}
end)
|> Enum.reject(fn dataset -> Enum.empty?(dataset.data) end)
if Enum.empty?(datasets) do
nil
else
Jason.encode!(%{datasets: datasets})
end
end
end
defp latency_check_to_data_point(check) do
%{
x: DateTime.to_unix(check.checked_at, :millisecond),
y: check.response_time_ms
}
end
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = amount) do
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
end
defp format_mrr(amount) when is_number(amount), do: "$#{format_number(round(amount))}"
defp format_mrr(_), do: "$0"
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
# Enqueue discovery job - safe to call in test environment
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
else
# In dev/prod, enqueue to Oban
DiscoveryWorker.enqueue(device_id)
end
end
end