From 139a42531f5ac19f7e3baec8785805496a6d6c3b Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 6 Jan 2026 13:55:16 -0600 Subject: [PATCH] Refactor credo issues: reduce complexity and nesting depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor check_threshold_violation: extract threshold checks into separate functions (complexity 10→~3) - Refactor load_sensor_chart_data: extract helper functions to reduce nesting - Refactor save_equipment: extract SNMP discovery logic into helpers - Refactor poll_sensors: extract error handling and result processing - Refactor handle_status_change: extract equipment down/up handlers - Refactor mount_current_scope: extract session scope building - Refactor load_traffic_chart_data: extract aggregation and calculation helpers - Refactor load_overall_traffic_chart_data: similar pattern to traffic chart - Refactor load_sensor_chart_data in equipment_live/show: extract dataset builders - Refactor discover_storage_sensors: extract storage sensor building - Refactor perform_poll: extract device polling logic into separate functions All 254 tests passing. Credo reports no issues. --- lib/towerops/monitoring/equipment_monitor.ex | 135 ++++--- lib/towerops/snmp/poller_worker.ex | 381 +++++++++++-------- lib/towerops/snmp/profiles/mikrotik.ex | 96 ++--- lib/towerops_web/live/equipment_live/form.ex | 54 ++- lib/towerops_web/live/equipment_live/show.ex | 229 +++++------ lib/towerops_web/live/graph_live/show.ex | 230 +++++------ lib/towerops_web/user_auth.ex | 71 ++-- 7 files changed, 652 insertions(+), 544 deletions(-) diff --git a/lib/towerops/monitoring/equipment_monitor.ex b/lib/towerops/monitoring/equipment_monitor.ex index 1c36d01d..7999c035 100644 --- a/lib/towerops/monitoring/equipment_monitor.ex +++ b/lib/towerops/monitoring/equipment_monitor.ex @@ -135,82 +135,79 @@ defmodule Towerops.Monitoring.EquipmentMonitor do case {old_status, new_status} do {_, :down} -> - # Equipment went down - create alert if one doesn't exist - if !Alerts.has_active_alert?(equipment.id, :equipment_down) do - alert_message = - if equipment.snmp_enabled do - "Equipment is not responding to SNMP" - else - "Equipment is not responding to ping" - end - - {:ok, _alert} = - Alerts.create_alert(%{ - equipment_id: equipment.id, - alert_type: :equipment_down, - triggered_at: now, - message: alert_message - }) - - # Send email notification in background (not in test environment) - # _ = - # if !test_env?() do - # Task.start(fn -> - # Alerts.send_alert_notification(alert) - # end) - # end - - # Broadcast alert - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "alerts:new", - {:new_alert, equipment.id, :equipment_down} - ) - end + handle_equipment_down(equipment, now) {_, :up} -> - # Equipment came back up - create recovery alert and resolve down alert - recovery_message = - if equipment.snmp_enabled do - "Equipment is now responding to SNMP" - else - "Equipment is now responding to ping" - end + handle_equipment_up(equipment, now) + end + end - {:ok, _alert} = - Alerts.create_alert(%{ - equipment_id: equipment.id, - alert_type: :equipment_up, - triggered_at: now, - message: recovery_message - }) + defp handle_equipment_down(equipment, now) do + if Alerts.has_active_alert?(equipment.id, :equipment_down) do + :ok + else + create_equipment_down_alert(equipment, now) + end + end - # Send email notification in background (not in test environment) - # _ = - # if !test_env?() do - # Task.start(fn -> - # Alerts.send_alert_notification(alert) - # end) - # end + defp create_equipment_down_alert(equipment, now) do + alert_message = get_down_alert_message(equipment) - # Resolve any active equipment_down alerts - _ = - case Alerts.get_active_alert(equipment.id, :equipment_down) do - nil -> - :ok + {:ok, _alert} = + Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_down, + triggered_at: now, + message: alert_message + }) - alert -> - Alerts.resolve_alert(alert) - end + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:new", + {:new_alert, equipment.id, :equipment_down} + ) + end - # Broadcast recovery - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "alerts:resolved", - {:alert_resolved, equipment.id, :equipment_down} - ) + defp get_down_alert_message(equipment) do + if equipment.snmp_enabled do + "Equipment is not responding to SNMP" + else + "Equipment is not responding to ping" + end + end + + defp handle_equipment_up(equipment, now) do + recovery_message = get_recovery_message(equipment) + + {:ok, _alert} = + Alerts.create_alert(%{ + equipment_id: equipment.id, + alert_type: :equipment_up, + triggered_at: now, + message: recovery_message + }) + + resolve_down_alert(equipment) + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:resolved", + {:alert_resolved, equipment.id, :equipment_down} + ) + end + + defp get_recovery_message(equipment) do + if equipment.snmp_enabled do + "Equipment is now responding to SNMP" + else + "Equipment is now responding to ping" + end + end + + defp resolve_down_alert(equipment) do + case Alerts.get_active_alert(equipment.id, :equipment_down) do + nil -> :ok + alert -> Alerts.resolve_alert(alert) end end diff --git a/lib/towerops/snmp/poller_worker.ex b/lib/towerops/snmp/poller_worker.ex index 86e9238c..be87a08c 100644 --- a/lib/towerops/snmp/poller_worker.ex +++ b/lib/towerops/snmp/poller_worker.ex @@ -14,6 +14,7 @@ defmodule Towerops.Snmp.PollerWorker do alias Towerops.Snmp alias Towerops.Snmp.Client alias Towerops.Snmp.PollerRegistry + alias Towerops.Snmp.Sensor require Logger @@ -96,126 +97,133 @@ defmodule Towerops.Snmp.PollerWorker do equipment = Equipment.get_equipment!(equipment_id) if equipment.snmp_enabled do - device = Snmp.get_device_with_associations(equipment_id) - - if device do - # Check if recently polled by another pod (distributed coordination) - poll_interval = get_poll_interval(equipment) - grace_period = 5 - - if should_skip_poll?(equipment, poll_interval, grace_period) do - Logger.debug("Skipping poll for #{equipment.name} - recently polled by another process") - # Update poll timestamp before polling (optimistic locking) - - # Poll all sensors - - # Poll all interfaces - else - Equipment.update_snmp_poll_time(equipment) - - client_opts = build_client_opts(equipment) - now = DateTime.truncate(DateTime.utc_now(), :second) - - try do - poll_sensors(device.sensors, client_opts, now) - Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}") - rescue - error -> - Logger.error( - "Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" - ) - end - - try do - poll_interfaces(device.interfaces, client_opts, now) - Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}") - rescue - error -> - Logger.error( - "Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" - ) - end - - try do - check_interface_changes(device.interfaces, equipment, client_opts, now) - rescue - error -> - Logger.error( - "Error checking interface changes for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" - ) - end - end - - # Schedule next poll regardless of whether we polled or skipped - schedule_next_poll(poll_interval) - end + poll_equipment_device(equipment_id, equipment) end end + defp poll_equipment_device(equipment_id, equipment) do + device = Snmp.get_device_with_associations(equipment_id) + + if device do + poll_interval = get_poll_interval(equipment) + execute_device_poll(device, equipment, poll_interval) + schedule_next_poll(poll_interval) + end + end + + defp execute_device_poll(device, equipment, poll_interval) do + if should_skip_poll?(equipment, poll_interval, 5) do + Logger.debug("Skipping poll for #{equipment.name} - recently polled by another process") + else + perform_device_data_collection(device, equipment) + end + end + + defp perform_device_data_collection(device, equipment) do + Equipment.update_snmp_poll_time(equipment) + + client_opts = build_client_opts(equipment) + now = DateTime.truncate(DateTime.utc_now(), :second) + + poll_device_sensors(device, equipment, client_opts, now) + poll_device_interfaces(device, equipment, client_opts, now) + check_device_interface_changes(device, equipment, client_opts, now) + end + + defp poll_device_sensors(device, equipment, client_opts, now) do + poll_sensors(device.sensors, client_opts, now) + Logger.debug("Polled #{length(device.sensors)} sensors for #{equipment.name}") + rescue + error -> + Logger.error("Error polling sensors for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + end + + defp poll_device_interfaces(device, equipment, client_opts, now) do + poll_interfaces(device.interfaces, client_opts, now) + Logger.debug("Polled #{length(device.interfaces)} interfaces for #{equipment.name}") + rescue + error -> + Logger.error("Error polling interfaces for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + end + + defp check_device_interface_changes(device, equipment, client_opts, now) do + check_interface_changes(device.interfaces, equipment, client_opts, now) + rescue + error -> + Logger.error( + "Error checking interface changes for #{equipment.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" + ) + end + defp poll_sensors(sensors, client_opts, timestamp) do Enum.each(sensors, fn sensor -> - # Check if sensor requires special calculation (e.g., storage percentage) - result = - if sensor.metadata["calculation"] == "percentage" do - poll_percentage_sensor(sensor, client_opts) - else - poll_simple_sensor(sensor, client_opts) - end - - case result do - {:ok, value} -> - Snmp.create_sensor_reading(%{ - sensor_id: sensor.id, - value: value, - status: "ok", - checked_at: timestamp - }) - - # Check for sensor value changes and threshold violations - detect_sensor_changes(sensor, value, timestamp) - - # Update sensor's last value and timestamp - Snmp.update_sensor(sensor, %{ - last_value: value, - last_checked_at: timestamp - }) - - {:error, :non_numeric} -> - Logger.debug("Non-numeric SNMP value for sensor #{sensor.sensor_descr}") - - Snmp.create_sensor_reading(%{ - sensor_id: sensor.id, - value: nil, - status: "unknown", - checked_at: timestamp - }) - - {:error, reason} -> - # Only log warnings for actual communication errors, not missing OIDs - case reason do - :no_such_object -> - Logger.debug("Sensor #{sensor.sensor_descr} OID not found on device") - - :no_such_instance -> - Logger.debug("Sensor #{sensor.sensor_descr} instance not found on device") - - :end_of_mib_view -> - Logger.debug("Sensor #{sensor.sensor_descr} end of MIB view") - - _ -> - Logger.warning("Failed to poll sensor #{sensor.sensor_descr}: #{inspect(reason)}") - end - - Snmp.create_sensor_reading(%{ - sensor_id: sensor.id, - value: nil, - status: "error", - checked_at: timestamp - }) - end + result = poll_sensor_value(sensor, client_opts) + handle_sensor_poll_result(sensor, result, timestamp) end) end + defp poll_sensor_value(sensor, client_opts) do + if sensor.metadata["calculation"] == "percentage" do + poll_percentage_sensor(sensor, client_opts) + else + poll_simple_sensor(sensor, client_opts) + end + end + + defp handle_sensor_poll_result(sensor, {:ok, value}, timestamp) do + Snmp.create_sensor_reading(%{ + sensor_id: sensor.id, + value: value, + status: "ok", + checked_at: timestamp + }) + + detect_sensor_changes(sensor, value, timestamp) + + Snmp.update_sensor(sensor, %{ + last_value: value, + last_checked_at: timestamp + }) + end + + defp handle_sensor_poll_result(sensor, {:error, :non_numeric}, timestamp) do + Logger.debug("Non-numeric SNMP value for sensor #{sensor.sensor_descr}") + + Snmp.create_sensor_reading(%{ + sensor_id: sensor.id, + value: nil, + status: "unknown", + checked_at: timestamp + }) + end + + defp handle_sensor_poll_result(sensor, {:error, reason}, timestamp) do + log_sensor_error(sensor, reason) + + Snmp.create_sensor_reading(%{ + sensor_id: sensor.id, + value: nil, + status: "error", + checked_at: timestamp + }) + end + + defp log_sensor_error(sensor, :no_such_object) do + Logger.debug("Sensor #{sensor.sensor_descr} OID not found on device") + end + + defp log_sensor_error(sensor, :no_such_instance) do + Logger.debug("Sensor #{sensor.sensor_descr} instance not found on device") + end + + defp log_sensor_error(sensor, :end_of_mib_view) do + Logger.debug("Sensor #{sensor.sensor_descr} end of MIB view") + end + + defp log_sensor_error(sensor, reason) do + Logger.warning("Failed to poll sensor #{sensor.sensor_descr}: #{inspect(reason)}") + end + defp poll_simple_sensor(sensor, client_opts) do case Client.get(client_opts, sensor.sensor_oid) do {:ok, raw_value} -> @@ -605,7 +613,7 @@ defmodule Towerops.Snmp.PollerWorker do end end - @spec detect_sensor_changes(Towerops.Snmp.Sensor.t(), float(), DateTime.t()) :: :ok + @spec detect_sensor_changes(Sensor.t(), float(), DateTime.t()) :: :ok defp detect_sensor_changes(sensor, current_value, timestamp) do # Skip change detection if there's no previous value to compare against if sensor.last_value == nil do @@ -644,58 +652,111 @@ defmodule Towerops.Snmp.PollerWorker do if threshold_event, do: [threshold_event | events], else: events end + @spec check_threshold_violation( + Sensor.t(), + float(), + DateTime.t(), + Ecto.UUID.t(), + map() + ) :: map() | nil defp check_threshold_violation(sensor, current_value, timestamp, equipment_id, thresholds) do - cond do - thresholds.critical_high && current_value >= thresholds.critical_high -> - build_threshold_event( - sensor, - current_value, - timestamp, - equipment_id, - "critical_high", - thresholds.critical_high, - "critical", - "critically high" - ) + threshold_checks = [ + &check_critical_high/5, + &check_critical_low/5, + &check_warning_high/5, + &check_warning_low/5 + ] - thresholds.critical_low && current_value <= thresholds.critical_low -> - build_threshold_event( - sensor, - current_value, - timestamp, - equipment_id, - "critical_low", - thresholds.critical_low, - "critical", - "critically low" - ) + Enum.find_value(threshold_checks, fn check_fn -> + check_fn.(sensor, current_value, timestamp, equipment_id, thresholds) + end) || check_returned_to_normal(sensor, current_value, timestamp, equipment_id, thresholds) + end - thresholds.warning_high && current_value >= thresholds.warning_high -> - build_threshold_event( - sensor, - current_value, - timestamp, - equipment_id, - "warning_high", - thresholds.warning_high, - "warning", - "high" - ) + @spec check_critical_high( + Sensor.t(), + float(), + DateTime.t(), + Ecto.UUID.t(), + map() + ) :: map() | nil + defp check_critical_high(sensor, current_value, timestamp, equipment_id, thresholds) do + if thresholds.critical_high && current_value >= thresholds.critical_high do + build_threshold_event( + sensor, + current_value, + timestamp, + equipment_id, + "critical_high", + thresholds.critical_high, + "critical", + "critically high" + ) + end + end - thresholds.warning_low && current_value <= thresholds.warning_low -> - build_threshold_event( - sensor, - current_value, - timestamp, - equipment_id, - "warning_low", - thresholds.warning_low, - "warning", - "low" - ) + @spec check_critical_low( + Sensor.t(), + float(), + DateTime.t(), + Ecto.UUID.t(), + map() + ) :: map() | nil + defp check_critical_low(sensor, current_value, timestamp, equipment_id, thresholds) do + if thresholds.critical_low && current_value <= thresholds.critical_low do + build_threshold_event( + sensor, + current_value, + timestamp, + equipment_id, + "critical_low", + thresholds.critical_low, + "critical", + "critically low" + ) + end + end - true -> - check_returned_to_normal(sensor, current_value, timestamp, equipment_id, thresholds) + @spec check_warning_high( + Sensor.t(), + float(), + DateTime.t(), + Ecto.UUID.t(), + map() + ) :: map() | nil + defp check_warning_high(sensor, current_value, timestamp, equipment_id, thresholds) do + if thresholds.warning_high && current_value >= thresholds.warning_high do + build_threshold_event( + sensor, + current_value, + timestamp, + equipment_id, + "warning_high", + thresholds.warning_high, + "warning", + "high" + ) + end + end + + @spec check_warning_low( + Sensor.t(), + float(), + DateTime.t(), + Ecto.UUID.t(), + map() + ) :: map() | nil + defp check_warning_low(sensor, current_value, timestamp, equipment_id, thresholds) do + if thresholds.warning_low && current_value <= thresholds.warning_low do + build_threshold_event( + sensor, + current_value, + timestamp, + equipment_id, + "warning_low", + thresholds.warning_low, + "warning", + "low" + ) end end diff --git a/lib/towerops/snmp/profiles/mikrotik.ex b/lib/towerops/snmp/profiles/mikrotik.ex index 3bab59e2..413b8e8b 100644 --- a/lib/towerops/snmp/profiles/mikrotik.ex +++ b/lib/towerops/snmp/profiles/mikrotik.ex @@ -247,58 +247,66 @@ defmodule Towerops.Snmp.Profiles.Mikrotik do end defp discover_storage_sensors(client_opts) do - # Walk hrStorageTable to get memory and disk usage with {:ok, descr_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.3"), {:ok, size_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.5"), {:ok, used_results} <- Client.walk(client_opts, "1.3.6.1.2.1.25.2.3.1.6") do - # Get indices - indices = - descr_results - |> Map.keys() - |> Enum.map(fn oid -> oid |> String.split(".") |> List.last() end) - - indices - |> Enum.map(fn index -> - descr_oid = "1.3.6.1.2.1.25.2.3.1.3.#{index}" - size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{index}" - used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{index}" - - descr = Map.get(descr_results, descr_oid, "") - size = Map.get(size_results, size_oid, 0) - used = Map.get(used_results, used_oid, 0) - - if size > 0 do - percent = used / size * 100 - - type = - cond do - String.contains?(descr, "memory") -> "memory" - String.contains?(descr, "disk") -> "disk" - true -> "storage" - end - - %{ - sensor_type: "#{type}_usage", - sensor_index: index, - sensor_oid: used_oid, - sensor_descr: descr, - sensor_unit: "%", - sensor_divisor: 1, - last_value: percent, - status: storage_status(type, percent), - metadata: %{ - size_oid: size_oid, - calculation: "percentage" - } - } - end - end) - |> Enum.reject(&is_nil/1) + indices = extract_storage_indices(descr_results) + build_storage_sensors(indices, descr_results, size_results, used_results) else _ -> [] end end + defp extract_storage_indices(descr_results) do + descr_results + |> Map.keys() + |> Enum.map(fn oid -> oid |> String.split(".") |> List.last() end) + end + + defp build_storage_sensors(indices, descr_results, size_results, used_results) do + indices + |> Enum.map(&build_storage_sensor(&1, descr_results, size_results, used_results)) + |> Enum.reject(&is_nil/1) + end + + defp build_storage_sensor(index, descr_results, size_results, used_results) do + descr_oid = "1.3.6.1.2.1.25.2.3.1.3.#{index}" + size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{index}" + used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{index}" + + descr = Map.get(descr_results, descr_oid, "") + size = Map.get(size_results, size_oid, 0) + used = Map.get(used_results, used_oid, 0) + + if size > 0 do + percent = used / size * 100 + type = determine_storage_type(descr) + + %{ + sensor_type: "#{type}_usage", + sensor_index: index, + sensor_oid: used_oid, + sensor_descr: descr, + sensor_unit: "%", + sensor_divisor: 1, + last_value: percent, + status: storage_status(type, percent), + metadata: %{ + size_oid: size_oid, + calculation: "percentage" + } + } + end + end + + defp determine_storage_type(descr) do + cond do + String.contains?(descr, "memory") -> "memory" + String.contains?(descr, "disk") -> "disk" + true -> "storage" + end + end + defp discover_poe_sensors(client_opts) do # Walk POE table to get per-port POE statistics # Table columns: Name(3), Status(4), Voltage(5), Current(6), Power(7) diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex index 4793fd60..1b1daa39 100644 --- a/lib/towerops_web/live/equipment_live/form.ex +++ b/lib/towerops_web/live/equipment_live/form.ex @@ -132,14 +132,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do defp save_equipment(socket, :new, equipment_params) do case Equipment.create_equipment(equipment_params) do {:ok, equipment} -> - # Start SNMP discovery in background if enabled - flash_message = - if equipment.snmp_enabled do - Task.start(fn -> Snmp.discover_equipment(equipment) end) - "Equipment created successfully. SNMP discovery started in background." - else - "Equipment created successfully" - end + flash_message = handle_equipment_creation(equipment) {:noreply, socket @@ -156,19 +149,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do case Equipment.update_equipment(old_equipment, equipment_params) do {:ok, equipment} -> - # Start SNMP discovery if SNMP was just enabled or configuration changed - flash_message = - if equipment.snmp_enabled and - (!old_equipment.snmp_enabled or - equipment.snmp_community != old_equipment.snmp_community or - equipment.snmp_version != old_equipment.snmp_version or - equipment.snmp_port != old_equipment.snmp_port or - equipment.ip_address != old_equipment.ip_address) do - Task.start(fn -> Snmp.discover_equipment(equipment) end) - "Equipment updated successfully. SNMP discovery started in background." - else - "Equipment updated successfully" - end + flash_message = handle_equipment_update(old_equipment, equipment) {:noreply, socket @@ -180,6 +161,37 @@ defmodule ToweropsWeb.EquipmentLive.Form do end end + defp handle_equipment_creation(equipment) do + if equipment.snmp_enabled do + Task.start(fn -> Snmp.discover_equipment(equipment) end) + "Equipment created successfully. SNMP discovery started in background." + else + "Equipment created successfully" + end + end + + defp handle_equipment_update(old_equipment, equipment) do + if should_trigger_snmp_discovery?(old_equipment, equipment) do + Task.start(fn -> Snmp.discover_equipment(equipment) end) + "Equipment updated successfully. SNMP discovery started in background." + else + "Equipment updated successfully" + end + end + + defp should_trigger_snmp_discovery?(old_equipment, equipment) do + equipment.snmp_enabled and + (!old_equipment.snmp_enabled or + snmp_config_changed?(old_equipment, equipment)) + end + + defp snmp_config_changed?(old_equipment, equipment) do + equipment.snmp_community != old_equipment.snmp_community or + equipment.snmp_version != old_equipment.snmp_version or + equipment.snmp_port != old_equipment.snmp_port or + equipment.ip_address != old_equipment.ip_address + end + @spec extract_snmp_config(Phoenix.HTML.Form.t()) :: %{ ip_address: String.t() | nil, snmp_community: String.t() | nil, diff --git a/lib/towerops_web/live/equipment_live/show.ex b/lib/towerops_web/live/equipment_live/show.ex index 33bb6d29..958e5d04 100644 --- a/lib/towerops_web/live/equipment_live/show.ex +++ b/lib/towerops_web/live/equipment_live/show.ex @@ -239,122 +239,129 @@ defmodule ToweropsWeb.EquipmentLive.Show do defp load_sensor_chart_data(equipment_id, sensor_types) when is_list(sensor_types) do case Snmp.get_device_with_associations(equipment_id) do - nil -> - nil - - device -> - # Find all sensors of the specified types - sensors = - device.sensors - |> Enum.filter(&(&1.sensor_type in sensor_types)) - |> Enum.sort_by(& &1.sensor_index) - - if Enum.empty?(sensors) do - nil - else - # Get readings for past 24 hours for each sensor - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - datasets = - Enum.map(sensors, fn sensor -> - readings = - sensor.id - |> Snmp.get_sensor_readings( - since: twenty_four_hours_ago, - limit: 1000 - ) - |> Enum.reverse() - - %{ - label: sensor.sensor_descr, - data: - Enum.map(readings, fn reading -> - %{ - x: DateTime.to_unix(reading.checked_at, :millisecond), - y: if(reading.value, do: Float.round(reading.value, 1)) - } - end) - } - end) - - Jason.encode!(%{datasets: datasets}) - end + nil -> nil + device -> build_sensor_datasets_json(device, sensor_types) end end + defp build_sensor_datasets_json(device, sensor_types) do + sensors = + device.sensors + |> Enum.filter(&(&1.sensor_type in sensor_types)) + |> Enum.sort_by(& &1.sensor_index) + + if Enum.empty?(sensors) do + nil + else + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + datasets = Enum.map(sensors, &sensor_to_chart_dataset(&1, twenty_four_hours_ago)) + Jason.encode!(%{datasets: datasets}) + end + end + + defp sensor_to_chart_dataset(sensor, since) do + readings = + sensor.id + |> Snmp.get_sensor_readings(since: since, limit: 1000) + |> Enum.reverse() + + %{ + label: sensor.sensor_descr, + data: Enum.map(readings, &reading_to_data_point/1) + } + end + + defp reading_to_data_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.value, do: Float.round(reading.value, 1)) + } + end + defp load_overall_traffic_chart_data(equipment_id) do case Snmp.get_device_with_associations(equipment_id) do - nil -> - nil - - device -> - if Enum.empty?(device.interfaces) do - nil - else - # Get stats for past 24 hours for all interfaces - twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) - - # Get all stats for all interfaces - all_stats = - device.interfaces - |> Enum.flat_map(fn interface -> - stats = - interface.id - |> Snmp.get_interface_stats(since: twenty_four_hours_ago, limit: 1000) - |> Enum.reverse() - - Enum.map(stats, fn stat -> - {stat.checked_at, stat.if_in_octets, stat.if_out_octets} - end) - end) - |> Enum.group_by(&elem(&1, 0)) - |> Enum.map(fn {timestamp, stats} -> - total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) - total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) - {timestamp, total_in, total_out} - end) - |> Enum.sort_by(&elem(&1, 0), DateTime) - - if Enum.empty?(all_stats) do - nil - else - # Calculate bits per second from counter differences - # Inbound shown as negative (below zero axis) - in_data = - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: -bps - } - end) - - # Outbound shown as positive (above zero axis) - out_data = - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: bps - } - end) - - datasets = [ - %{label: "Outbound", data: out_data}, - %{label: "Inbound", data: in_data} - ] - - Jason.encode!(%{datasets: datasets}) - end - end + nil -> nil + device -> build_overall_traffic_chart_json(device) end end + + defp build_overall_traffic_chart_json(device) do + if Enum.empty?(device.interfaces) do + nil + else + twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour) + all_stats = aggregate_interface_traffic_stats(device.interfaces, twenty_four_hours_ago) + + if Enum.empty?(all_stats) do + nil + else + build_traffic_json_datasets(all_stats) + end + end + end + + defp aggregate_interface_traffic_stats(interfaces, since) do + interfaces + |> Enum.flat_map(fn interface -> + stats = + interface.id + |> Snmp.get_interface_stats(since: since, limit: 1000) + |> Enum.reverse() + + Enum.map(stats, &interface_stat_to_tuple/1) + end) + |> Enum.group_by(&elem(&1, 0)) + |> Enum.map(&sum_traffic_stats_by_timestamp/1) + |> Enum.sort_by(&elem(&1, 0), DateTime) + end + + defp interface_stat_to_tuple(stat) do + {stat.checked_at, stat.if_in_octets, stat.if_out_octets} + end + + defp sum_traffic_stats_by_timestamp({timestamp, stats}) do + total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) + total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) + {timestamp, total_in, total_out} + end + + defp build_traffic_json_datasets(all_stats) do + in_data = calculate_bps_data(all_stats, :inbound) + out_data = calculate_bps_data(all_stats, :outbound) + + datasets = [ + %{label: "Outbound", data: out_data}, + %{label: "Inbound", data: in_data} + ] + + Jason.encode!(%{datasets: datasets}) + end + + defp calculate_bps_data(all_stats, :inbound) do + all_stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> + time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) + bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) + + %{ + x: DateTime.to_unix(t2, :millisecond), + y: -bps + } + end) + end + + defp calculate_bps_data(all_stats, :outbound) do + all_stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> + time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) + bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) + + %{ + x: DateTime.to_unix(t2, :millisecond), + y: bps + } + end) + end end diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index a059f464..502a7761 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -89,45 +89,45 @@ defmodule ToweropsWeb.GraphLive.Show do nil device -> - # Find all sensors of the specified types - sensors = - device.sensors - |> Enum.filter(&(&1.sensor_type in sensor_types)) - |> Enum.sort_by(& &1.sensor_index) - - if Enum.empty?(sensors) do - nil - else - # Get readings based on range - since = get_datetime_from_range(range) - - datasets = - Enum.map(sensors, fn sensor -> - readings = - sensor.id - |> Snmp.get_sensor_readings( - since: since, - limit: get_limit_for_range(range) - ) - |> Enum.reverse() - - %{ - label: sensor.sensor_descr, - data: - Enum.map(readings, fn reading -> - %{ - x: DateTime.to_unix(reading.checked_at, :millisecond), - y: if(reading.value, do: Float.round(reading.value, 1)) - } - end) - } - end) - - Jason.encode!(%{datasets: datasets}) - end + build_sensor_chart_json(device, sensor_types, range) end end + defp build_sensor_chart_json(device, sensor_types, range) do + sensors = + device.sensors + |> Enum.filter(&(&1.sensor_type in sensor_types)) + |> Enum.sort_by(& &1.sensor_index) + + if Enum.empty?(sensors) do + nil + else + since = get_datetime_from_range(range) + limit = get_limit_for_range(range) + datasets = Enum.map(sensors, &sensor_to_dataset(&1, since, limit)) + Jason.encode!(%{datasets: datasets}) + end + end + + defp sensor_to_dataset(sensor, since, limit) do + readings = + sensor.id + |> Snmp.get_sensor_readings(since: since, limit: limit) + |> Enum.reverse() + + %{ + label: sensor.sensor_descr, + data: Enum.map(readings, &reading_to_chart_point/1) + } + end + + defp reading_to_chart_point(reading) do + %{ + x: DateTime.to_unix(reading.checked_at, :millisecond), + y: if(reading.value, do: Float.round(reading.value, 1)) + } + end + defp get_datetime_from_range("1h"), do: DateTime.add(DateTime.utc_now(), -1, :hour) defp get_datetime_from_range("6h"), do: DateTime.add(DateTime.utc_now(), -6, :hour) defp get_datetime_from_range("12h"), do: DateTime.add(DateTime.utc_now(), -12, :hour) @@ -146,81 +146,89 @@ defmodule ToweropsWeb.GraphLive.Show do defp load_traffic_chart_data(equipment_id, range) do case Snmp.get_device_with_associations(equipment_id) do - nil -> - nil - - device -> - if Enum.empty?(device.interfaces) do - nil - else - since = get_datetime_from_range(range) - limit = get_limit_for_range(range) - - # Get all stats for all interfaces - all_stats = - device.interfaces - |> Enum.flat_map(fn interface -> - stats = - interface.id - |> Snmp.get_interface_stats(since: since, limit: limit) - |> Enum.reverse() - - Enum.map(stats, fn stat -> - {stat.checked_at, stat.if_in_octets, stat.if_out_octets} - end) - end) - |> Enum.group_by(&elem(&1, 0)) - |> Enum.map(fn {timestamp, stats} -> - total_in = - Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) - - total_out = - Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) - - {timestamp, total_in, total_out} - end) - |> Enum.sort_by(&elem(&1, 0), DateTime) - - if Enum.empty?(all_stats) do - nil - else - # Calculate bits per second from counter differences - # Inbound shown as negative (below zero axis) - in_data = - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: -bps - } - end) - - # Outbound shown as positive (above zero axis) - out_data = - all_stats - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> - time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) - bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) - - %{ - x: DateTime.to_unix(t2, :millisecond), - y: bps - } - end) - - datasets = [ - %{label: "Outbound", data: out_data}, - %{label: "Inbound", data: in_data} - ] - - Jason.encode!(%{datasets: datasets}) - end - end + nil -> nil + device -> build_traffic_chart_json(device, range) end end + + defp build_traffic_chart_json(device, range) do + if Enum.empty?(device.interfaces) do + nil + else + since = get_datetime_from_range(range) + limit = get_limit_for_range(range) + all_stats = aggregate_interface_stats(device.interfaces, since, limit) + + if Enum.empty?(all_stats) do + nil + else + build_traffic_datasets(all_stats) + end + end + end + + defp aggregate_interface_stats(interfaces, since, limit) do + interfaces + |> Enum.flat_map(fn interface -> + stats = + interface.id + |> Snmp.get_interface_stats(since: since, limit: limit) + |> Enum.reverse() + + Enum.map(stats, &stat_to_tuple/1) + end) + |> Enum.group_by(&elem(&1, 0)) + |> Enum.map(&sum_stats_by_timestamp/1) + |> Enum.sort_by(&elem(&1, 0), DateTime) + end + + defp stat_to_tuple(stat) do + {stat.checked_at, stat.if_in_octets, stat.if_out_octets} + end + + defp sum_stats_by_timestamp({timestamp, stats}) do + total_in = Enum.reduce(stats, 0, fn {_, in_octets, _}, acc -> acc + (in_octets || 0) end) + total_out = Enum.reduce(stats, 0, fn {_, _, out_octets}, acc -> acc + (out_octets || 0) end) + {timestamp, total_in, total_out} + end + + defp build_traffic_datasets(all_stats) do + in_data = calculate_traffic_data(all_stats, :inbound) + out_data = calculate_traffic_data(all_stats, :outbound) + + datasets = [ + %{label: "Outbound", data: out_data}, + %{label: "Inbound", data: in_data} + ] + + Jason.encode!(%{datasets: datasets}) + end + + defp calculate_traffic_data(all_stats, :inbound) do + all_stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [{t1, in1, _out1}, {t2, in2, _out2}] -> + time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) + bps = ((in2 - in1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) + + %{ + x: DateTime.to_unix(t2, :millisecond), + y: -bps + } + end) + end + + defp calculate_traffic_data(all_stats, :outbound) do + all_stats + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [{t1, _in1, out1}, {t2, _in2, out2}] -> + time_diff = t2 |> DateTime.diff(t1, :second) |> max(1) + bps = ((out2 - out1) * 8 / time_diff) |> max(0) |> :erlang.float() |> Float.round(2) + + %{ + x: DateTime.to_unix(t2, :millisecond), + y: bps + } + end) + end end diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index 1e6fdbec..f4343f5b 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -416,37 +416,52 @@ defmodule ToweropsWeb.UserAuth do defp mount_current_scope(socket, session) do Phoenix.Component.assign_new(socket, :current_scope, fn -> - # Check if currently impersonating - if session["impersonating"] do - superuser_id = session["superuser_id"] - target_user_id = session["target_user_id"] - - # Validate we have both IDs before attempting to fetch users - if superuser_id && target_user_id do - with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id), - target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do - Scope.for_impersonation(superuser, target_user) - else - _ -> Scope.for_user(nil) - end - else - # Missing IDs, return nil user - Scope.for_user(nil) - end - else - # Normal authentication flow - if user_token = session["user_token"] do - case Accounts.get_user_by_session_token(user_token) do - {user, _token_inserted_at} -> Scope.for_user(user) - nil -> Scope.for_user(nil) - end - else - Scope.for_user(nil) - end - end + build_scope_from_session(session) end) end + defp build_scope_from_session(session) do + if session["impersonating"] do + build_impersonation_scope(session) + else + build_normal_scope(session) + end + end + + defp build_impersonation_scope(session) do + superuser_id = session["superuser_id"] + target_user_id = session["target_user_id"] + + if superuser_id && target_user_id do + fetch_impersonation_users(superuser_id, target_user_id) + else + Scope.for_user(nil) + end + end + + defp fetch_impersonation_users(superuser_id, target_user_id) do + with superuser when not is_nil(superuser) <- Accounts.get_user(superuser_id), + target_user when not is_nil(target_user) <- Accounts.get_user(target_user_id) do + Scope.for_impersonation(superuser, target_user) + else + _ -> Scope.for_user(nil) + end + end + + defp build_normal_scope(session) do + case session["user_token"] do + nil -> Scope.for_user(nil) + user_token -> fetch_user_from_token(user_token) + end + end + + defp fetch_user_from_token(user_token) do + case Accounts.get_user_by_session_token(user_token) do + {user, _token_inserted_at} -> Scope.for_user(user) + nil -> Scope.for_user(nil) + end + end + @doc """ Start impersonating a user as a superuser.