diff --git a/assets/js/app.ts b/assets/js/app.ts index 630593c4..606f249f 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -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' diff --git a/lib/towerops/profiles/yaml_profiles.ex b/lib/towerops/profiles/yaml_profiles.ex index ed28e0c8..b21e7611 100644 --- a/lib/towerops/profiles/yaml_profiles.ex +++ b/lib/towerops/profiles/yaml_profiles.ex @@ -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 diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 7a30bf56..724ab678 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -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 diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index a4743862..54bb3853 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -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() } diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 9f00201a..a83a78ad 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -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 diff --git a/lib/towerops/snmp/mib_translator.ex b/lib/towerops/snmp/mib_translator.ex index fd45efb9..bdb96fd4 100644 --- a/lib/towerops/snmp/mib_translator.ex +++ b/lib/towerops/snmp/mib_translator.ex @@ -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", diff --git a/lib/towerops/snmp/profiles/dynamic.ex b/lib/towerops/snmp/profiles/dynamic.ex index 61fdaf06..27ce4f75 100644 --- a/lib/towerops/snmp/profiles/dynamic.ex +++ b/lib/towerops/snmp/profiles/dynamic.ex @@ -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"), diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 6e479bf9..ad0e7a64 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -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 diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index d5c6b229..192864c0 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -435,7 +435,9 @@
<.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 @@
<% end %> + + <%= if @storage && length(@storage) > 0 do %> +
+
+

+ Storage Volumes + + ({length(@storage)}) + +

+
+
+ <%= 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 %> +
+
+ + {storage.description || "Storage #{storage.storage_index}"} + + + {format_bytes(storage.used_bytes)} / {format_bytes(storage.total_bytes)} + +
+
+
+
= 70 && usage_percent < 90 && "bg-yellow-500", + usage_percent >= 90 && "bg-red-500" + ]} + style={"width: #{min(usage_percent, 100)}%"} + > +
+
+ + {usage_percent}% + +
+
+ {String.replace(storage.storage_type || "other", "_", " ") + |> String.capitalize()} +
+
+ <% end %> +
+
+ <% end %> <%= if @temperature_sensors && length(@temperature_sensors) > 0 do %>
@@ -472,7 +528,9 @@
<.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 @@
<.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 @@
<.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 @@
<.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} diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex index df2f4e66..2714e482 100644 --- a/lib/towerops_web/live/graph_live/show.ex +++ b/lib/towerops_web/live/graph_live/show.ex @@ -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