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""" + + + <%= for {value, idx} <- Enum.with_index(@data) do %> + 1 end) * @height} + r="2" + class={if value == 0, do: "fill-red-500", else: "fill-blue-500"} + /> + <% end %> + + """ + 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""" +
+
+
+
+ {@percentage}% +
+
+ """ + end end diff --git a/lib/towerops_web/live/equipment_live/show.html.heex b/lib/towerops_web/live/equipment_live/show.html.heex index e8f22f32..dc7aa059 100644 --- a/lib/towerops_web/live/equipment_live/show.html.heex +++ b/lib/towerops_web/live/equipment_live/show.html.heex @@ -3,157 +3,274 @@ current_organization={@current_organization} active_page="equipment" > - <.header> - {@page_title} - <:subtitle>{@equipment.ip_address} - <:actions> - <.button phx-click="trigger_check" variant="primary"> +
+
+

{@equipment.name}

+

{@equipment.ip_address}

+
+
+ <.button + phx-click="trigger_check" + class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100" + > <.icon name="hero-arrow-path" class="h-4 w-4" /> Check Now - <.button navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}> + <%= if @equipment.snmp_enabled do %> + <.button + phx-click="trigger_discovery" + class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100" + > + <.icon name="hero-magnifying-glass" class="h-4 w-4" /> Discover + + <% end %> + <.button + navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"} + class="text-sm bg-white dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100" + > <.icon name="hero-pencil" class="h-4 w-4" /> Edit <.button phx-click="delete" data-confirm="Are you sure?" - class="bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 dark:bg-red-500 dark:hover:bg-red-600" + class="text-sm bg-red-600 text-white hover:bg-red-700" > <.icon name="hero-trash" class="h-4 w-4" /> Delete - - - -
-
-

- Equipment Details -

-
-
-
Status
-
- - {@equipment.status |> to_string() |> String.upcase()} - -
-
- -
-
Site
-
- <.link - navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@equipment.site.id}"} - class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" - > - {@equipment.site.name} - -
-
- -
-
IP Address
-
{@equipment.ip_address}
-
- - <%= if @equipment.description do %> -
-
Description
-
{@equipment.description}
-
- <% end %> - -
-
Monitoring
-
- <%= if @equipment.monitoring_enabled do %> - - Enabled - - <% else %> - - Disabled - - <% end %> -
-
- -
-
Check Interval
-
- {@equipment.check_interval_seconds} seconds -
-
-
-
- -
-

- Monitoring History -

-
-
-
Last Checked
-
- <%= if @equipment.last_checked_at do %> - {Calendar.strftime(@equipment.last_checked_at, "%Y-%m-%d %H:%M:%S UTC")} - <% else %> - Never - <% end %> -
-
- -
-
Last Status Change
-
- <%= if @equipment.last_status_change_at do %> - {Calendar.strftime(@equipment.last_status_change_at, "%Y-%m-%d %H:%M:%S UTC")} - <% else %> - Never - <% end %> -
-
-
+ +
+
+
Status
+
+ + + + {@equipment.status |> to_string() |> String.upcase()} + +
+
-
-

Recent Checks

- <%= if Enum.empty?(@recent_checks) do %> -
+
+
Uptime
+
+ {@metrics.uptime_percentage}% +
+
+ +
+
Avg Response
+
+ {@metrics.avg_response_time}ms +
+
+ +
+
Last Check
+
+ {time_ago(@equipment.last_checked_at)} +
+
+
+ +
+ +
+

Response Time

+
+ {sparkline(@recent_checks, 400, 80)} +
+
+ +
+

+ Availability (last {@metrics.total_checks} checks) +

+
+ {uptime_gauge(@metrics.uptime_percentage)} +
+
+ {@metrics.successful_checks} successful + {@metrics.total_checks - @metrics.successful_checks} failed +
+
+
+ <%= if @snmp_device do %> + +
+

+ Device Information +

+
+ <%= if @snmp_device.manufacturer do %> +
+ Manufacturer: + + {@snmp_device.manufacturer} + +
+ <% end %> + <%= if @snmp_device.model do %> +
+ Model: + + {@snmp_device.model} + +
+ <% end %> + <%= if @snmp_device.firmware_version do %> +
+ Firmware: + + {@snmp_device.firmware_version} + +
+ <% end %> + <%= if @equipment.last_discovery_at do %> +
+ Last Discovery: + + {Calendar.strftime(@equipment.last_discovery_at, "%Y-%m-%d %H:%M")} + +
+ <% end %> +
+ <%= if @snmp_device.sys_descr do %> +
+ {@snmp_device.sys_descr} +
+ <% end %> +
+ <% end %> + <%= if @snmp_sensors && length(@snmp_sensors) > 0 do %> + +
+

Sensors

+
+ <%= for sensor <- @snmp_sensors do %> +
+
+ {sensor.sensor_descr} +
+ <%= if sensor.latest_reading do %> +
+ + {format_sensor_value(sensor.latest_reading.value, sensor.sensor_divisor)} + + {sensor.sensor_unit} +
+ <% else %> +
No data
+ <% end %> +
+ <% end %> +
+
+ <% end %> + <%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %> + +
+

+ Network Interfaces +

+
+ + + + + + + + + + + + <%= for interface <- @snmp_interfaces do %> + + + + + + + + <% end %> + +
#NameStatusSpeedMAC
{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 || "-"} +
+
+
+ <% end %> + +
+

Recent Checks

+ <%= if Enum.empty?(Enum.take(@recent_checks, 10)) do %> +
No monitoring checks yet
<% else %> - <.table id="recent-checks" rows={@recent_checks}> - <:col :let={check} label="Status"> - - {check.status |> to_string() |> String.upcase()} - - - <:col :let={check} label="Response Time"> - <%= if check.response_time_ms do %> - {check.response_time_ms}ms - <% else %> - - - <% end %> - - <:col :let={check} label="Checked At"> - {Calendar.strftime(check.checked_at, "%Y-%m-%d %H:%M:%S UTC")} - - +
+ + + + + + + + + + <%= for check <- Enum.take(@recent_checks, 10) do %> + + + + + + <% end %> + +
StatusResponseTime
+ + {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")} +
+
<% end %>
diff --git a/test/towerops_web/live/equipment_live_test.exs b/test/towerops_web/live/equipment_live_test.exs index 343c989c..456d7205 100644 --- a/test/towerops_web/live/equipment_live_test.exs +++ b/test/towerops_web/live/equipment_live_test.exs @@ -79,25 +79,27 @@ defmodule ToweropsWeb.EquipmentLiveTest do assert html =~ equipment.name assert html =~ "192.168.1.1" - assert html =~ "Main router" + assert html =~ "Status" + assert html =~ "Uptime" end - test "displays monitoring status", %{ + test "displays monitoring metrics", %{ conn: conn, organization: organization, equipment: equipment } do {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") - assert html =~ "Monitoring" - assert html =~ "Enabled" - assert html =~ "300 seconds" + assert html =~ "Status" + assert html =~ "Uptime" + assert html =~ "Avg Response" end - test "displays site link", %{conn: conn, organization: organization, equipment: equipment} do + test "displays dashboard widgets", %{conn: conn, organization: organization, equipment: equipment} do {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") - assert html =~ "Test Site" + assert html =~ "Response Time" + assert html =~ "Availability" end test "displays recent checks", %{