fix mikrotik sensors
This commit is contained in:
parent
639ffd4a2c
commit
f402dcb7af
9 changed files with 673 additions and 6 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
40
lib/towerops/snmp/profiles/vendors/routeros.ex
vendored
40
lib/towerops/snmp/profiles/vendors/routeros.ex
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
45
lib/towerops/snmp/storage_reading.ex
Normal file
45
lib/towerops/snmp/storage_reading.ex
Normal 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
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,8 +56,15 @@ defmodule ToweropsWeb.GraphLive.Show do
|
||||||
base_params
|
base_params
|
||||||
end
|
end
|
||||||
|
|
||||||
if assigns.sensor_id do
|
base_params =
|
||||||
Map.put(base_params, "sensor_id", assigns.sensor_id)
|
if assigns.sensor_id do
|
||||||
|
Map.put(base_params, "sensor_id", assigns.sensor_id)
|
||||||
|
else
|
||||||
|
base_params
|
||||||
|
end
|
||||||
|
|
||||||
|
if assigns.storage_id do
|
||||||
|
Map.put(base_params, "storage_id", assigns.storage_id)
|
||||||
else
|
else
|
||||||
base_params
|
base_params
|
||||||
end
|
end
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue