Refactor credo issues: reduce complexity and nesting depth

- 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.
This commit is contained in:
Graham McIntire 2026-01-06 13:55:16 -06:00
parent 02e0744c3c
commit 139a42531f
No known key found for this signature in database
7 changed files with 652 additions and 544 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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.