towerops/lib/towerops_web/live/site_live/show.ex
Graham McIntire 3ca0834ef0 tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false`
across ~20 LiveViews, Oban workers, and sync modules so they're reachable from
unit tests. Refactored several `cond` blocks into idiomatic function heads with
guards. Added ~250 new test cases in new files under test/towerops and
test/towerops_web, including DB-backed tests for CnMaestro.Sync and
AlertNotificationWorker, and removed dead LiveView tab components and
CapacityLive (no callers anywhere in lib/test).

Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party
code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types,
Phoenix HTML modules, Inspect protocol impls) from coverage calculations —
these are not our project code.

Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
2026-04-24 09:49:06 -05:00

274 lines
9.2 KiB
Elixir

defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Capacity
alias Towerops.ConfigChanges
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Gaiia
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)
# Subscriber impact per device from Gaiia
site_impact =
try do
Gaiia.get_site_impact(site.id)
rescue
_ -> %{subscriber_count: 0, mrr: nil, devices: []}
end
# Build per-device impact lookup for device health table
device_impact = Map.new(site_impact.devices, fn d -> {d.device_id, d} end)
# Use site_impact as fallback when network site isn't mapped
site_summary = merge_impact_into_summary(site_summary, site_impact)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, devices)
|> assign(:site_impact, site_impact)
|> assign(:device_impact, device_impact)
|> 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)
|> assign(:capacity_summary, Capacity.get_site_capacity_summary(site.id))}
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 merge_impact_into_summary(summary, impact) do
if impact.subscriber_count > 0 do
%{summary | subscribers: impact.subscriber_count, mrr: impact.mrr}
else
summary
end
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)
@doc false
def qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
def qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
def qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
def qoe_color(_), do: "text-red-600 dark:text-red-400"
@doc false
def qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
def qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
def qoe_bg(score) when score >= 6.0,
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
def qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
@doc false
def capacity_bar_color(nil), do: "bg-gray-300"
def capacity_bar_color(score) when score >= 70, do: "bg-green-500"
def capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
def capacity_bar_color(_), do: "bg-red-500"
@doc false
def status_dot_class(:up), do: "bg-green-500"
def status_dot_class(:down), do: "bg-red-500"
def status_dot_class(_), do: "bg-gray-400"
@doc false
def time_ago(nil), do: ""
def time_ago(datetime) do
seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
format_seconds_ago(seconds)
end
defp format_seconds_ago(seconds) when seconds < 60, do: "#{seconds}s ago"
defp format_seconds_ago(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m ago"
defp format_seconds_ago(seconds) when seconds < 86_400, do: "#{div(seconds, 3600)}h ago"
defp format_seconds_ago(seconds), do: "#{div(seconds, 86_400)}d ago"
@doc false
def format_response_time(nil), do: ""
def format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms"
def format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms"
def format_response_time(_), do: ""
@doc false
def change_size_label(size) when size < 100, do: "Small"
def change_size_label(size) when size < 500, do: "Medium"
def change_size_label(_), do: "Large"
@doc false
def change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
def change_size_color(size) when size < 500,
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
def change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
@doc false
def insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
def insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
def insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
@doc false
def insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
def insight_urgency_icon("warning"), do: "hero-exclamation-circle"
def insight_urgency_icon(_), do: "hero-information-circle"
@doc false
def alert_severity(:device_down), do: "critical"
def alert_severity(:device_up), do: "info"
def alert_severity(_), do: "warning"
@doc false
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
def format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
def format_capacity(bps) when is_number(bps), do: "#{bps} bps"
def format_capacity(_), do: "-"
@doc false
def utilization_bar_color(pct) when pct >= 90, do: "bg-red-500"
def utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500"
def utilization_bar_color(_pct), do: "bg-green-500"
@doc false
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
# 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 -> run_discovery_if_exists(device_id) end)
else
# In dev/prod, enqueue to Oban
DiscoveryWorker.enqueue(device_id)
end
end
defp run_discovery_if_exists(device_id) do
case Devices.get_device(device_id) do
nil -> :ok
device -> Snmp.discover_device(device)
end
end
end