diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 5a1284af..30b35e6e 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -107,6 +107,14 @@ defmodule Towerops.Snmp.Discovery do {:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors) do update_equipment_discovery_time(equipment) Logger.info("SNMP discovery completed successfully for: #{equipment.name}") + + # Broadcast discovery completion for real-time updates + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "equipment:#{equipment.id}", + {:discovery_completed, equipment.id} + ) + {:ok, device} else {:error, reason} = error -> diff --git a/lib/towerops_web/live/equipment_live/show.ex b/lib/towerops_web/live/equipment_live/show.ex index 830de067..35a6fa93 100644 --- a/lib/towerops_web/live/equipment_live/show.ex +++ b/lib/towerops_web/live/equipment_live/show.ex @@ -4,6 +4,7 @@ defmodule ToweropsWeb.EquipmentLive.Show do alias Towerops.Equipment alias Towerops.Monitoring + alias Towerops.Snmp @impl true def mount(_params, _session, socket) do @@ -12,32 +13,31 @@ defmodule ToweropsWeb.EquipmentLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do - equipment = Equipment.get_equipment!(id) - checks = Monitoring.list_equipment_checks(id, 10) + if connected?(socket) do + Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}") + Process.send_after(self(), :refresh_data, 10_000) + end - # Subscribe to status updates for this equipment - _ = - if connected?(socket) do - Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}") - end - - {:noreply, - socket - |> assign(:page_title, equipment.name) - |> assign(:equipment, equipment) - |> assign(:recent_checks, checks)} + {:noreply, load_equipment_data(socket, id)} end @impl true - def handle_info({:equipment_status_changed, equipment_id, new_status, response_time}, socket) do - equipment = Equipment.get_equipment!(equipment_id) - checks = Monitoring.list_equipment_checks(equipment_id, 10) + def handle_info(:refresh_data, socket) do + Process.send_after(self(), :refresh_data, 10_000) + {:noreply, load_equipment_data(socket, socket.assigns.equipment.id)} + end + @impl true + def handle_info({:equipment_status_changed, _equipment_id, _new_status, _response_time}, socket) do + {:noreply, load_equipment_data(socket, socket.assigns.equipment.id)} + end + + @impl true + def handle_info({:discovery_completed, _equipment_id}, socket) do {:noreply, socket - |> assign(:equipment, equipment) - |> assign(:recent_checks, checks) - |> put_flash(:info, "Status updated: #{new_status} (#{response_time}ms)")} + |> load_equipment_data(socket.assigns.equipment.id) + |> put_flash(:info, "Discovery completed")} end @impl true @@ -59,4 +59,218 @@ defmodule ToweropsWeb.EquipmentLive.Show do _ = Towerops.Monitoring.EquipmentMonitor.trigger_check(socket.assigns.equipment.id) {:noreply, put_flash(socket, :info, "Check triggered")} end + + @impl true + def handle_event("trigger_discovery", _params, socket) do + equipment = socket.assigns.equipment + + if equipment.snmp_enabled do + # Run discovery in background + Task.start(fn -> + Snmp.discover_equipment(equipment) + end) + + {:noreply, put_flash(socket, :info, "Discovery started...")} + else + {:noreply, put_flash(socket, :error, "SNMP is not enabled for this equipment")} + end + end + + # Private functions + + defp load_equipment_data(socket, equipment_id) do + equipment = Equipment.get_equipment!(equipment_id) + recent_checks = Monitoring.list_equipment_checks(equipment_id, 50) + snmp_data = load_snmp_data(equipment_id) + + # Calculate metrics for dashboard + metrics = calculate_metrics(recent_checks, equipment) + + socket + |> assign(:page_title, equipment.name) + |> assign(:equipment, equipment) + |> assign(:recent_checks, recent_checks) + |> assign(:metrics, metrics) + |> assign(:snmp_device, snmp_data.device) + |> assign(:snmp_interfaces, snmp_data.interfaces) + |> assign(:snmp_sensors, snmp_data.sensors) + end + + defp calculate_metrics(checks, _equipment) do + total_checks = length(checks) + + if total_checks > 0 do + successful_checks = Enum.count(checks, &(&1.status == :success)) + uptime_percentage = Float.round(successful_checks / total_checks * 100, 1) + + avg_response_time = + checks + |> Enum.filter(&(&1.response_time_ms != nil)) + |> case do + [] -> + 0 + + valid_checks -> + sum = Enum.reduce(valid_checks, 0, &(&1.response_time_ms + &2)) + Float.round(sum / length(valid_checks), 1) + end + + %{ + uptime_percentage: uptime_percentage, + avg_response_time: avg_response_time, + total_checks: total_checks, + successful_checks: successful_checks + } + else + %{ + uptime_percentage: 0, + avg_response_time: 0, + total_checks: 0, + successful_checks: 0 + } + end + end + + defp format_speed(speed_bps) when is_integer(speed_bps) do + cond do + speed_bps >= 1_000_000_000 -> + "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps" + + speed_bps >= 1_000_000 -> + "#{Float.round(speed_bps / 1_000_000, 1)} Mbps" + + speed_bps >= 1_000 -> + "#{Float.round(speed_bps / 1_000, 1)} Kbps" + + true -> + "#{speed_bps} bps" + end + end + + defp format_speed(_), do: "-" + + defp format_sensor_value(value, divisor) when is_number(value) and is_number(divisor) do + result = value / divisor + Float.round(result, 1) + end + + defp format_sensor_value(_, _), do: "-" + + defp time_ago(nil), do: "Never" + + defp time_ago(datetime) do + diff = DateTime.diff(DateTime.utc_now(), datetime, :second) + + cond do + diff < 60 -> "#{diff}s ago" + diff < 3600 -> "#{div(diff, 60)}m ago" + diff < 86_400 -> "#{div(diff, 3600)}h ago" + true -> "#{div(diff, 86_400)}d ago" + end + end + + defp load_snmp_data(equipment_id) do + case Snmp.get_device_with_associations(equipment_id) do + nil -> + %{device: nil, interfaces: [], sensors: []} + + device -> + # Preload latest readings for sensors + sensors_with_readings = + Enum.map(device.sensors, fn sensor -> + latest_reading = Snmp.get_latest_sensor_reading(sensor.id) + Map.put(sensor, :latest_reading, latest_reading) + end) + + # Preload latest stats for interfaces + interfaces_with_stats = + Enum.map(device.interfaces, fn interface -> + latest_stat = Snmp.get_latest_interface_stat(interface.id) + Map.put(interface, :latest_stat, latest_stat) + end) + + %{ + device: device, + interfaces: interfaces_with_stats, + sensors: sensors_with_readings + } + end + end + + # SVG Chart helpers + + defp sparkline(checks, width, height) when is_list(checks) do + if Enum.empty?(checks) or length(checks) < 2 do + "" + else + # Get response times in reverse order (oldest to newest for left-to-right) + data = + checks + |> Enum.reverse() + |> Enum.map(fn check -> + if check.response_time_ms, do: check.response_time_ms, else: 0 + end) + + max_value = Enum.max(data, fn -> 1 end) + max_value = if max_value == 0, do: 1, else: max_value + data_count = length(data) + + points = + data + |> Enum.with_index() + |> Enum.map_join(" ", fn {value, index} -> + x = index * (width / (data_count - 1)) + y = height - value / max_value * height + "#{Float.round(x, 2)},#{Float.round(y, 2)}" + end) + + assigns = %{points: points, width: width, height: height, data: data} + + ~H""" + + """ + end + end + + defp uptime_gauge(percentage) do + assigns = %{percentage: percentage} + + color_class = + cond do + percentage >= 95 -> "bg-green-500" + percentage >= 80 -> "bg-yellow-500" + true -> "bg-red-500" + end + + assigns = Map.put(assigns, :color_class, color_class) + + ~H""" +
{@equipment.ip_address}
+| # | +Name | +Status | +Speed | +MAC | +
|---|---|---|---|---|
| {interface.if_index} | ++ {interface.if_name || interface.if_descr} + | ++ + {String.upcase(interface.if_oper_status || "unknown")} + + | ++ {format_speed(interface.if_speed)} + | ++ {interface.if_phys_address || "-"} + | +
| Status | +Response | +Time | +
|---|---|---|
| + + {check.status |> to_string() |> String.upcase()} + + | ++ <%= if check.response_time_ms do %> + {check.response_time_ms}ms + <% else %> + - + <% end %> + | ++ {Calendar.strftime(check.checked_at, "%H:%M:%S")} + | +