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) 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 < 86_400 -> "#{div(seconds, 3600)}h ago" true -> "#{div(seconds, 86_400)}d ago" end end defp format_response_time(nil), do: "—" defp format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms" defp format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms" defp format_response_time(_), do: "—" 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: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300" defp change_size_color(size) when size < 500, do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" defp change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" 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" defp alert_severity(:device_down), do: "critical" defp alert_severity(:device_up), do: "info" defp alert_severity(_), do: "warning" defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps" defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps" defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps" defp format_capacity(bps) when is_number(bps), do: "#{bps} bps" defp format_capacity(_), do: "-" defp utilization_bar_color(pct) when pct >= 90, do: "bg-red-500" defp utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500" defp utilization_bar_color(_pct), do: "bg-green-500" defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400" defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400" defp 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