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
========================
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)
- 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:

View file

@ -592,7 +592,10 @@ defmodule Towerops.Agents do
:agent_assignments,
: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
@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 """
Returns the list of all devices with monitoring enabled.
"""

View file

@ -372,6 +372,28 @@ defmodule Towerops.Snmp do
|> Repo.insert()
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 """
Records a new interface stat.
"""
@ -381,6 +403,28 @@ defmodule Towerops.Snmp do
|> Repo.insert()
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
@doc """
@ -1383,6 +1427,28 @@ defmodule Towerops.Snmp do
|> Repo.insert()
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
@doc """
@ -1490,6 +1556,28 @@ defmodule Towerops.Snmp do
|> Repo.insert()
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
@doc """

View file

@ -413,17 +413,54 @@ defmodule Towerops.Workers.DevicePollerWorker do
# (These remain unchanged - I'll include them all below)
defp poll_storage(storage_entries, client_opts, timestamp) do
storage_entries
|> Task.async_stream(
fn storage ->
result = poll_storage_value(storage, client_opts)
handle_storage_poll_result(storage, result, timestamp)
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
poll_results =
storage_entries
|> Task.async_stream(
fn storage ->
result = poll_storage_value(storage, client_opts)
{storage, result}
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> 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
defp poll_storage_value(storage, client_opts) do
@ -447,38 +484,35 @@ defmodule Towerops.Workers.DevicePollerWorker do
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
processors
|> Task.async_stream(
fn processor ->
result = poll_processor_value(processor, client_opts)
handle_processor_poll_result(processor, result, timestamp)
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
poll_results =
processors
|> Task.async_stream(
fn processor ->
result = poll_processor_value(processor, client_opts)
{processor, result}
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> 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
defp poll_processor_value(processor, client_opts) do
@ -533,46 +567,69 @@ defmodule Towerops.Workers.DevicePollerWorker do
end
end
defp handle_processor_poll_result(processor, {:ok, load_percent}, timestamp) do
Snmp.create_processor_reading(%{
defp build_processor_reading_entry(processor, {:ok, load_percent}, timestamp) do
%{
processor_id: processor.id,
load_percent: load_percent,
status: "ok",
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, %{
load_percent: load_percent,
last_checked_at: timestamp
})
end
defp handle_processor_poll_result(processor, {:error, reason}, timestamp) do
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 handle_processor_poll_metadata(_processor, {:error, _reason}, _timestamp), do: :ok
defp poll_sensors(sensors, client_opts, timestamp) do
sensors
|> Task.async_stream(
fn sensor ->
result = poll_sensor_value(sensor, client_opts)
handle_sensor_poll_result(sensor, result, timestamp)
sensor.sensor_descr
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Enum.each(fn
{:ok, _} -> :ok
{:exit, reason} -> Logger.warning("Sensor poll task failed: #{inspect(reason)}")
# Poll all sensors concurrently and collect results
poll_results =
sensors
|> Task.async_stream(
fn sensor ->
result = poll_sensor_value(sensor, client_opts)
{sensor, result}
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Enum.flat_map(fn
{: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
@ -720,18 +777,43 @@ defmodule Towerops.Workers.DevicePollerWorker do
end
end
defp handle_sensor_poll_result(sensor, {:ok, value}, timestamp) do
# For state sensors, map numeric value to state description
# Build a reading entry map for batch insert, or nil if not applicable
defp build_sensor_reading_entry(sensor, {:ok, value}, timestamp) do
state_descr = get_state_description(sensor, value)
Snmp.create_sensor_reading(%{
%{
sensor_id: sensor.id,
value: value,
status: "ok",
state_descr: state_descr,
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)
Snmp.update_sensor(sensor, %{
@ -740,27 +822,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
})
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 handle_sensor_poll_metadata(_sensor, {:error, _reason}, _timestamp), do: :ok
# Get state description for state sensors from metadata
defp get_state_description(sensor, value) do
@ -838,24 +900,27 @@ defmodule Towerops.Workers.DevicePollerWorker do
end
defp poll_interfaces(interfaces, client_opts, timestamp) do
interfaces
|> Task.async_stream(
fn interface ->
{:ok, stat_data} = get_interface_stats(client_opts, interface.if_index)
entries =
interfaces
|> Task.async_stream(
fn interface ->
{:ok, stat_data} = get_interface_stats(client_opts, interface.if_index)
stats =
Map.merge(stat_data, %{
interface_id: interface.id,
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)
end,
max_concurrency: 2,
timeout: 40_000,
on_timeout: :kill_task
)
|> Stream.run()
Snmp.create_interface_stats_batch(entries)
end
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
oid_values = normalize_oid_keys(result.oid_values)
# Use server time to avoid clock drift issues between agents
timestamp = DateTime.utc_now()
timestamp = DateTime.truncate(DateTime.utc_now(), :second)
Logger.info(
"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)
Enum.each(snmp_device.sensors, &process_sensor_reading(&1, oid_values, timestamp))
# Batch insert sensor readings and process metadata updates
process_sensor_readings_batch(snmp_device.sensors, oid_values, timestamp)
# Process interface stats (quick path - already in memory)
Enum.each(device.snmp_device.interfaces, &process_interface_stats(&1, oid_values, timestamp))
# Batch insert interface stats
process_interface_stats_batch(snmp_device.interfaces, oid_values, timestamp)
# Process complex SNMP data (neighbors, ARP, MAC, IP addresses, processors, storage)
# using Replay adapter to reuse existing parsing logic
@ -1576,47 +1576,69 @@ defmodule ToweropsWeb.AgentChannel do
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, ".")
case Map.get(oid_values, normalized_oid) do
nil ->
:ok
value ->
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
with value when not is_nil(value) <- Map.get(oid_values, normalized_oid),
parsed when not is_nil(parsed) <- parse_float(value) do
Float.round(parsed / sensor.sensor_divisor, 1)
else
_ -> nil
end
end
defp process_interface_stats(iface, oid_values, timestamp) do
idx = iface.if_index
defp process_interface_stats_batch(interfaces, oid_values, timestamp) do
entries =
Enum.map(interfaces, fn iface ->
idx = iface.if_index
Snmp.create_interface_stat(%{
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_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_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_out_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.19.#{idx}")),
checked_at: timestamp
})
%{
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_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_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_out_discards: parse_integer(Map.get(oid_values, "1.3.6.1.2.1.2.2.1.19.#{idx}")),
checked_at: timestamp
}
end)
Snmp.create_interface_stats_batch(entries)
end
defp parse_integer(nil), do: nil

View file

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

View file

@ -31,7 +31,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
|> assign(:device_quota, %{current: current, limit: limit})}
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:pending_reload_ref, nil)}
end
@impl true
@ -210,12 +211,47 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
end
# Structural changes (create/delete) need full reload with quota recalculation
@impl true
def handle_info({event, _org_id}, socket)
when event in [:device_created, :device_updated, :device_deleted, :device_status_changed] do
def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do
{:noreply, reload_devices(socket)}
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
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
@ -227,6 +263,17 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:device_quota, %{current: current, limit: limit})
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
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)

View file

@ -96,17 +96,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
_device ->
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
@impl true
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
@impl true
def handle_info({:discovery_completed, _device_id}, socket) do
# Structural change - full reload needed
{:noreply,
socket
|> load_equipment_data(socket.assigns.device.id)
@ -115,61 +117,60 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
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
@impl true
def handle_info({:device_event, _event_attrs}, socket) do
# Device event logged (interface changes, sensor thresholds, etc.)
# Reload to update events list
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
{:noreply, reload_if_tab(socket, ["logs"])}
end
@impl true
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
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
{:noreply, reload_base_data(socket)}
else
{:noreply, socket}
end
@ -177,9 +178,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
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
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
{:noreply, reload_base_data(socket)}
else
{:noreply, socket}
end
@ -187,9 +188,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
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
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
{:noreply, reload_base_data(socket)}
else
{:noreply, socket}
end
@ -197,11 +198,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
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
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
{:noreply, socket}
end
@ -220,21 +221,111 @@ defmodule ToweropsWeb.DeviceLive.Show do
:ok
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
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_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_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)
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)
arp_entries = Snmp.list_arp_entries(device_id)
mac_addresses = Snmp.list_mac_addresses(device_id)
vlans = load_vlans(snmp_data.device)
ip_addresses = load_ip_addresses(snmp_data.device)
processors = load_processors(snmp_data.device)
storage = load_storage(snmp_data.device)
vlans = load_vlans(snmp_device)
ip_addresses = load_ip_addresses(snmp_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)
processor_chart_data = load_processor_chart_data(processors)
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)
traffic_chart_data = load_overall_traffic_chart_data(device_id)
# Wireless sensor chart data
wireless_chart_data =
load_sensor_chart_data(device_id, [
"frequency",
@ -257,81 +347,35 @@ defmodule ToweropsWeb.DeviceLive.Show do
"utilization"
])
# Filter sensors by type for temperature, voltage, storage, count, and transceivers
# Separate transceiver sensors from general sensors
# Classify sensors by type
{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")
end)
temperature_sensors =
Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
temperature_sensors = Enum.filter(non_transceiver_sensors, &temperature_sensor?/1)
voltage_sensors = Enum.filter(non_transceiver_sensors, &voltage_sensor?/1)
current_sensors = Enum.filter(non_transceiver_sensors, &current_sensor?/1)
power_sensors = Enum.filter(non_transceiver_sensors, &power_sensor?/1)
fan_sensors = Enum.filter(non_transceiver_sensors, &fan_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"]))
# Include various counter/gauge sensor types in the Counters section
count_sensors = Enum.filter(non_transceiver_sensors, &counter_sensor?/1)
# Split count sensors into firewall and general
{firewall_counters, general_counters} =
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)
# Signal quality sensors (SNR, RSSI, RSRP, etc.)
signal_sensors = Enum.filter(non_transceiver_sensors, &signal_sensor?/1)
# Group transceiver sensors by transceiver name
grouped_transceivers = group_transceiver_sensors(transceiver_sensors)
# Calculate metrics for dashboard
metrics = calculate_metrics(recent_checks, 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
available_firmware = get_available_firmware(snmp_device)
socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:recent_checks, recent_checks)
|> 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(:storage, storage)
|> assign(:events, events)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:processor_chart_data, processor_chart_data)
|> assign(:memory_chart_data, memory_chart_data)
@ -352,8 +396,36 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:wireless_sensors, wireless_sensors)
|> assign(:signal_sensors, signal_sensors)
|> assign(:grouped_transceivers, grouped_transceivers)
|> assign(:agent_info, agent_info)
|> 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(:selected_backup_ids, MapSet.new())
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