better discovery

This commit is contained in:
Graham McIntire 2026-01-22 17:40:58 -06:00
parent 96fc488023
commit 639ffd4a2c
No known key found for this signature in database
10 changed files with 316 additions and 17 deletions

View file

@ -78,7 +78,7 @@ const SensorChart: SensorChartHook = {
const ctx = canvas.getContext('2d')
if (!ctx) return
const unit = this.el.dataset.unit || '%'
const unit = this.el.dataset.unit ?? '%'
const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true'
const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true'
const range = this.el.dataset.range || '24h'

View file

@ -426,6 +426,8 @@ defmodule Towerops.Profiles.YamlProfiles do
base_oid: base_oid,
oid_name: oid_name,
sensor_descr: extract_descr(sensor_def, sensor_type),
# Store the descr MIB OID reference for walking to get actual names
descr_oid: extract_descr_oid(sensor_def),
sensor_unit: Map.get(sensor_def, "unit"),
sensor_divisor: Map.get(sensor_def, "divisor", 1),
precision: Map.get(sensor_def, "precision", 1),
@ -458,6 +460,22 @@ defmodule Towerops.Profiles.YamlProfiles do
end
end
# Extract the descr MIB OID reference when descr contains a MIB symbolic name
# This allows the dynamic profile to walk the descr OID and get actual sensor names
defp extract_descr_oid(sensor_def) do
case Map.get(sensor_def, "descr") do
descr when is_binary(descr) ->
# Remove template variables first
cleaned = descr |> String.replace(~r/\{\{ [^}]+ \}\}/, "") |> String.trim()
# If it's a MIB symbolic name (contains "::"), return it for later resolution
if String.contains?(cleaned, "::"), do: cleaned
_ ->
nil
end
end
# Extract processor/CPU sensors (like LibreNMS processors module)
defp extract_processor_oids(yaml) do
case get_in(yaml, ["modules", "processors", "data"]) do

View file

@ -21,6 +21,7 @@ defmodule Towerops.Snmp do
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.SensorReading
alias Towerops.Snmp.StateSensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Vlan
@doc """
@ -99,7 +100,7 @@ defmodule Towerops.Snmp do
def get_device_with_associations(device_id) do
Device
|> where([d], d.device_id == ^device_id)
|> preload([:interfaces, :sensors, :state_sensors, :processors])
|> preload([:interfaces, :sensors, :state_sensors, :processors, :storage])
|> Repo.one()
end
@ -633,4 +634,32 @@ defmodule Towerops.Snmp do
|> ProcessorReading.changeset(attrs)
|> Repo.insert()
end
# Storage queries
@doc """
Lists all storage entries for a device.
"""
def list_storage(snmp_device_id) do
Storage
|> where([s], s.snmp_device_id == ^snmp_device_id)
|> order_by([s], s.storage_index)
|> Repo.all()
end
@doc """
Gets a specific storage entry.
"""
def get_storage(storage_id) do
Repo.get(Storage, storage_id)
end
@doc """
Updates a storage entry.
"""
def update_storage(storage, attrs) do
storage
|> Storage.changeset(attrs)
|> Repo.update()
end
end

View file

@ -15,6 +15,7 @@ defmodule Towerops.Snmp.Device do
alias Towerops.Snmp.Processor
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.StateSensor
alias Towerops.Snmp.Storage
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -38,6 +39,7 @@ defmodule Towerops.Snmp.Device do
has_many :processors, Processor, foreign_key: :snmp_device_id
has_many :sensors, Sensor, foreign_key: :snmp_device_id
has_many :state_sensors, StateSensor, foreign_key: :snmp_device_id
has_many :storage, Storage, foreign_key: :snmp_device_id
timestamps(type: :utc_datetime)
end
@ -62,6 +64,7 @@ defmodule Towerops.Snmp.Device do
processors: NotLoaded.t() | [Processor.t()],
sensors: NotLoaded.t() | [Sensor.t()],
state_sensors: NotLoaded.t() | [StateSensor.t()],
storage: NotLoaded.t() | [Storage.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}

View file

@ -28,6 +28,7 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.Profiles.Base
alias Towerops.Snmp.Profiles.Dynamic
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Vlan
require Logger
@ -131,9 +132,11 @@ defmodule Towerops.Snmp.Discovery do
{:ok, vlans} <- discover_vlans_with_timeout(client_opts, profile, timeouts),
{:ok, ip_addresses} <- discover_ip_addresses_with_timeout(client_opts, timeouts),
{:ok, processors} <- discover_processors_with_timeout(client_opts, timeouts),
{:ok, storage} <- discover_storage_with_timeout(client_opts, timeouts),
{:ok, discovered_device} <- save_discovery_results(device, device_info, interfaces, sensors, vlans),
:ok <- sync_ip_addresses(discovered_device, ip_addresses),
:ok <- sync_processors(discovered_device, processors),
:ok <- sync_storage(discovered_device, storage),
{:ok, neighbors} <- discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts),
:ok <- save_neighbors(discovered_device.device_id, neighbors) do
_ = update_device_discovery_time(device)
@ -222,6 +225,16 @@ defmodule Towerops.Snmp.Discovery do
)
end
# Storage discovery with timeout - falls back to empty list on timeout
defp discover_storage_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_storage(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
@doc """
Runs discovery for all SNMP-enabled devices in an organization.
Returns a summary of successful and failed discoveries.
@ -762,6 +775,63 @@ defmodule Towerops.Snmp.Discovery do
:ok
end
@spec sync_storage(Device.t(), [map()]) :: :ok
defp sync_storage(device, discovered_storage) do
# Get existing storage for this device, indexed by storage_index
existing_storage =
from(s in Storage, where: s.snmp_device_id == ^device.id)
|> Repo.all()
|> Map.new(&{&1.storage_index, &1})
# Get the set of discovered storage_indices (convert string index to integer)
discovered_indices =
MapSet.new(discovered_storage, fn s ->
normalize_storage_index(s.storage_index)
end)
# Delete storage that no longer exists on the device
existing_indices = MapSet.new(Map.keys(existing_storage))
removed_indices = MapSet.difference(existing_indices, discovered_indices)
if MapSet.size(removed_indices) > 0 do
removed_list = MapSet.to_list(removed_indices)
Repo.delete_all(
from s in Storage,
where: s.snmp_device_id == ^device.id and s.storage_index in ^removed_list
)
Logger.debug("Deleted #{MapSet.size(removed_indices)} removed storage entries")
end
# Upsert each discovered storage
Enum.each(discovered_storage, fn storage_data ->
storage_index = normalize_storage_index(storage_data.storage_index)
storage_attrs = Map.put(storage_data, :storage_index, storage_index)
case Map.get(existing_storage, storage_index) do
nil ->
# New storage - insert
%Storage{}
|> Storage.changeset(Map.put(storage_attrs, :snmp_device_id, device.id))
|> Repo.insert!()
existing ->
# Existing storage - update
existing
|> Storage.changeset(storage_attrs)
|> Repo.update!()
end
end)
Logger.info("Synced #{length(discovered_storage)} storage entries for device")
:ok
end
# Normalize storage index to integer (Base.discover_storage may return string from OID parsing)
defp normalize_storage_index(index) when is_integer(index), do: index
defp normalize_storage_index(index) when is_binary(index), do: String.to_integer(index)
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
defp update_device_discovery_time(device) do

View file

@ -55,6 +55,17 @@ defmodule Towerops.Snmp.MibTranslator do
"MIKROTIK-MIB::mtxrHlBackupPowerSupplyState.0" => "1.3.6.1.4.1.14988.1.1.3.16.0",
"MIKROTIK-MIB::mtxrHlFanSpeed1.0" => "1.3.6.1.4.1.14988.1.1.3.17.0",
"MIKROTIK-MIB::mtxrHlFanSpeed2.0" => "1.3.6.1.4.1.14988.1.1.3.18.0",
# MikroTik Gauge Table (mtxrHealth.100) - sensor names and values
"MIKROTIK-MIB::mtxrGaugeName" => "1.3.6.1.4.1.14988.1.1.3.100.1.2",
"MIKROTIK-MIB::mtxrGaugeValue" => "1.3.6.1.4.1.14988.1.1.3.100.1.3",
"MIKROTIK-MIB::mtxrGaugeUnit" => "1.3.6.1.4.1.14988.1.1.3.100.1.4",
# MikroTik Optical Table (mtxrOptical) - SFP sensor names and values
"MIKROTIK-MIB::mtxrOpticalName" => "1.3.6.1.4.1.14988.1.1.19.1.1.2",
"MIKROTIK-MIB::mtxrOpticalTemperature" => "1.3.6.1.4.1.14988.1.1.19.1.1.6",
"MIKROTIK-MIB::mtxrOpticalSupplyVoltage" => "1.3.6.1.4.1.14988.1.1.19.1.1.7",
"MIKROTIK-MIB::mtxrOpticalTxBiasCurrent" => "1.3.6.1.4.1.14988.1.1.19.1.1.8",
"MIKROTIK-MIB::mtxrOpticalTxPower" => "1.3.6.1.4.1.14988.1.1.19.1.1.9",
"MIKROTIK-MIB::mtxrOpticalRxPower" => "1.3.6.1.4.1.14988.1.1.19.1.1.10",
# CISCO-ENTITY-SENSOR-MIB (enterprise 9.9.91)
"CISCO-ENTITY-SENSOR-MIB::entSensorType" => "1.3.6.1.4.1.9.9.91.1.1.1.1.1",
"CISCO-ENTITY-SENSOR-MIB::entSensorScale" => "1.3.6.1.4.1.9.9.91.1.1.1.1.2",

View file

@ -209,39 +209,66 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp walk_table_sensor(sensor_def, client_opts) do
base_oid = sensor_def[:base_oid]
descr_oid = sensor_def[:descr_oid]
case Client.walk(client_opts, base_oid) do
{:ok, results} when is_map(results) and map_size(results) > 0 ->
parse_table_walk_results(results, sensor_def)
# If there's a descr_oid, walk it to get actual sensor names
descr_map = walk_descr_oid(descr_oid, client_opts)
parse_table_walk_results(results, sensor_def, descr_map)
_ ->
[]
end
end
defp parse_table_walk_results(results, sensor_def) when is_map(results) do
# Walk the description OID to get actual sensor names (e.g., mtxrGaugeName)
defp walk_descr_oid(nil, _client_opts), do: %{}
defp walk_descr_oid(descr_mib_name, client_opts) do
with {:ok, numeric_oid} <- MibTranslator.translate(descr_mib_name),
{:ok, results} when is_map(results) <- Client.walk(client_opts, numeric_oid) do
# Build a map of index -> description
Map.new(results, fn {oid, value} ->
# Extract the index from the full OID (last component)
index = oid |> String.split(".") |> List.last()
{index, to_string(value)}
end)
else
{:error, :translation_failed} ->
Logger.debug("Failed to translate descr MIB name: #{descr_mib_name}")
%{}
_ ->
%{}
end
end
defp parse_table_walk_results(results, sensor_def, descr_map) when is_map(results) do
results
|> Enum.with_index(1)
|> Enum.map(fn {{oid, value}, idx} ->
build_table_sensor(sensor_def, oid, value, idx)
# Extract the index from the OID to look up the description
oid_index = oid |> String.split(".") |> List.last()
build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index)
end)
|> Enum.reject(&is_nil/1)
end
# Build a sensor from table walk result
defp build_table_sensor(sensor_def, oid, value, idx) when is_integer(value) do
defp build_table_sensor(sensor_def, oid, value, idx, descr_map, oid_index) when is_integer(value) do
%{
sensor_type: sensor_def[:sensor_type],
sensor_index: "#{sensor_def[:sensor_type]}_#{idx}",
sensor_oid: oid,
sensor_descr: build_sensor_descr(sensor_def, idx),
sensor_descr: build_sensor_descr(sensor_def, idx, descr_map, oid_index),
sensor_unit: sensor_def[:sensor_unit] || "",
sensor_divisor: sensor_def[:sensor_divisor] || 1,
last_value: value / 1
}
end
defp build_table_sensor(_sensor_def, _oid, _value, _idx), do: nil
defp build_table_sensor(_sensor_def, _oid, _value, _idx, _descr_map, _oid_index), do: nil
# Discover state sensors (sensors with discrete states like GPS Status, DFS Status)
defp discover_state_sensors(sensor_defs, client_opts) do
@ -279,7 +306,7 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
sensor_type: "state",
sensor_index: "#{sensor_def[:oid_name] || "state"}_#{idx}",
sensor_oid: oid,
sensor_descr: build_sensor_descr(sensor_def, idx),
sensor_descr: build_sensor_descr_default(sensor_def, idx),
sensor_unit: "",
sensor_divisor: 1,
last_value: value / 1,
@ -289,7 +316,21 @@ defmodule Towerops.Snmp.Profiles.Dynamic do
defp build_state_sensor(_sensor_def, _oid, _value, _idx), do: nil
defp build_sensor_descr(sensor_def, idx) do
# Build sensor description, using the descr_map if available for actual device names
defp build_sensor_descr(sensor_def, idx, descr_map, oid_index) do
# First, try to get the description from the descr_map (walked from device)
case Map.get(descr_map, oid_index) do
descr when is_binary(descr) and descr != "" ->
# Use the actual device name directly
descr
_ ->
# Fall back to the default description building logic
build_sensor_descr_default(sensor_def, idx)
end
end
defp build_sensor_descr_default(sensor_def, idx) do
base_descr = sensor_def[:sensor_descr] || sensor_def[:oid_name] || sensor_def[:sensor_type]
# If the description contains a MIB symbolic name (e.g., "MIKROTIK-MIB::mtxrOpticalName"),

View file

@ -141,6 +141,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
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)
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"])
@ -186,6 +187,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:ipv4_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv4")))
|> assign(:ipv6_addresses, Enum.filter(ip_addresses, &(&1.ip_type == "ipv6")))
|> assign(:processors, processors)
|> assign(:storage, storage)
|> assign(:events, events)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:processor_chart_data, processor_chart_data)
@ -238,6 +240,30 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp format_speed(_), do: "-"
defp format_bytes(nil), do: "-"
defp format_bytes(0), do: "0 B"
defp format_bytes(bytes) when is_integer(bytes) do
cond do
bytes >= 1_099_511_627_776 ->
"#{Float.round(bytes / 1_099_511_627_776, 1)} TB"
bytes >= 1_073_741_824 ->
"#{Float.round(bytes / 1_073_741_824, 1)} GB"
bytes >= 1_048_576 ->
"#{Float.round(bytes / 1_048_576, 1)} MB"
bytes >= 1024 ->
"#{Float.round(bytes / 1024, 1)} KB"
true ->
"#{bytes} B"
end
end
defp format_bytes(_), do: "-"
defp format_sensor_value(value, _divisor) when is_number(value) do
# Value is already divided by divisor when stored in sensor_reading
Float.round(value, 1)
@ -365,6 +391,9 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp load_processors(nil), do: []
defp load_processors(snmp_device), do: Snmp.list_processors(snmp_device.id)
defp load_storage(nil), do: []
defp load_storage(snmp_device), do: Snmp.list_storage(snmp_device.id)
defp load_processor_chart_data([]), do: nil
defp load_processor_chart_data(processors) do

View file

@ -435,7 +435,9 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/storage"}
navigate={
~p"/devices/#{@device.id}/graph/storage?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
@ -458,6 +460,60 @@
</div>
</div>
<% end %>
<!-- Physical Storage Volumes (from HOST-RESOURCES-MIB) -->
<%= if @storage && length(@storage) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
Storage Volumes
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
({length(@storage)})
</span>
</h3>
</div>
<div class="p-4 space-y-4">
<%= for storage <- @storage do %>
<% usage_percent =
if storage.total_bytes && storage.total_bytes > 0 do
Float.round(storage.used_bytes / storage.total_bytes * 100, 1)
else
0.0
end %>
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-700 dark:text-gray-300">
{storage.description || "Storage #{storage.storage_index}"}
</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)}
</span>
</div>
<div class="flex items-center gap-3">
<div class="flex-1 bg-gray-200 rounded-full h-2 dark:bg-gray-700">
<div
class={[
"h-2 rounded-full transition-all",
usage_percent < 70 && "bg-green-500",
usage_percent >= 70 && usage_percent < 90 && "bg-yellow-500",
usage_percent >= 90 && "bg-red-500"
]}
style={"width: #{min(usage_percent, 100)}%"}
>
</div>
</div>
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
{usage_percent}%
</span>
</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{String.replace(storage.storage_type || "other", "_", " ")
|> String.capitalize()}
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Temperature Sensors -->
<%= if @temperature_sensors && length(@temperature_sensors) > 0 do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
@ -472,7 +528,9 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/temperature"}
navigate={
~p"/devices/#{@device.id}/graph/temperature?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
@ -507,7 +565,9 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/voltage"}
navigate={
~p"/devices/#{@device.id}/graph/voltage?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
@ -545,7 +605,9 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/dbm"}
navigate={
~p"/devices/#{@device.id}/graph/dbm?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}
@ -581,7 +643,9 @@
<div class="flex justify-between py-1.5 border-b border-gray-100 dark:border-white/5">
<dt class="text-sm text-gray-600 dark:text-gray-400">
<.link
navigate={~p"/devices/#{@device.id}/graph/count"}
navigate={
~p"/devices/#{@device.id}/graph/count?sensor_id=#{sensor.id}"
}
class="hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{sensor.sensor_descr}

View file

@ -20,6 +20,7 @@ defmodule ToweropsWeb.GraphLive.Show do
range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id")
sensor_id = Map.get(params, "sensor_id")
socket =
socket
@ -27,6 +28,7 @@ defmodule ToweropsWeb.GraphLive.Show do
|> assign(:sensor_type, sensor_type)
|> assign(:range, range)
|> assign(:interface_id, interface_id)
|> assign(:sensor_id, sensor_id)
|> load_graph_data()
{:noreply, socket}
@ -45,8 +47,15 @@ defmodule ToweropsWeb.GraphLive.Show do
defp build_graph_params(assigns, range) do
base_params = %{"range" => range}
if assigns.interface_id do
Map.put(base_params, "interface_id", assigns.interface_id)
base_params =
if assigns.interface_id do
Map.put(base_params, "interface_id", assigns.interface_id)
else
base_params
end
if assigns.sensor_id do
Map.put(base_params, "sensor_id", assigns.sensor_id)
else
base_params
end
@ -59,6 +68,7 @@ defmodule ToweropsWeb.GraphLive.Show do
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
interface_id = socket.assigns.interface_id
sensor_id = socket.assigns.sensor_id
device = Devices.get_device!(device_id)
@ -73,6 +83,10 @@ defmodule ToweropsWeb.GraphLive.Show do
sensor_type == "traffic" ->
{load_traffic_chart_data(device_id, range), nil}
sensor_id ->
# Load only the specific sensor when sensor_id is provided
{load_single_sensor_chart_data(sensor_id, range), get_sensor_name(sensor_id)}
true ->
sensor_types = get_sensor_types_for_chart(sensor_type)
{load_sensor_chart_data(device_id, sensor_types, range), nil}
@ -131,6 +145,26 @@ defmodule ToweropsWeb.GraphLive.Show do
end
end
defp load_single_sensor_chart_data(sensor_id, range) do
case Snmp.get_sensor(sensor_id) do
nil ->
nil
sensor ->
since = get_datetime_from_range(range)
limit = get_limit_for_range(range)
dataset = sensor_to_dataset(sensor, since, limit)
Jason.encode!(%{datasets: [dataset]})
end
end
defp get_sensor_name(sensor_id) do
case Snmp.get_sensor(sensor_id) do
nil -> nil
sensor -> sensor.sensor_descr
end
end
defp build_sensor_chart_json(device, sensor_types, range) do
sensors =
device.sensors