fix mikrotik sensors

This commit is contained in:
Graham McIntire 2026-01-22 18:07:20 -06:00
parent 639ffd4a2c
commit f402dcb7af
No known key found for this signature in database
9 changed files with 673 additions and 6 deletions

View file

@ -22,6 +22,7 @@ defmodule Towerops.Snmp do
alias Towerops.Snmp.SensorReading alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.StateSensor alias Towerops.Snmp.StateSensor
alias Towerops.Snmp.Storage alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
alias Towerops.Snmp.Vlan alias Towerops.Snmp.Vlan
@doc """ @doc """
@ -662,4 +663,83 @@ defmodule Towerops.Snmp do
|> Storage.changeset(attrs) |> Storage.changeset(attrs)
|> Repo.update() |> Repo.update()
end end
# Storage reading queries
@doc """
Gets recent storage readings for a storage entry.
## Options
- `:limit` - Maximum number of readings to return (default: 100)
- `:since` - Only return readings after this datetime
"""
def get_storage_readings(storage_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
since = Keyword.get(opts, :since)
query =
StorageReading
|> where([r], r.storage_id == ^storage_id)
|> order_by([r], desc: r.checked_at)
|> limit(^limit)
query =
if since do
where(query, [r], r.checked_at >= ^since)
else
query
end
Repo.all(query)
end
@doc """
Gets the latest storage reading for a storage entry.
"""
def get_latest_storage_reading(storage_id) do
StorageReading
|> where([r], r.storage_id == ^storage_id)
|> order_by([r], desc: r.checked_at)
|> limit(1)
|> Repo.one()
end
@doc """
Gets the latest storage readings for multiple storage entries in a single query.
Returns a map of %{storage_id => reading} for efficient batch loading.
Storage entries without readings will not be present in the map.
## Examples
iex> get_latest_storage_readings_batch([storage_id1, storage_id2])
%{storage_id1 => %StorageReading{}, storage_id2 => %StorageReading{}}
"""
def get_latest_storage_readings_batch(storage_ids) when is_list(storage_ids) do
if Enum.empty?(storage_ids) do
%{}
else
# Use DISTINCT ON to get only the latest reading per storage
query =
from(r in StorageReading,
where: r.storage_id in ^storage_ids,
distinct: r.storage_id,
order_by: [asc: r.storage_id, desc: r.checked_at]
)
query
|> Repo.all()
|> Map.new(fn reading -> {reading.storage_id, reading} end)
end
end
@doc """
Records a new storage reading.
"""
def create_storage_reading(attrs) do
%StorageReading{}
|> StorageReading.changeset(attrs)
|> Repo.insert()
end
end end

View file

@ -169,6 +169,9 @@ defmodule Towerops.Snmp.PollerWorker do
end), end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn -> Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_processors(device, snmp_device, client_opts, now) poll_device_processors(device, snmp_device, client_opts, now)
end),
Task.Supervisor.async_nolink(PollerTaskSupervisor, fn ->
poll_device_storage(device, snmp_device, client_opts, now)
end) end)
] ]
@ -285,6 +288,86 @@ defmodule Towerops.Snmp.PollerWorker do
Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") Logger.error("Error polling processors for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end end
defp poll_device_storage(device, snmp_device, client_opts, now) do
storage_entries = snmp_device.storage || []
if storage_entries != [] do
poll_storage(storage_entries, client_opts, now)
Logger.debug("Polled #{length(storage_entries)} storage entries for #{device.name}")
# Broadcast storage update event to device-specific topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:storage_updated, device.id}
)
end
rescue
error ->
Logger.error("Error polling storage for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}")
end
defp poll_storage(storage_entries, client_opts, timestamp) do
# Poll storage entries in parallel with max_concurrency
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()
end
defp poll_storage_value(storage, client_opts) do
# HOST-RESOURCES-MIB storage OIDs
used_oid = "1.3.6.1.2.1.25.2.3.1.6.#{storage.storage_index}"
size_oid = "1.3.6.1.2.1.25.2.3.1.5.#{storage.storage_index}"
alloc_oid = "1.3.6.1.2.1.25.2.3.1.4.#{storage.storage_index}"
with {:ok, used_units} <- Client.get(client_opts, used_oid),
{:ok, size_units} <- Client.get(client_opts, size_oid),
{:ok, alloc_units} <- Client.get(client_opts, alloc_oid),
true <- is_integer(used_units) and is_integer(size_units) and is_integer(alloc_units),
true <- size_units > 0 do
# Calculate bytes from allocation units
used_bytes = used_units * alloc_units
total_bytes = size_units * alloc_units
usage_percent = used_bytes / total_bytes * 100
{:ok, %{used_bytes: used_bytes, total_bytes: total_bytes, usage_percent: usage_percent}}
else
{:error, reason} -> {:error, reason}
false -> {:error, :invalid_values}
_ -> {:error, :unknown}
end
end
defp handle_storage_poll_result(storage, {:ok, values}, timestamp) do
# Create a reading
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
})
# Update storage entry with latest values
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
# Poll processors in parallel with max_concurrency # Poll processors in parallel with max_concurrency
processors processors

View file

@ -585,7 +585,9 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
unit_type = get_int_value(values, 4) unit_type = get_int_value(values, 4)
if name && value do if name && value do
{sensor_type, sensor_unit, divisor} = gauge_unit_to_sensor_type(unit_type) # Get base type from unit, then check if name suggests different type
base_type = gauge_unit_to_sensor_type(unit_type)
{sensor_type, sensor_unit, divisor} = maybe_override_sensor_type(base_type, name)
[ [
%{ %{
@ -612,6 +614,42 @@ defmodule Towerops.Snmp.Profiles.Vendors.Routeros do
defp gauge_unit_to_sensor_type(@gauge_unit_status), do: {"state", "", 1} defp gauge_unit_to_sensor_type(@gauge_unit_status), do: {"state", "", 1}
defp gauge_unit_to_sensor_type(_), do: {"unknown", "", 1} defp gauge_unit_to_sensor_type(_), do: {"unknown", "", 1}
# Override sensor type based on name when the device reports incorrect unit type
# This handles cases where MikroTik devices report wrong mtxrGaugeUnit values
defp maybe_override_sensor_type({sensor_type, _unit, _divisor} = type_info, name) when is_binary(name) do
name_lower = String.downcase(name)
inferred_type = infer_type_from_name(name_lower)
case {sensor_type, inferred_type} do
# Type matches or no override needed
{type, type} -> type_info
{_, nil} -> type_info
# Override with inferred type
{_, inferred} -> sensor_type_config(inferred)
end
end
defp maybe_override_sensor_type(type_info, _name), do: type_info
# Infer sensor type from name keywords
defp infer_type_from_name(name_lower) do
cond do
String.contains?(name_lower, "voltage") -> "voltage"
String.contains?(name_lower, ["temperature", "temp"]) -> "temperature"
String.contains?(name_lower, ["current", "ampere"]) -> "current"
String.contains?(name_lower, ["power", "watt"]) -> "power"
String.contains?(name_lower, ["fan", "rpm"]) -> "fanspeed"
true -> nil
end
end
# Sensor type configurations for overrides
defp sensor_type_config("voltage"), do: {"voltage", "V", 10}
defp sensor_type_config("temperature"), do: {"temperature", "°C", 10}
defp sensor_type_config("current"), do: {"current", "A", 1000}
defp sensor_type_config("power"), do: {"power", "W", 10}
defp sensor_type_config("fanspeed"), do: {"fanspeed", "RPM", 1}
# Discover sensors from mtxrOpticalTable (SFP/optical transceiver sensors) # Discover sensors from mtxrOpticalTable (SFP/optical transceiver sensors)
defp discover_optical_sensors(client_opts) do defp discover_optical_sensors(client_opts) do
case Client.walk(client_opts, @optical_table) do case Client.walk(client_opts, @optical_table) do

View file

@ -0,0 +1,45 @@
defmodule Towerops.Snmp.StorageReading do
@moduledoc """
Time-series storage reading schema.
Stores individual storage readings (used/total bytes, usage percent, timestamp)
collected during SNMP polling cycles.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Snmp.Storage
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_storage_readings" do
field :used_bytes, :integer
field :total_bytes, :integer
field :usage_percent, :float
field :checked_at, :utc_datetime
belongs_to :storage, Storage
timestamps(type: :utc_datetime, updated_at: false)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
used_bytes: integer() | nil,
total_bytes: integer() | nil,
usage_percent: float() | nil,
checked_at: DateTime.t(),
storage_id: Ecto.UUID.t(),
storage: Ecto.Association.NotLoaded.t() | Storage.t(),
inserted_at: DateTime.t()
}
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:storage_id, :used_bytes, :total_bytes, :usage_percent, :checked_at])
|> validate_required([:storage_id, :checked_at])
|> foreign_key_constraint(:storage_id)
end
end

View file

@ -118,6 +118,11 @@ defmodule ToweropsWeb.DeviceLive.Show do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)} {:noreply, load_equipment_data(socket, socket.assigns.device.id)}
end end
@impl true
def handle_info({:storage_updated, _device_id}, socket) do
{:noreply, load_equipment_data(socket, socket.assigns.device.id)}
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, load_equipment_data(socket, socket.assigns.device.id)}

View file

@ -479,9 +479,14 @@
else else
0.0 0.0
end %> end %>
<div> <.link
navigate={
~p"/devices/#{@device.id}/graph/storage_volume?storage_id=#{storage.id}"
}
class="block hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-2 px-2 py-1 rounded transition-colors"
>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300"> <span class="text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:underline">
{storage.description || "Storage #{storage.storage_index}"} {storage.description || "Storage #{storage.storage_index}"}
</span> </span>
<span class="text-sm font-medium text-gray-900 dark:text-white"> <span class="text-sm font-medium text-gray-900 dark:text-white">
@ -509,7 +514,7 @@
{String.replace(storage.storage_type || "other", "_", " ") {String.replace(storage.storage_type || "other", "_", " ")
|> String.capitalize()} |> String.capitalize()}
</div> </div>
</div> </.link>
<% end %> <% end %>
</div> </div>
</div> </div>

View file

@ -21,6 +21,7 @@ defmodule ToweropsWeb.GraphLive.Show do
range = Map.get(params, "range", "24h") range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id") interface_id = Map.get(params, "interface_id")
sensor_id = Map.get(params, "sensor_id") sensor_id = Map.get(params, "sensor_id")
storage_id = Map.get(params, "storage_id")
socket = socket =
socket socket
@ -29,6 +30,7 @@ defmodule ToweropsWeb.GraphLive.Show do
|> assign(:range, range) |> assign(:range, range)
|> assign(:interface_id, interface_id) |> assign(:interface_id, interface_id)
|> assign(:sensor_id, sensor_id) |> assign(:sensor_id, sensor_id)
|> assign(:storage_id, storage_id)
|> load_graph_data() |> load_graph_data()
{:noreply, socket} {:noreply, socket}
@ -54,11 +56,18 @@ defmodule ToweropsWeb.GraphLive.Show do
base_params base_params
end end
base_params =
if assigns.sensor_id do if assigns.sensor_id do
Map.put(base_params, "sensor_id", assigns.sensor_id) Map.put(base_params, "sensor_id", assigns.sensor_id)
else else
base_params base_params
end end
if assigns.storage_id do
Map.put(base_params, "storage_id", assigns.storage_id)
else
base_params
end
end end
# Private functions # Private functions
@ -69,6 +78,7 @@ defmodule ToweropsWeb.GraphLive.Show do
range = socket.assigns.range range = socket.assigns.range
interface_id = socket.assigns.interface_id interface_id = socket.assigns.interface_id
sensor_id = socket.assigns.sensor_id sensor_id = socket.assigns.sensor_id
storage_id = socket.assigns.storage_id
device = Devices.get_device!(device_id) device = Devices.get_device!(device_id)
@ -83,6 +93,9 @@ defmodule ToweropsWeb.GraphLive.Show do
sensor_type == "traffic" -> sensor_type == "traffic" ->
{load_traffic_chart_data(device_id, range), nil} {load_traffic_chart_data(device_id, range), nil}
sensor_type == "storage_volume" && storage_id ->
{load_storage_chart_data(storage_id, range), get_storage_name(storage_id)}
sensor_id -> sensor_id ->
# Load only the specific sensor when sensor_id is provided # Load only the specific sensor when sensor_id is provided
{load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)} {load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)}
@ -119,6 +132,7 @@ defmodule ToweropsWeb.GraphLive.Show do
defp get_chart_config("processors"), do: {"Processor Usage", "%", false} defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
defp get_chart_config("memory"), do: {"Memory Usage", "%", false} defp get_chart_config("memory"), do: {"Memory Usage", "%", false}
defp get_chart_config("storage"), do: {"Storage Usage", "%", false} defp get_chart_config("storage"), do: {"Storage Usage", "%", false}
defp get_chart_config("storage_volume"), do: {"Storage Usage", "%", false}
defp get_chart_config("temperature"), do: {"Temperature", "°C", true} defp get_chart_config("temperature"), do: {"Temperature", "°C", true}
defp get_chart_config("voltage"), do: {"Voltage", "V", true} defp get_chart_config("voltage"), do: {"Voltage", "V", true}
defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true} defp get_chart_config("traffic"), do: {"Overall Traffic", "bps", true}
@ -165,6 +179,45 @@ defmodule ToweropsWeb.GraphLive.Show do
end end
end end
defp load_storage_chart_data(storage_id, range) do
case Snmp.get_storage(storage_id) do
nil ->
nil
storage ->
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
dataset = storage_to_dataset(storage, since, limit)
Jason.encode!(%{datasets: [dataset]})
end
end
defp get_storage_name(storage_id) do
case Snmp.get_storage(storage_id) do
nil -> nil
storage -> storage.description || "Storage #{storage.storage_index}"
end
end
defp storage_to_dataset(storage, since, limit) do
readings =
storage.id
|> Snmp.get_storage_readings(since: since, limit: limit)
|> Enum.reverse()
%{
label: storage.description || "Storage #{storage.storage_index}",
data: Enum.map(readings, &storage_reading_to_chart_point/1)
}
end
defp storage_reading_to_chart_point(reading) do
%{
x: DateTime.to_unix(reading.checked_at, :millisecond),
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
}
end
defp build_sensor_chart_json(device, sensor_types, range) do defp build_sensor_chart_json(device, sensor_types, range) do
sensors = sensors =
device.sensors device.sensors

View file

@ -0,0 +1,28 @@
defmodule Towerops.Repo.Migrations.AddSnmpStorageReadings do
use Ecto.Migration
def up do
create table(:snmp_storage_readings, primary_key: false) do
add :id, :binary_id, primary_key: true
add :storage_id, references(:snmp_storage, type: :binary_id, on_delete: :delete_all),
null: false
add :used_bytes, :bigint
add :total_bytes, :bigint
add :usage_percent, :float
add :checked_at, :utc_datetime, null: false
timestamps(type: :utc_datetime, updated_at: false)
end
# Create indexes for query performance
create index(:snmp_storage_readings, [:storage_id])
create index(:snmp_storage_readings, [:storage_id, :checked_at])
create index(:snmp_storage_readings, [:checked_at])
end
def down do
drop table(:snmp_storage_readings)
end
end

View file

@ -11,6 +11,8 @@ defmodule Towerops.SnmpTest do
alias Towerops.Snmp.Sensor alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.SnmpMock alias Towerops.Snmp.SnmpMock
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
setup do setup do
user = user_fixture() user = user_fixture()
@ -1309,4 +1311,332 @@ defmodule Towerops.SnmpTest do
assert Snmp.get_neighbor(neighbor2.id) assert Snmp.get_neighbor(neighbor2.id)
end end
end end
describe "get_storage_readings/2" do
test "returns recent readings for a storage entry", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..5 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id)
assert length(readings) == 5
end
test "respects limit option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
for i <- 1..10 do
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000 * i,
total_bytes: 10_000,
usage_percent: 10.0 * i,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
end
readings = Snmp.get_storage_readings(storage.id, limit: 3)
assert length(readings) == 3
end
test "respects since option", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
past = DateTime.add(DateTime.utc_now(), -3600, :second)
recent = DateTime.add(DateTime.utc_now(), -60, :second)
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: past
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: recent
})
|> Repo.insert!()
since_time = DateTime.add(DateTime.utc_now(), -120, :second)
readings = Snmp.get_storage_readings(storage.id, since: since_time)
assert length(readings) == 1
assert hd(readings).usage_percent == 50.0
end
test "returns empty list for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_storage_readings(storage.id) == []
end
end
describe "get_latest_storage_reading/1" do
test "returns most recent reading", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
latest = Snmp.get_latest_storage_reading(storage.id)
assert latest.id == new_reading.id
refute latest.id == old_reading.id
end
test "returns nil for storage with no readings", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
assert Snmp.get_latest_storage_reading(storage.id) == nil
end
end
describe "get_latest_storage_readings_batch/1" do
test "returns empty map for empty storage list" do
assert Snmp.get_latest_storage_readings_batch([]) == %{}
end
test "returns readings for multiple storage entries", %{snmp_device: snmp_device} do
storage1 =
%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!()
reading1 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage1.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
reading2 =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage2.id,
used_bytes: 2000,
total_bytes: 8000,
usage_percent: 25.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage1.id, storage2.id])
assert map_size(batch) == 2
assert batch[storage1.id].id == reading1.id
assert batch[storage2.id].id == reading2.id
end
test "returns only latest reading per storage", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
_old_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 1000,
total_bytes: 10_000,
usage_percent: 10.0,
checked_at: DateTime.add(DateTime.utc_now(), -3600, :second)
})
|> Repo.insert!()
new_reading =
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage.id])
assert map_size(batch) == 1
assert batch[storage.id].id == new_reading.id
assert batch[storage.id].usage_percent == 50.0
end
test "excludes storage without readings", %{snmp_device: snmp_device} do
storage_with_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
storage_without_reading =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
storage_type: "ram",
description: "Physical Memory"
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage_with_reading.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
|> Repo.insert!()
batch = Snmp.get_latest_storage_readings_batch([storage_with_reading.id, storage_without_reading.id])
assert map_size(batch) == 1
assert Map.has_key?(batch, storage_with_reading.id)
refute Map.has_key?(batch, storage_without_reading.id)
end
end
describe "create_storage_reading/1" do
test "creates a storage reading with valid attributes", %{snmp_device: snmp_device} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
storage_type: "fixed_disk",
description: "/dev/sda1"
})
|> Repo.insert!()
{:ok, reading} =
Snmp.create_storage_reading(%{
storage_id: storage.id,
used_bytes: 5000,
total_bytes: 10_000,
usage_percent: 50.0,
checked_at: DateTime.utc_now()
})
assert reading.storage_id == storage.id
assert reading.used_bytes == 5000
assert reading.total_bytes == 10_000
assert reading.usage_percent == 50.0
end
test "returns error with invalid attributes" do
{:error, changeset} = Snmp.create_storage_reading(%{used_bytes: 1000})
assert changeset.errors[:storage_id]
end
end
end end