perf: batch inserts, selective LiveView reloading, and database indexes for scale

Replace individual Repo.insert() with Repo.insert_all() for sensor readings,
interface stats, processor readings, and storage readings in agent channel and
device poller worker. Add partial/composite database indexes for common query
patterns. Dashboard now uses GROUP BY aggregation instead of loading all devices.
DeviceLive.Show only reloads data for the active tab on PubSub events.
DeviceLive.Index debounces status changes and skips quota queries on updates.
Agent polling preloads filtered to monitored sensors/interfaces only.
This commit is contained in:
Graham McIntire 2026-02-12 10:16:10 -06:00
parent b80cdd54ab
commit aa1bac0d46
No known key found for this signature in database
12 changed files with 1211 additions and 239 deletions

View file

@ -1,6 +1,33 @@
CHANGELOG - towerops-web CHANGELOG - towerops-web
======================== ========================
2026-02-12 - perf: performance improvements for scale (batch inserts, indexes, selective reloading)
- Files: lib/towerops/snmp.ex, lib/towerops/devices.ex, lib/towerops_web/channels/agent_channel.ex,
lib/towerops/workers/device_poller_worker.ex, lib/towerops_web/live/dashboard_live.ex,
lib/towerops_web/live/device_live/show.ex, lib/towerops_web/live/device_live/index.ex,
lib/towerops/agents.ex, priv/repo/migrations/20260212155105_add_performance_indexes.exs
Phase 1: Batch data ingestion - replaced individual Repo.insert() calls with Repo.insert_all()
for sensor readings, interface stats, processor readings, and storage readings in both
agent_channel.ex and device_poller_worker.ex. Added create_*_batch/1 functions to snmp.ex.
Phase 2: Database indexes - added partial indexes for monitored sensors/interfaces,
composite index for devices org+status, partial index for active alerts.
Phase 3: Dashboard aggregation - replaced loading all devices into memory with
Devices.get_device_status_counts/1 GROUP BY query.
Phase 4: DeviceLive.Show selective updates - split monolithic load_equipment_data into
tab-specific loaders (base, tab_nav, overview, ports, logs, backups). PubSub handlers
now only reload data relevant to the active tab.
Phase 5: DeviceLive.Index incremental updates - separated structural changes (create/delete)
from status changes. Added debouncing for rapid status changes. Skip reload when viewing
discovered tab.
Phase 6: Agent polling memory optimization - filtered preloads in list_agent_polling_targets
to only load monitored sensors and interfaces instead of all.
2026-02-12 - feat: complete WISP vendor coverage (ePMP, Raisecom, Netonix) 2026-02-12 - feat: complete WISP vendor coverage (ePMP, Raisecom, Netonix)
- Files: priv/profiles/os_discovery/epmp.yaml (enhanced), lib/towerops/snmp/profiles/vendors/raisecom.ex (new) - Files: priv/profiles/os_discovery/epmp.yaml (enhanced), lib/towerops/snmp/profiles/vendors/raisecom.ex (new)
Completed LibreNMS parity for major WISP equipment vendors. Three improvements: Completed LibreNMS parity for major WISP equipment vendors. Three improvements:

View file

@ -592,7 +592,10 @@ defmodule Towerops.Agents do
:agent_assignments, :agent_assignments,
:organization, :organization,
site: :organization, site: :organization,
snmp_device: [:sensors, :interfaces] snmp_device: [
sensors: ^from(s in Towerops.Snmp.Sensor, where: s.monitored == true),
interfaces: ^from(i in Towerops.Snmp.Interface, where: i.monitored == true)
]
] ]
) )

View file

@ -116,6 +116,30 @@ defmodule Towerops.Devices do
) )
end end
@doc """
Returns a map of device status counts for an organization.
Uses a GROUP BY query instead of loading all devices into memory.
## Examples
iex> get_device_status_counts(organization_id)
%{up: 5, down: 2, unknown: 1}
iex> get_device_status_counts(empty_org_id)
%{}
"""
@spec get_device_status_counts(String.t()) :: %{atom() => non_neg_integer()}
def get_device_status_counts(organization_id) do
from(d in DeviceSchema,
where: d.organization_id == ^organization_id,
group_by: d.status,
select: {d.status, count(d.id)}
)
|> Repo.all()
|> Map.new()
end
@doc """ @doc """
Returns the list of all devices with monitoring enabled. Returns the list of all devices with monitoring enabled.
""" """

View file

@ -372,6 +372,28 @@ defmodule Towerops.Snmp do
|> Repo.insert() |> Repo.insert()
end end
@doc """
Batch inserts multiple sensor readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_sensor_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_sensor_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_sensor_readings_batch([]), do: {0, nil}
def create_sensor_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(SensorReading, rows)
end
@doc """ @doc """
Records a new interface stat. Records a new interface stat.
""" """
@ -381,6 +403,28 @@ defmodule Towerops.Snmp do
|> Repo.insert() |> Repo.insert()
end end
@doc """
Batch inserts multiple interface stats using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_interface_stat/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_interface_stats_batch([map()]) :: {non_neg_integer(), nil}
def create_interface_stats_batch([]), do: {0, nil}
def create_interface_stats_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(InterfaceStat, rows)
end
# Neighbor queries # Neighbor queries
@doc """ @doc """
@ -1383,6 +1427,28 @@ defmodule Towerops.Snmp do
|> Repo.insert() |> Repo.insert()
end end
@doc """
Batch inserts multiple processor readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_processor_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_processor_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_processor_readings_batch([]), do: {0, nil}
def create_processor_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(ProcessorReading, rows)
end
# Storage queries # Storage queries
@doc """ @doc """
@ -1490,6 +1556,28 @@ defmodule Towerops.Snmp do
|> Repo.insert() |> Repo.insert()
end end
@doc """
Batch inserts multiple storage readings using `Repo.insert_all/3`.
Accepts a list of attribute maps with the same keys as `create_storage_reading/1`.
Generates UUIDs and timestamps automatically. Returns `{count, nil}`.
"""
@spec create_storage_readings_batch([map()]) :: {non_neg_integer(), nil}
def create_storage_readings_batch([]), do: {0, nil}
def create_storage_readings_batch(entries) when is_list(entries) do
now = DateTime.truncate(DateTime.utc_now(), :second)
rows =
Enum.map(entries, fn entry ->
entry
|> Map.put(:id, Ecto.UUID.generate())
|> Map.put(:inserted_at, now)
end)
Repo.insert_all(StorageReading, rows)
end
# ARP Entry queries # ARP Entry queries
@doc """ @doc """

View file

@ -413,17 +413,54 @@ defmodule Towerops.Workers.DevicePollerWorker do
# (These remain unchanged - I'll include them all below) # (These remain unchanged - I'll include them all below)
defp poll_storage(storage_entries, client_opts, timestamp) do defp poll_storage(storage_entries, client_opts, timestamp) do
storage_entries poll_results =
|> Task.async_stream( storage_entries
fn storage -> |> Task.async_stream(
result = poll_storage_value(storage, client_opts) fn storage ->
handle_storage_poll_result(storage, result, timestamp) result = poll_storage_value(storage, client_opts)
end, {storage, result}
max_concurrency: 2, end,
timeout: 40_000, max_concurrency: 2,
on_timeout: :kill_task timeout: 40_000,
) on_timeout: :kill_task
|> Stream.run() )
|> Enum.flat_map(fn
{:ok, pair} -> [pair]
{:exit, _reason} -> []
end)
# Build batch reading entries (only for successful polls)
reading_entries =
Enum.flat_map(poll_results, fn
{storage, {:ok, values}} ->
[
%{
storage_id: storage.id,
used_bytes: values.used_bytes,
total_bytes: values.total_bytes,
usage_percent: values.usage_percent,
checked_at: timestamp
}
]
{_storage, {:error, _reason}} ->
[]
end)
Snmp.create_storage_readings_batch(reading_entries)
# Process metadata updates individually
Enum.each(poll_results, fn
{storage, {:ok, values}} ->
Snmp.update_storage(storage, %{
used_bytes: values.used_bytes,
total_bytes: values.total_bytes,
last_checked_at: timestamp
})
{storage, {:error, reason}} ->
Logger.debug("Failed to poll storage #{storage.description}: #{inspect(reason)}")
end)
end end
defp poll_storage_value(storage, client_opts) do defp poll_storage_value(storage, client_opts) do
@ -447,38 +484,35 @@ defmodule Towerops.Workers.DevicePollerWorker do
end end
end end
defp handle_storage_poll_result(storage, {:ok, values}, timestamp) do
Snmp.create_storage_reading(%{
storage_id: storage.id,
used_bytes: values.used_bytes,
total_bytes: values.total_bytes,
usage_percent: values.usage_percent,
checked_at: timestamp
})
Snmp.update_storage(storage, %{
used_bytes: values.used_bytes,
total_bytes: values.total_bytes,
last_checked_at: timestamp
})
end
defp handle_storage_poll_result(storage, {:error, reason}, _timestamp) do
Logger.debug("Failed to poll storage #{storage.description}: #{inspect(reason)}")
end
defp poll_processors(processors, client_opts, timestamp) do defp poll_processors(processors, client_opts, timestamp) do
processors poll_results =
|> Task.async_stream( processors
fn processor -> |> Task.async_stream(
result = poll_processor_value(processor, client_opts) fn processor ->
handle_processor_poll_result(processor, result, timestamp) result = poll_processor_value(processor, client_opts)
end, {processor, result}
max_concurrency: 2, end,
timeout: 40_000, max_concurrency: 2,
on_timeout: :kill_task timeout: 40_000,
) on_timeout: :kill_task
|> Stream.run() )
|> Enum.flat_map(fn
{:ok, pair} -> [pair]
{:exit, _reason} -> []
end)
# Build batch reading entries
reading_entries =
Enum.map(poll_results, fn {processor, result} ->
build_processor_reading_entry(processor, result, timestamp)
end)
Snmp.create_processor_readings_batch(reading_entries)
# Process metadata updates individually
Enum.each(poll_results, fn {processor, result} ->
handle_processor_poll_metadata(processor, result, timestamp)
end)
end end
defp poll_processor_value(processor, client_opts) do defp poll_processor_value(processor, client_opts) do
@ -533,46 +567,69 @@ defmodule Towerops.Workers.DevicePollerWorker do
end end
end end
defp handle_processor_poll_result(processor, {:ok, load_percent}, timestamp) do defp build_processor_reading_entry(processor, {:ok, load_percent}, timestamp) do
Snmp.create_processor_reading(%{ %{
processor_id: processor.id, processor_id: processor.id,
load_percent: load_percent, load_percent: load_percent,
status: "ok", status: "ok",
checked_at: timestamp checked_at: timestamp
}) }
end
defp build_processor_reading_entry(processor, {:error, reason}, timestamp) do
Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}")
%{
processor_id: processor.id,
load_percent: nil,
status: "error",
checked_at: timestamp
}
end
defp handle_processor_poll_metadata(processor, {:ok, load_percent}, timestamp) do
Snmp.update_processor(processor, %{ Snmp.update_processor(processor, %{
load_percent: load_percent, load_percent: load_percent,
last_checked_at: timestamp last_checked_at: timestamp
}) })
end end
defp handle_processor_poll_result(processor, {:error, reason}, timestamp) do defp handle_processor_poll_metadata(_processor, {:error, _reason}, _timestamp), do: :ok
Logger.debug("Failed to poll processor #{processor.description}: #{inspect(reason)}")
Snmp.create_processor_reading(%{
processor_id: processor.id,
load_percent: nil,
status: "error",
checked_at: timestamp
})
end
defp poll_sensors(sensors, client_opts, timestamp) do defp poll_sensors(sensors, client_opts, timestamp) do
sensors # Poll all sensors concurrently and collect results
|> Task.async_stream( poll_results =
fn sensor -> sensors
result = poll_sensor_value(sensor, client_opts) |> Task.async_stream(
handle_sensor_poll_result(sensor, result, timestamp) fn sensor ->
sensor.sensor_descr result = poll_sensor_value(sensor, client_opts)
end, {sensor, result}
max_concurrency: 2, end,
timeout: 40_000, max_concurrency: 2,
on_timeout: :kill_task timeout: 40_000,
) on_timeout: :kill_task
|> Enum.each(fn )
{:ok, _} -> :ok |> Enum.flat_map(fn
{:exit, reason} -> Logger.warning("Sensor poll task failed: #{inspect(reason)}") {:ok, pair} ->
[pair]
{:exit, reason} ->
Logger.warning("Sensor poll task failed: #{inspect(reason)}")
[]
end)
# Build batch reading entries
reading_entries =
Enum.map(poll_results, fn {sensor, result} ->
build_sensor_reading_entry(sensor, result, timestamp)
end)
# Batch insert all sensor readings
Snmp.create_sensor_readings_batch(reading_entries)
# Process metadata updates and change detection individually
Enum.each(poll_results, fn {sensor, result} ->
handle_sensor_poll_metadata(sensor, result, timestamp)
end) end)
end end
@ -720,18 +777,43 @@ defmodule Towerops.Workers.DevicePollerWorker do
end end
end end
defp handle_sensor_poll_result(sensor, {:ok, value}, timestamp) do # Build a reading entry map for batch insert, or nil if not applicable
# For state sensors, map numeric value to state description defp build_sensor_reading_entry(sensor, {:ok, value}, timestamp) do
state_descr = get_state_description(sensor, value) state_descr = get_state_description(sensor, value)
Snmp.create_sensor_reading(%{ %{
sensor_id: sensor.id, sensor_id: sensor.id,
value: value, value: value,
status: "ok", status: "ok",
state_descr: state_descr, state_descr: state_descr,
checked_at: timestamp checked_at: timestamp
}) }
end
defp build_sensor_reading_entry(sensor, {:error, :non_numeric}, timestamp) do
Logger.debug("Non-numeric SNMP value for sensor #{sensor.sensor_descr}")
%{
sensor_id: sensor.id,
value: nil,
status: "unknown",
checked_at: timestamp
}
end
defp build_sensor_reading_entry(sensor, {:error, reason}, timestamp) do
log_sensor_error(sensor, reason)
%{
sensor_id: sensor.id,
value: nil,
status: "error",
checked_at: timestamp
}
end
# Process metadata updates and change detection (must remain per-sensor)
defp handle_sensor_poll_metadata(sensor, {:ok, value}, timestamp) do
SensorChangeDetector.detect_and_broadcast(sensor, value, timestamp) SensorChangeDetector.detect_and_broadcast(sensor, value, timestamp)
Snmp.update_sensor(sensor, %{ Snmp.update_sensor(sensor, %{
@ -740,27 +822,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
}) })
end end
defp handle_sensor_poll_result(sensor, {:error, :non_numeric}, timestamp) do defp handle_sensor_poll_metadata(_sensor, {:error, _reason}, _timestamp), do: :ok
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
# Get state description for state sensors from metadata # Get state description for state sensors from metadata
defp get_state_description(sensor, value) do defp get_state_description(sensor, value) do
@ -838,24 +900,27 @@ defmodule Towerops.Workers.DevicePollerWorker do
end end
defp poll_interfaces(interfaces, client_opts, timestamp) do defp poll_interfaces(interfaces, client_opts, timestamp) do
interfaces entries =
|> Task.async_stream( interfaces
fn interface -> |> Task.async_stream(
{:ok, stat_data} = get_interface_stats(client_opts, interface.if_index) fn interface ->
{:ok, stat_data} = get_interface_stats(client_opts, interface.if_index)
stats =
Map.merge(stat_data, %{ Map.merge(stat_data, %{
interface_id: interface.id, interface_id: interface.id,
checked_at: timestamp checked_at: timestamp
}) })
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Enum.flat_map(fn
{:ok, entry} -> [entry]
{:exit, _reason} -> []
end)
Snmp.create_interface_stat(stats) Snmp.create_interface_stats_batch(entries)
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
end end
defp check_interface_changes(interfaces, device, client_opts, timestamp) do defp check_interface_changes(interfaces, device, client_opts, timestamp) do

View file

@ -1422,17 +1422,17 @@ defmodule ToweropsWeb.AgentChannel do
snmp_device = device.snmp_device snmp_device = device.snmp_device
oid_values = normalize_oid_keys(result.oid_values) oid_values = normalize_oid_keys(result.oid_values)
# Use server time to avoid clock drift issues between agents # Use server time to avoid clock drift issues between agents
timestamp = DateTime.utc_now() timestamp = DateTime.truncate(DateTime.utc_now(), :second)
Logger.info( Logger.info(
"Processing polling result for #{device.name}: #{map_size(oid_values)} OIDs, #{length(snmp_device.sensors)} sensors, #{length(snmp_device.interfaces)} interfaces" "Processing polling result for #{device.name}: #{map_size(oid_values)} OIDs, #{length(snmp_device.sensors)} sensors, #{length(snmp_device.interfaces)} interfaces"
) )
# Process sensor readings (quick path - already in memory) # Batch insert sensor readings and process metadata updates
Enum.each(snmp_device.sensors, &process_sensor_reading(&1, oid_values, timestamp)) process_sensor_readings_batch(snmp_device.sensors, oid_values, timestamp)
# Process interface stats (quick path - already in memory) # Batch insert interface stats
Enum.each(device.snmp_device.interfaces, &process_interface_stats(&1, oid_values, timestamp)) process_interface_stats_batch(snmp_device.interfaces, oid_values, timestamp)
# Process complex SNMP data (neighbors, ARP, MAC, IP addresses, processors, storage) # Process complex SNMP data (neighbors, ARP, MAC, IP addresses, processors, storage)
# using Replay adapter to reuse existing parsing logic # using Replay adapter to reuse existing parsing logic
@ -1576,47 +1576,69 @@ defmodule ToweropsWeb.AgentChannel do
end end
end end
defp process_sensor_reading(sensor, oid_values, timestamp) do defp process_sensor_readings_batch(sensors, oid_values, timestamp) do
# Build reading entries and collect sensor updates in one pass
{reading_entries, sensor_updates} =
Enum.reduce(sensors, {[], []}, fn sensor, {readings, updates} ->
case resolve_sensor_value(sensor, oid_values) do
nil ->
{readings, updates}
final_value ->
reading = %{
sensor_id: sensor.id,
value: final_value,
status: "ok",
checked_at: timestamp
}
{[reading | readings], [{sensor, final_value} | updates]}
end
end)
# Batch insert all sensor readings in one SQL statement
Snmp.create_sensor_readings_batch(reading_entries)
# Process change detection and metadata updates individually
Enum.each(sensor_updates, fn {sensor, final_value} ->
SensorChangeDetector.detect_and_broadcast(sensor, final_value, timestamp)
Snmp.update_sensor(sensor, %{
last_value: final_value,
last_checked_at: timestamp
})
end)
end
defp resolve_sensor_value(sensor, oid_values) do
normalized_oid = String.trim_leading(sensor.sensor_oid, ".") normalized_oid = String.trim_leading(sensor.sensor_oid, ".")
case Map.get(oid_values, normalized_oid) do with value when not is_nil(value) <- Map.get(oid_values, normalized_oid),
nil -> parsed when not is_nil(parsed) <- parse_float(value) do
:ok Float.round(parsed / sensor.sensor_divisor, 1)
else
value -> _ -> nil
with parsed_value when not is_nil(parsed_value) <- parse_float(value) do
final_value = Float.round(parsed_value / sensor.sensor_divisor, 1)
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: final_value,
status: "ok",
checked_at: timestamp
})
SensorChangeDetector.detect_and_broadcast(sensor, final_value, timestamp)
Snmp.update_sensor(sensor, %{
last_value: final_value,
last_checked_at: timestamp
})
end
end end
end end
defp process_interface_stats(iface, oid_values, timestamp) do defp process_interface_stats_batch(interfaces, oid_values, timestamp) do
idx = iface.if_index entries =
Enum.map(interfaces, fn iface ->
idx = iface.if_index
Snmp.create_interface_stat(%{ %{
interface_id: iface.id, interface_id: iface.id,
if_in_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.6.#{idx}")), if_in_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.6.#{idx}")),
if_out_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.10.#{idx}")), if_out_octets: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.31.1.1.1.10.#{idx}")),
if_in_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.14.#{idx}")), if_in_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.14.#{idx}")),
if_out_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.20.#{idx}")), if_out_errors: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.20.#{idx}")),
if_in_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.13.#{idx}")), if_in_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.13.#{idx}")),
if_out_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.19.#{idx}")), if_out_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.19.#{idx}")),
checked_at: timestamp checked_at: timestamp
}) }
end)
Snmp.create_interface_stats_batch(entries)
end end
defp parse_integer(nil), do: nil defp parse_integer(nil), do: nil

View file

@ -36,20 +36,17 @@ defmodule ToweropsWeb.DashboardLive do
defp load_dashboard_data(socket, organization_id) do defp load_dashboard_data(socket, organization_id) do
organization = socket.assigns.current_scope.organization organization = socket.assigns.current_scope.organization
active_alerts = Alerts.list_organization_active_alerts(organization_id) active_alerts = Alerts.list_organization_active_alerts(organization_id)
sites_count = if organization.use_sites, do: length(Sites.list_organization_sites(organization_id)), else: 0 sites_count = if organization.use_sites, do: Sites.count_organization_sites(organization_id), else: 0
devices = Devices.list_organization_devices(organization_id) status_counts = Devices.get_device_status_counts(organization_id)
devices_by_status = device_count = status_counts |> Map.values() |> Enum.sum()
devices
|> Enum.group_by(& &1.status)
|> Map.new(fn {status, equip} -> {status, length(equip)} end)
socket socket
|> assign(:active_alerts, active_alerts) |> assign(:active_alerts, active_alerts)
|> assign(:sites_count, sites_count) |> assign(:sites_count, sites_count)
|> assign(:device_count, length(devices)) |> assign(:device_count, device_count)
|> assign(:device_up, Map.get(devices_by_status, :up, 0)) |> assign(:device_up, Map.get(status_counts, :up, 0))
|> assign(:device_down, Map.get(devices_by_status, :down, 0)) |> assign(:device_down, Map.get(status_counts, :down, 0))
|> assign(:device_unknown, Map.get(devices_by_status, :unknown, 0)) |> assign(:device_unknown, Map.get(status_counts, :unknown, 0))
end end
end end

View file

@ -31,7 +31,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:sites_enabled, organization.use_sites) |> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != []) |> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false) |> assign(:reorder_mode, false)
|> assign(:device_quota, %{current: current, limit: limit})} |> assign(:device_quota, %{current: current, limit: limit})
|> assign(:pending_reload_ref, nil)}
end end
@impl true @impl true
@ -210,12 +211,47 @@ defmodule ToweropsWeb.DeviceLive.Index do
end end
end end
# Structural changes (create/delete) need full reload with quota recalculation
@impl true @impl true
def handle_info({event, _org_id}, socket) def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do
when event in [:device_created, :device_updated, :device_deleted, :device_status_changed] do
{:noreply, reload_devices(socket)} {:noreply, reload_devices(socket)}
end end
# Status changes and updates are debounced to avoid redundant reloads
# when many devices change status simultaneously (e.g., network event)
@impl true
def handle_info({event, _org_id}, socket) when event in [:device_status_changed, :device_updated] do
{:noreply, schedule_debounced_reload(socket, event)}
end
@impl true
def handle_info(:debounced_device_reload, socket) do
{:noreply, reload_device_stream(socket)}
end
@debounce_ms if Mix.env() == :test, do: 0, else: 100
defp schedule_debounced_reload(socket, event) do
# Cancel any pending debounced reload
if ref = socket.assigns.pending_reload_ref do
Process.cancel_timer(ref)
end
# For status changes, skip reload entirely if viewing the discovered tab
if event == :device_status_changed && socket.assigns.active_tab == "discovered" do
assign(socket, :pending_reload_ref, nil)
else
if @debounce_ms == 0 do
# In test: reload immediately so render(view) sees the update
reload_device_stream(socket)
else
ref = Process.send_after(self(), :debounced_device_reload, @debounce_ms)
assign(socket, :pending_reload_ref, ref)
end
end
end
# Full reload: stream + quota (for structural changes like create/delete)
defp reload_devices(socket) do defp reload_devices(socket) do
organization = socket.assigns.current_scope.organization organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id) devices = Devices.list_organization_devices(organization.id)
@ -227,6 +263,17 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:device_quota, %{current: current, limit: limit}) |> assign(:device_quota, %{current: current, limit: limit})
end end
# Lightweight reload: stream only, skip quota query (for status/update changes)
defp reload_device_stream(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:pending_reload_ref, nil)
end
defp enqueue_discovery(device_id) do defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end) _ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)

View file

@ -96,17 +96,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
_device -> _device ->
Process.send_after(self(), :refresh_data, 10_000) Process.send_after(self(), :refresh_data, 10_000)
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_active_tab_data(socket)}
end end
end end
@impl true @impl true
def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do def handle_info({:device_status_changed, _device_id, _new_status, _response_time}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} # Device status shows on all tabs in the header - reload base data only
{:noreply, reload_base_data(socket)}
end end
@impl true @impl true
def handle_info({:discovery_completed, _device_id}, socket) do def handle_info({:discovery_completed, _device_id}, socket) do
# Structural change - full reload needed
{:noreply, {:noreply,
socket socket
|> load_equipment_data(socket.assigns.device.id) |> load_equipment_data(socket.assigns.device.id)
@ -115,61 +117,60 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true @impl true
def handle_info({:sensors_updated, _device_id}, socket) do def handle_info({:sensors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview"])}
end end
@impl true @impl true
def handle_info({:interfaces_updated, _device_id}, socket) do def handle_info({:interfaces_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview", "ports"])}
end end
@impl true @impl true
def handle_info({:neighbors_updated, _device_id}, socket) do def handle_info({:neighbors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["neighbors"])}
end end
@impl true @impl true
def handle_info({:arp_updated, _device_id}, socket) do def handle_info({:arp_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["arp"])}
end end
@impl true @impl true
def handle_info({:mac_updated, _device_id}, socket) do def handle_info({:mac_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["mac"])}
end end
@impl true @impl true
def handle_info({:state_sensors_updated, _device_id}, socket) do def handle_info({:state_sensors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview"])}
end end
@impl true @impl true
def handle_info({:processors_updated, _device_id}, socket) do def handle_info({:processors_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview"])}
end end
@impl true @impl true
def handle_info({:storage_updated, _device_id}, socket) do def handle_info({:storage_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview"])}
end end
@impl true @impl true
def handle_info({:monitoring_check_updated, _device_id}, socket) do def handle_info({:monitoring_check_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_if_tab(socket, ["overview"])}
end end
@impl true @impl true
def handle_info({:device_event, _event_attrs}, socket) do def handle_info({:device_event, _event_attrs}, socket) do
# Device event logged (interface changes, sensor thresholds, etc.) # Device event logged (interface changes, sensor thresholds, etc.)
# Reload to update events list {:noreply, reload_if_tab(socket, ["logs"])}
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end end
@impl true @impl true
def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do def handle_info({:agent_connected, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent just connected # Reload agent info if this device's agent just connected
if socket.assigns.agent_info.agent_token_id == agent_token_id do if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_base_data(socket)}
else else
{:noreply, socket} {:noreply, socket}
end end
@ -177,9 +178,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true @impl true
def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do def handle_info({:agent_disconnected, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent just disconnected # Reload agent info if this device's agent just disconnected
if socket.assigns.agent_info.agent_token_id == agent_token_id do if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_base_data(socket)}
else else
{:noreply, socket} {:noreply, socket}
end end
@ -187,9 +188,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true @impl true
def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do def handle_info({:agent_heartbeat, agent_token_id, _organization_id}, socket) do
# Reload if this device's agent sent a heartbeat (important for stale agents coming back) # Reload agent info if this device's agent sent a heartbeat
if socket.assigns.agent_info.agent_token_id == agent_token_id do if socket.assigns.agent_info.agent_token_id == agent_token_id do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_base_data(socket)}
else else
{:noreply, socket} {:noreply, socket}
end end
@ -197,11 +198,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true @impl true
def handle_info({:agents_stale, stale_agents}, socket) do def handle_info({:agents_stale, stale_agents}, socket) do
# Reload if this device's agent went stale # Reload agent info if this device's agent went stale
device_agent_id = socket.assigns.agent_info.agent_token_id device_agent_id = socket.assigns.agent_info.agent_token_id
if device_agent_id && Enum.any?(stale_agents, &(&1.id == device_agent_id)) do if device_agent_id && Enum.any?(stale_agents, &(&1.id == device_agent_id)) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, reload_base_data(socket)}
else else
{:noreply, socket} {:noreply, socket}
end end
@ -220,21 +221,111 @@ defmodule ToweropsWeb.DeviceLive.Show do
:ok :ok
end end
# -- Full data load (initial page load and discovery_completed) --
# Loads all data for every tab. Called from handle_params (initial load)
# and discovery_completed (structural change requiring full refresh).
defp load_equipment_data(socket, device_id) do defp load_equipment_data(socket, device_id) do
socket
|> assign_base_data(device_id)
|> assign_tab_nav_data(device_id)
|> assign_overview_data(device_id)
|> assign_ports_data()
|> assign_logs_data(device_id)
|> assign_backups_data(device_id)
end
# -- Selective reload helpers (used by handle_info handlers) --
# Reload only base data (device, agent_info, snmp_device).
# Used when device status or agent state changes - these show in the header on all tabs.
defp reload_base_data(socket) do
assign_base_data(socket, socket.assigns.device.id)
end
# Reload base data + data for the currently active tab only.
# Used by the periodic refresh timer.
defp reload_active_tab_data(socket) do
device_id = socket.assigns.device.id
socket
|> assign_base_data(device_id)
|> assign_tab_nav_data(device_id)
|> reload_current_tab_data(device_id)
end
# Reload data only if the active tab is one of the relevant tabs.
# Otherwise does nothing - the event is irrelevant to what the user sees.
defp reload_if_tab(socket, relevant_tabs) do
if socket.assigns.active_tab in relevant_tabs do
reload_current_tab_data(socket, socket.assigns.device.id)
else
socket
end
end
# Dispatch to the appropriate tab-specific loader based on active_tab.
defp reload_current_tab_data(socket, device_id) do
case socket.assigns.active_tab do
"overview" -> assign_overview_data(socket, device_id)
"ports" -> assign_ports_data(socket)
"logs" -> assign_logs_data(socket, device_id)
"backups" -> assign_backups_data(socket, device_id)
# neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base
_ -> socket
end
end
# -- Focused data loaders --
# Base data needed by all tabs: device record, agent info, SNMP device/sensors/interfaces.
defp assign_base_data(socket, device_id) do
device = Devices.get_device!(device_id) device = Devices.get_device!(device_id)
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]]) device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations) {agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
agent_info = load_agent_info(agent_token_id, agent_source) agent_info = load_agent_info(agent_token_id, agent_source)
recent_checks = Monitoring.list_devices_checks(device_id, 50)
snmp_data = load_snmp_data(device_id) snmp_data = load_snmp_data(device_id)
events = Devices.list_devices_events(device_id, 100)
socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:agent_info, agent_info)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
end
# Data needed for the tab navigation bar to decide which tabs to show.
# Neighbors, ARP, MAC, VLANs, and IP addresses determine tab visibility.
defp assign_tab_nav_data(socket, device_id) do
snmp_device = socket.assigns.snmp_device
neighbors = Snmp.list_neighbors_with_equipment(device_id) neighbors = Snmp.list_neighbors_with_equipment(device_id)
arp_entries = Snmp.list_arp_entries(device_id) arp_entries = Snmp.list_arp_entries(device_id)
mac_addresses = Snmp.list_mac_addresses(device_id) mac_addresses = Snmp.list_mac_addresses(device_id)
vlans = load_vlans(snmp_data.device) vlans = load_vlans(snmp_device)
ip_addresses = load_ip_addresses(snmp_data.device) ip_addresses = load_ip_addresses(snmp_device)
processors = load_processors(snmp_data.device)
storage = load_storage(snmp_data.device) socket
|> assign(:neighbors, neighbors)
|> assign(:arp_entries, arp_entries)
|> assign(:mac_addresses, mac_addresses)
|> assign(:vlans, vlans)
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
end
# Overview tab: charts, sensor classifications, metrics, processors, storage, firmware.
# This is the most expensive loader due to time-series chart data queries.
defp assign_overview_data(socket, device_id) do
device = socket.assigns.device
snmp_device = socket.assigns.snmp_device
sensors = socket.assigns.snmp_sensors
recent_checks = Monitoring.list_devices_checks(device_id, 50)
processors = load_processors(snmp_device)
storage = load_storage(snmp_device)
latency_chart_data = load_latency_chart_data(device_id) latency_chart_data = load_latency_chart_data(device_id)
processor_chart_data = load_processor_chart_data(processors) processor_chart_data = load_processor_chart_data(processors)
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"]) memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
@ -242,7 +333,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
storage_volume_chart_data = load_storage_volume_chart_data(storage) storage_volume_chart_data = load_storage_volume_chart_data(storage)
traffic_chart_data = load_overall_traffic_chart_data(device_id) traffic_chart_data = load_overall_traffic_chart_data(device_id)
# Wireless sensor chart data
wireless_chart_data = wireless_chart_data =
load_sensor_chart_data(device_id, [ load_sensor_chart_data(device_id, [
"frequency", "frequency",
@ -257,81 +347,35 @@ defmodule ToweropsWeb.DeviceLive.Show do
"utilization" "utilization"
]) ])
# Filter sensors by type for temperature, voltage, storage, count, and transceivers # Classify sensors by type
# Separate transceiver sensors from general sensors
{transceiver_sensors, non_transceiver_sensors} = {transceiver_sensors, non_transceiver_sensors} =
Enum.split_with(snmp_data.sensors, fn sensor -> Enum.split_with(sensors, fn sensor ->
sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp") sensor.sensor_descr && String.contains?(String.downcase(sensor.sensor_descr), "sfp")
end) end)
temperature_sensors = temperature_sensors = Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1) voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1)
current_sensors = Enum.filter(non_transceiver_sensors, &current_sensor?/1) current_sensors = Enum.filter(non_transceiver_sensors, &current_sensor?/1)
power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1) power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1)
fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1) fan_sensors = Enum.filter(non_transceiver_sensors, &fan_sensor?/1)
load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1) load_sensors = Enum.filter(non_transceiver_sensors, &load_sensor?/1)
storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"])) storage_sensors = Enum.filter(non_transceiver_sensors, &(&1.sensor_type in ["disk_usage"]))
# Include various counter/gauge sensor types in the Counters section
count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1) count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1)
# Split count sensors into firewall and general
{firewall_counters, general_counters} = {firewall_counters, general_counters} =
Enum.split_with(count_sensors, &firewall_counter?/1) Enum.split_with(count_sensors, &firewall_counter?/1)
# Wireless sensors (frequency, power, rssi, ccq, etc.)
wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1) wireless_sensors = Enum.filter(non_transceiver_sensors, &wireless_sensor?/1)
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1) signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1)
# Group transceiver sensors by transceiver name
grouped_transceivers = group_transceiver_sensors(transceiver_sensors) grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, device) metrics = calculate_metrics(recent_checks, device)
available_firmware = get_available_firmware(snmp_device)
# Group interfaces by type for the ports tab
interfaces_by_type = group_interfaces_by_type(snmp_data.interfaces)
# Check for available firmware updates
available_firmware = get_available_firmware(snmp_data.device)
# Load MikroTik backups if device has MikroTik API enabled
mikrotik_backups =
if device.mikrotik_enabled do
MikrotikBackups.list_device_backups(device_id, limit: 50)
else
[]
end
socket socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:recent_checks, recent_checks) |> assign(:recent_checks, recent_checks)
|> assign(:metrics, metrics) |> assign(:metrics, metrics)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:interfaces_by_type, interfaces_by_type)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign(:neighbors, neighbors)
|> assign(:arp_entries, arp_entries)
|> assign(:mac_addresses, mac_addresses)
|> assign(:vlans, vlans)
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:ip_addresses_by_interface, group_ip_addresses_by_interface(ip_addresses))
|> assign(:processors, processors) |> assign(:processors, processors)
|> assign(:storage, storage) |> assign(:storage, storage)
|> assign(:events, events)
|> assign(:latency_chart_data, latency_chart_data) |> assign(:latency_chart_data, latency_chart_data)
|> assign(:processor_chart_data, processor_chart_data) |> assign(:processor_chart_data, processor_chart_data)
|> assign(:memory_chart_data, memory_chart_data) |> assign(:memory_chart_data, memory_chart_data)
@ -352,8 +396,36 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:wireless_sensors, wireless_sensors) |> assign(:wireless_sensors, wireless_sensors)
|> assign(:signal_sensors, signal_sensors) |> assign(:signal_sensors, signal_sensors)
|> assign(:grouped_transceivers, grouped_transceivers) |> assign(:grouped_transceivers, grouped_transceivers)
|> assign(:agent_info, agent_info)
|> assign(:available_firmware, available_firmware) |> assign(:available_firmware, available_firmware)
end
# Ports tab: interfaces grouped by type.
# Uses snmp_interfaces already loaded in base data.
defp assign_ports_data(socket) do
interfaces = socket.assigns.snmp_interfaces
interfaces_by_type = group_interfaces_by_type(interfaces)
assign(socket, :interfaces_by_type, interfaces_by_type)
end
# Logs/events tab data.
defp assign_logs_data(socket, device_id) do
events = Devices.list_devices_events(device_id, 100)
assign(socket, :events, events)
end
# Backups tab data.
defp assign_backups_data(socket, device_id) do
device = socket.assigns.device
mikrotik_backups =
if device.mikrotik_enabled do
MikrotikBackups.list_device_backups(device_id, limit: 50)
else
[]
end
socket
|> assign(:mikrotik_backups, mikrotik_backups) |> assign(:mikrotik_backups, mikrotik_backups)
|> assign(:selected_backup_ids, MapSet.new()) |> assign(:selected_backup_ids, MapSet.new())
end end

View file

@ -0,0 +1,39 @@
defmodule Towerops.Repo.Migrations.AddPerformanceIndexes do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def change do
# Partial index for monitored sensors (WHERE monitored = true)
# Used by Snmp.list_monitored_sensors/1
create index(:snmp_sensors, [:monitored],
where: "monitored = true",
name: :snmp_sensors_monitored_true_idx,
concurrently: true
)
# Partial index for monitored interfaces (WHERE monitored = true)
# Used by Snmp.list_monitored_interfaces/1
create index(:snmp_interfaces, [:monitored],
where: "monitored = true",
name: :snmp_interfaces_monitored_true_idx,
concurrently: true
)
# Composite index for devices filtered by org + status
# Used by Devices.list_organization_devices/2 with status filter
create index(:devices, [:organization_id, :status],
name: :devices_organization_id_status_idx,
concurrently: true
)
# Partial index for active (unresolved) alerts
# Used by Alerts.list_organization_active_alerts/1
create index(:alerts, [:resolved_at],
where: "resolved_at IS NULL",
name: :alerts_active_idx,
concurrently: true
)
end
end

View file

@ -0,0 +1,129 @@
defmodule Towerops.Devices.DeviceStatusCountsTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Devices
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site}
end
describe "get_device_status_counts/1" do
test "returns empty map when no devices exist", %{organization: organization} do
assert Devices.get_device_status_counts(organization.id) == %{}
end
test "returns correct counts for mixed statuses", %{organization: organization, site: site} do
# Create devices and set their statuses
for i <- 1..3 do
{:ok, device} =
Devices.create_device(%{
name: "Up Device #{i}",
ip_address: "192.168.1.#{i}",
site_id: site.id,
organization_id: organization.id
})
Devices.update_device_status(device, :up)
end
for i <- 1..2 do
{:ok, device} =
Devices.create_device(%{
name: "Down Device #{i}",
ip_address: "192.168.2.#{i}",
site_id: site.id,
organization_id: organization.id
})
Devices.update_device_status(device, :down)
end
{:ok, _unknown_device} =
Devices.create_device(%{
name: "Unknown Device",
ip_address: "192.168.3.1",
site_id: site.id,
organization_id: organization.id
})
# Unknown is the default status, so no need to update
counts = Devices.get_device_status_counts(organization.id)
assert counts == %{up: 3, down: 2, unknown: 1}
end
test "only counts devices for the given organization", %{organization: organization, site: site} do
# Create device in our org
{:ok, device} =
Devices.create_device(%{
name: "Our Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
Devices.update_device_status(device, :up)
# Create device in a different org
other_user = user_fixture()
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, other_user.id)
{:ok, other_site} =
Towerops.Sites.create_site(%{
name: "Other Site",
organization_id: other_org.id
})
{:ok, other_device} =
Devices.create_device(%{
name: "Other Device",
ip_address: "192.168.1.1",
site_id: other_site.id,
organization_id: other_org.id
})
Devices.update_device_status(other_device, :down)
# Our org should only see its own device
counts = Devices.get_device_status_counts(organization.id)
assert counts == %{up: 1}
# Other org should only see its own device
other_counts = Devices.get_device_status_counts(other_org.id)
assert other_counts == %{down: 1}
end
test "returns correct counts after status changes", %{organization: organization, site: site} do
{:ok, device} =
Devices.create_device(%{
name: "Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# Initially unknown
assert Devices.get_device_status_counts(organization.id) == %{unknown: 1}
# Change to up
{:ok, device} = Devices.update_device_status(device, :up)
assert Devices.get_device_status_counts(organization.id) == %{up: 1}
# Change to down
{:ok, _device} = Devices.update_device_status(device, :down)
assert Devices.get_device_status_counts(organization.id) == %{down: 1}
end
end
end

View file

@ -0,0 +1,459 @@
defmodule Towerops.Snmp.BatchInsertTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
alias Towerops.Repo
alias Towerops.Snmp
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.Processor
alias Towerops.Snmp.ProcessorReading
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
site_id: site.id,
organization_id: organization.id
})
snmp_device =
%Device{}
|> Device.changeset(%{
device_id: device.id,
sys_name: "test-router",
sys_descr: "Test Device"
})
|> Repo.insert!()
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "temp_1",
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.4.1"
})
|> Repo.insert!()
sensor2 =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "voltage",
sensor_index: "volt_1",
sensor_oid: "1.3.6.1.4.1.9.9.91.1.1.1.1.4.2"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0"
})
|> Repo.insert!()
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1"
})
|> Repo.insert!()
processor =
%Processor{}
|> Processor.changeset(%{
snmp_device_id: snmp_device.id,
processor_index: "1",
processor_type: "hr_processor",
description: "CPU 1"
})
|> Repo.insert!()
processor2 =
%Processor{}
|> Processor.changeset(%{
snmp_device_id: snmp_device.id,
processor_index: "2",
processor_type: "hr_processor",
description: "CPU 2"
})
|> Repo.insert!()
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage2 =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
%{
snmp_device: snmp_device,
sensor: sensor,
sensor2: sensor2,
interface: interface,
interface2: interface2,
processor: processor,
processor2: processor2,
storage: storage,
storage2: storage2
}
end
describe "create_sensor_readings_batch/1" do
test "inserts multiple sensor readings", %{sensor: sensor, sensor2: sensor2} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{sensor_id: sensor.id, value: 25.5, status: "ok", checked_at: now},
%{sensor_id: sensor2.id, value: 3.3, status: "warning", checked_at: now}
]
assert {2, nil} = Snmp.create_sensor_readings_batch(entries)
readings = Repo.all(SensorReading)
assert length(readings) == 2
reading1 = Enum.find(readings, &(&1.sensor_id == sensor.id))
assert reading1.value == 25.5
assert reading1.status == "ok"
assert reading1.checked_at == now
assert reading1.id
assert reading1.inserted_at
reading2 = Enum.find(readings, &(&1.sensor_id == sensor2.id))
assert reading2.value == 3.3
assert reading2.status == "warning"
end
test "returns {0, nil} for empty list" do
assert {0, nil} = Snmp.create_sensor_readings_batch([])
assert Repo.all(SensorReading) == []
end
test "generates unique UUIDs for each entry", %{sensor: sensor} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{sensor_id: sensor.id, value: 10.0, status: "ok", checked_at: now},
%{sensor_id: sensor.id, value: 11.0, status: "ok", checked_at: now}
]
{2, nil} = Snmp.create_sensor_readings_batch(entries)
readings = Repo.all(SensorReading)
ids = Enum.map(readings, & &1.id)
assert length(Enum.uniq(ids)) == 2
end
test "handles optional state_descr field", %{sensor: sensor} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{
sensor_id: sensor.id,
value: 25.5,
status: "ok",
state_descr: "Normal operating temperature",
checked_at: now
}
]
assert {1, nil} = Snmp.create_sensor_readings_batch(entries)
[reading] = Repo.all(SensorReading)
assert reading.state_descr == "Normal operating temperature"
end
test "handles entries with nil value", %{sensor: sensor} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{sensor_id: sensor.id, value: nil, status: "error", checked_at: now}
]
assert {1, nil} = Snmp.create_sensor_readings_batch(entries)
[reading] = Repo.all(SensorReading)
assert reading.value == nil
assert reading.status == "error"
end
end
describe "create_interface_stats_batch/1" do
test "inserts multiple interface stats", %{interface: interface, interface2: interface2} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{
interface_id: interface.id,
if_in_octets: 1000,
if_out_octets: 2000,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
checked_at: now
},
%{
interface_id: interface2.id,
if_in_octets: 5000,
if_out_octets: 6000,
if_in_errors: 1,
if_out_errors: 2,
if_in_discards: 3,
if_out_discards: 4,
checked_at: now
}
]
assert {2, nil} = Snmp.create_interface_stats_batch(entries)
stats = Repo.all(InterfaceStat)
assert length(stats) == 2
stat1 = Enum.find(stats, &(&1.interface_id == interface.id))
assert stat1.if_in_octets == 1000
assert stat1.if_out_octets == 2000
assert stat1.if_in_errors == 0
assert stat1.if_out_errors == 0
assert stat1.if_in_discards == 0
assert stat1.if_out_discards == 0
assert stat1.checked_at == now
assert stat1.id
assert stat1.inserted_at
stat2 = Enum.find(stats, &(&1.interface_id == interface2.id))
assert stat2.if_in_octets == 5000
assert stat2.if_out_octets == 6000
assert stat2.if_in_errors == 1
assert stat2.if_out_errors == 2
end
test "returns {0, nil} for empty list" do
assert {0, nil} = Snmp.create_interface_stats_batch([])
assert Repo.all(InterfaceStat) == []
end
test "generates unique UUIDs for each entry", %{interface: interface} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{interface_id: interface.id, if_in_octets: 100, if_out_octets: 200, checked_at: now},
%{interface_id: interface.id, if_in_octets: 300, if_out_octets: 400, checked_at: now}
]
{2, nil} = Snmp.create_interface_stats_batch(entries)
stats = Repo.all(InterfaceStat)
ids = Enum.map(stats, & &1.id)
assert length(Enum.uniq(ids)) == 2
end
test "handles entries with nil counter values", %{interface: interface} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{interface_id: interface.id, if_in_octets: nil, if_out_octets: nil, checked_at: now}
]
assert {1, nil} = Snmp.create_interface_stats_batch(entries)
[stat] = Repo.all(InterfaceStat)
assert stat.if_in_octets == nil
assert stat.if_out_octets == nil
end
end
describe "create_processor_readings_batch/1" do
test "inserts multiple processor readings", %{processor: processor, processor2: processor2} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{processor_id: processor.id, load_percent: 45.0, status: "ok", checked_at: now},
%{processor_id: processor2.id, load_percent: 92.0, status: "critical", checked_at: now}
]
assert {2, nil} = Snmp.create_processor_readings_batch(entries)
readings = Repo.all(ProcessorReading)
assert length(readings) == 2
reading1 = Enum.find(readings, &(&1.processor_id == processor.id))
assert reading1.load_percent == 45.0
assert reading1.status == "ok"
assert reading1.checked_at == now
assert reading1.id
assert reading1.inserted_at
reading2 = Enum.find(readings, &(&1.processor_id == processor2.id))
assert reading2.load_percent == 92.0
assert reading2.status == "critical"
end
test "returns {0, nil} for empty list" do
assert {0, nil} = Snmp.create_processor_readings_batch([])
assert Repo.all(ProcessorReading) == []
end
test "generates unique UUIDs for each entry", %{processor: processor} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{processor_id: processor.id, load_percent: 10.0, status: "ok", checked_at: now},
%{processor_id: processor.id, load_percent: 20.0, status: "ok", checked_at: now}
]
{2, nil} = Snmp.create_processor_readings_batch(entries)
readings = Repo.all(ProcessorReading)
ids = Enum.map(readings, & &1.id)
assert length(Enum.uniq(ids)) == 2
end
test "handles entries with nil load_percent", %{processor: processor} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{processor_id: processor.id, load_percent: nil, status: "error", checked_at: now}
]
assert {1, nil} = Snmp.create_processor_readings_batch(entries)
[reading] = Repo.all(ProcessorReading)
assert reading.load_percent == nil
assert reading.status == "error"
end
end
describe "create_storage_readings_batch/1" do
test "inserts multiple storage readings", %{storage: storage, storage2: storage2} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{
storage_id: storage.id,
used_bytes: 5_000_000,
total_bytes: 10_000_000,
usage_percent: 50.0,
checked_at: now
},
%{
storage_id: storage2.id,
used_bytes: 2_000_000,
total_bytes: 8_000_000,
usage_percent: 25.0,
checked_at: now
}
]
assert {2, nil} = Snmp.create_storage_readings_batch(entries)
readings = Repo.all(StorageReading)
assert length(readings) == 2
reading1 = Enum.find(readings, &(&1.storage_id == storage.id))
assert reading1.used_bytes == 5_000_000
assert reading1.total_bytes == 10_000_000
assert reading1.usage_percent == 50.0
assert reading1.checked_at == now
assert reading1.id
assert reading1.inserted_at
reading2 = Enum.find(readings, &(&1.storage_id == storage2.id))
assert reading2.used_bytes == 2_000_000
assert reading2.total_bytes == 8_000_000
assert reading2.usage_percent == 25.0
end
test "returns {0, nil} for empty list" do
assert {0, nil} = Snmp.create_storage_readings_batch([])
assert Repo.all(StorageReading) == []
end
test "generates unique UUIDs for each entry", %{storage: storage} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 2000,
usage_percent: 50.0,
checked_at: now
},
%{
storage_id: storage.id,
used_bytes: 1500,
total_bytes: 2000,
usage_percent: 75.0,
checked_at: now
}
]
{2, nil} = Snmp.create_storage_readings_batch(entries)
readings = Repo.all(StorageReading)
ids = Enum.map(readings, & &1.id)
assert length(Enum.uniq(ids)) == 2
end
test "handles entries with nil optional fields", %{storage: storage} do
now = DateTime.truncate(DateTime.utc_now(), :second)
entries = [
%{
storage_id: storage.id,
used_bytes: nil,
total_bytes: nil,
usage_percent: nil,
checked_at: now
}
]
assert {1, nil} = Snmp.create_storage_readings_batch(entries)
[reading] = Repo.all(StorageReading)
assert reading.used_bytes == nil
assert reading.total_bytes == nil
assert reading.usage_percent == nil
end
end
end