towerops/lib/towerops_web/live/site_live/show.ex
Graham McIntie c512f30e5a feat: add device list health indicators
- Health status dot (green/yellow/red/gray) from worst-case check state
- Relative 'last seen' with color-coded staleness thresholds
- Response time badge from latest monitoring check
- Subscriber count badge from Gaiia inventory items
- Batch queries to avoid N+1: Monitoring.get_device_health_summary/1,
  Monitoring.get_device_latest_response_times/1,
  Gaiia.get_device_subscriber_counts/1
2026-02-13 18:14:29 -06:00

201 lines
6.6 KiB
Elixir

defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.ConfigChanges
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Preseem
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)
devices = Devices.list_site_devices(site.id)
device_ids = Enum.map(devices, & &1.id)
latency_chart_data = load_site_latency_chart_data(devices)
site_summary = Dashboard.get_site_summary(site.id)
# QoE summary from Preseem
qoe_summary = Preseem.get_site_qoe_summary(site.id)
# Config changes in last 30 days
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
config_changes = ConfigChanges.list_site_changes(site.id, after: thirty_days_ago, limit: 10)
# Active alerts
active_alerts = Alerts.list_site_active_alerts(site.id)
# Insights
insights = Preseem.Insights.list_site_insights(site.id, limit: 10)
# Device response times for health grid
response_times = Monitoring.get_device_latest_response_times(device_ids)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, devices)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:site_summary, site_summary)
|> assign(:qoe_summary, qoe_summary)
|> assign(:config_changes, config_changes)
|> assign(:active_alerts, active_alerts)
|> assign(:insights, insights)
|> assign(:response_times, response_times)}
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)
defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
defp qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
defp qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
defp qoe_color(_), do: "text-red-600 dark:text-red-400"
defp qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
defp qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
defp qoe_bg(score) when score >= 6.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
defp qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
defp capacity_bar_color(nil), do: "bg-gray-300"
defp capacity_bar_color(score) when score >= 70, do: "bg-green-500"
defp capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
defp capacity_bar_color(_), do: "bg-red-500"
defp status_dot_class(:up), do: "bg-green-500"
defp status_dot_class(:down), do: "bg-red-500"
defp status_dot_class(_), do: "bg-gray-400"
defp time_ago(nil), do: ""
defp time_ago(datetime) do
seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
seconds < 60 -> "#{seconds}s ago"
seconds < 3600 -> "#{div(seconds, 60)}m ago"
seconds < 86400 -> "#{div(seconds, 3600)}h ago"
true -> "#{div(seconds, 86400)}d ago"
end
end
defp format_response_time(nil), do: ""
defp format_response_time(ms) when ms < 1, do: "<1ms"
defp format_response_time(ms), do: "#{round(ms)}ms"
defp change_size_label(size) when size < 100, do: "Small"
defp change_size_label(size) when size < 500, do: "Medium"
defp change_size_label(_), do: "Large"
defp change_size_color(size) when size < 100, do: "badge-ghost"
defp change_size_color(size) when size < 500, do: "badge-warning"
defp change_size_color(_), do: "badge-error"
defp insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
defp insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
defp insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
defp insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
defp insight_urgency_icon("warning"), do: "hero-exclamation-circle"
defp insight_urgency_icon(_), do: "hero-information-circle"
# 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