parent
06ca0390f0
commit
11f9c4450c
13 changed files with 1769 additions and 204 deletions
|
|
@ -211,7 +211,7 @@ defmodule Towerops.Capacity do
|
||||||
|
|
||||||
def percentile(values, n) when n >= 0 and n <= 100 do
|
def percentile(values, n) when n >= 0 and n <= 100 do
|
||||||
sorted = Enum.sort(values)
|
sorted = Enum.sort(values)
|
||||||
rank = Float.ceil(n / 100 * length(sorted)) |> trunc() |> max(1)
|
rank = (n / 100 * length(sorted)) |> Float.ceil() |> trunc() |> max(1)
|
||||||
Enum.at(sorted, rank - 1)
|
Enum.at(sorted, rank - 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -556,6 +556,50 @@ defmodule Towerops.Monitoring do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Finds an existing auto-discovered check or creates a new one.
|
||||||
|
|
||||||
|
Uses `(device_id, check_type, source_id)` as the uniqueness key.
|
||||||
|
If a check already exists, updates its name and config. Otherwise creates it
|
||||||
|
and schedules a CheckExecutorWorker job.
|
||||||
|
|
||||||
|
Attrs must include: `:device_id`, `:check_type`, `:source_id`, `:source_type`,
|
||||||
|
`:organization_id`, `:name`, `:config`.
|
||||||
|
"""
|
||||||
|
def ensure_discovery_check(attrs) do
|
||||||
|
device_id = Map.fetch!(attrs, :device_id)
|
||||||
|
check_type = Map.fetch!(attrs, :check_type)
|
||||||
|
source_id = Map.fetch!(attrs, :source_id)
|
||||||
|
|
||||||
|
existing =
|
||||||
|
Repo.one(
|
||||||
|
from(c in Check,
|
||||||
|
where: c.device_id == ^device_id,
|
||||||
|
where: c.check_type == ^check_type,
|
||||||
|
where: c.source_id == ^source_id,
|
||||||
|
limit: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case existing do
|
||||||
|
nil ->
|
||||||
|
with {:ok, check} <- create_check(attrs) do
|
||||||
|
_ = schedule_check(check)
|
||||||
|
{:ok, check}
|
||||||
|
end
|
||||||
|
|
||||||
|
check ->
|
||||||
|
name = Map.get(attrs, :name, check.name)
|
||||||
|
config = Map.get(attrs, :config, check.config)
|
||||||
|
|
||||||
|
if check.name == name and check.config == config do
|
||||||
|
{:ok, check}
|
||||||
|
else
|
||||||
|
update_check(check, %{name: name, config: config})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
## Legacy ping-based monitoring compatibility (stub)
|
## Legacy ping-based monitoring compatibility (stub)
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
|
|
|
||||||
|
|
@ -446,8 +446,6 @@ defmodule Towerops.Snmp.Discovery do
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
def create_checks_from_discovery(device, snmp_device) do
|
def create_checks_from_discovery(device, snmp_device) do
|
||||||
alias Towerops.Monitoring
|
|
||||||
|
|
||||||
# Ensure associations are loaded
|
# Ensure associations are loaded
|
||||||
snmp_device = Repo.preload(snmp_device, [:sensors, :interfaces, :processors, :storage])
|
snmp_device = Repo.preload(snmp_device, [:sensors, :interfaces, :processors, :storage])
|
||||||
|
|
||||||
|
|
@ -510,98 +508,74 @@ defmodule Towerops.Snmp.Discovery do
|
||||||
# Private functions
|
# Private functions
|
||||||
|
|
||||||
defp create_sensor_check(device, sensor) do
|
defp create_sensor_check(device, sensor) do
|
||||||
alias Towerops.Monitoring
|
Towerops.Monitoring.ensure_discovery_check(%{
|
||||||
|
organization_id: device.organization_id,
|
||||||
with {:ok, check} <-
|
device_id: device.id,
|
||||||
Monitoring.create_check(%{
|
name: sensor.sensor_descr,
|
||||||
organization_id: device.organization_id,
|
check_type: "snmp_sensor",
|
||||||
device_id: device.id,
|
source_type: "auto_discovery",
|
||||||
name: sensor.sensor_descr,
|
source_id: sensor.id,
|
||||||
check_type: "snmp_sensor",
|
interval_seconds: 60,
|
||||||
source_type: "auto_discovery",
|
enabled: true,
|
||||||
source_id: sensor.id,
|
config: %{
|
||||||
interval_seconds: 60,
|
"sensor_type" => sensor.sensor_type,
|
||||||
enabled: true,
|
"sensor_class" => sensor.sensor_class,
|
||||||
config: %{
|
"sensor_oid" => sensor.sensor_oid,
|
||||||
"sensor_type" => sensor.sensor_type,
|
"sensor_divisor" => sensor.sensor_divisor,
|
||||||
"sensor_class" => sensor.sensor_class,
|
"sensor_unit" => sensor.sensor_unit
|
||||||
"sensor_oid" => sensor.sensor_oid,
|
}
|
||||||
"sensor_divisor" => sensor.sensor_divisor,
|
})
|
||||||
"sensor_unit" => sensor.sensor_unit
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{:ok, _job} <- Monitoring.schedule_check(check) do
|
|
||||||
{:ok, check}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_interface_check(device, interface) do
|
defp create_interface_check(device, interface) do
|
||||||
alias Towerops.Monitoring
|
Towerops.Monitoring.ensure_discovery_check(%{
|
||||||
|
organization_id: device.organization_id,
|
||||||
with {:ok, check} <-
|
device_id: device.id,
|
||||||
Monitoring.create_check(%{
|
name: "Interface #{interface.if_descr}",
|
||||||
organization_id: device.organization_id,
|
check_type: "snmp_interface",
|
||||||
device_id: device.id,
|
source_type: "auto_discovery",
|
||||||
name: "Interface #{interface.if_descr}",
|
source_id: interface.id,
|
||||||
check_type: "snmp_interface",
|
interval_seconds: 60,
|
||||||
source_type: "auto_discovery",
|
enabled: true,
|
||||||
source_id: interface.id,
|
config: %{
|
||||||
interval_seconds: 60,
|
"if_index" => interface.if_index,
|
||||||
enabled: true,
|
"if_descr" => interface.if_descr
|
||||||
config: %{
|
}
|
||||||
"if_index" => interface.if_index,
|
})
|
||||||
"if_descr" => interface.if_descr
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{:ok, _job} <- Monitoring.schedule_check(check) do
|
|
||||||
{:ok, check}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_processor_check(device, processor) do
|
defp create_processor_check(device, processor) do
|
||||||
alias Towerops.Monitoring
|
Towerops.Monitoring.ensure_discovery_check(%{
|
||||||
|
organization_id: device.organization_id,
|
||||||
with {:ok, check} <-
|
device_id: device.id,
|
||||||
Monitoring.create_check(%{
|
name: "CPU #{processor.processor_index}",
|
||||||
organization_id: device.organization_id,
|
check_type: "snmp_processor",
|
||||||
device_id: device.id,
|
source_type: "auto_discovery",
|
||||||
name: "CPU #{processor.processor_index}",
|
source_id: processor.id,
|
||||||
check_type: "snmp_processor",
|
interval_seconds: 60,
|
||||||
source_type: "auto_discovery",
|
enabled: true,
|
||||||
source_id: processor.id,
|
config: %{
|
||||||
interval_seconds: 60,
|
"processor_index" => processor.processor_index,
|
||||||
enabled: true,
|
"processor_descr" => processor.processor_descr
|
||||||
config: %{
|
}
|
||||||
"processor_index" => processor.processor_index,
|
})
|
||||||
"processor_descr" => processor.processor_descr
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{:ok, _job} <- Monitoring.schedule_check(check) do
|
|
||||||
{:ok, check}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_storage_check(device, storage) do
|
defp create_storage_check(device, storage) do
|
||||||
alias Towerops.Monitoring
|
Towerops.Monitoring.ensure_discovery_check(%{
|
||||||
|
organization_id: device.organization_id,
|
||||||
with {:ok, check} <-
|
device_id: device.id,
|
||||||
Monitoring.create_check(%{
|
name: storage.description || storage.device_name || "Storage #{storage.storage_index}",
|
||||||
organization_id: device.organization_id,
|
check_type: "snmp_storage",
|
||||||
device_id: device.id,
|
source_type: "auto_discovery",
|
||||||
name: storage.description || storage.device_name || "Storage #{storage.storage_index}",
|
source_id: storage.id,
|
||||||
check_type: "snmp_storage",
|
interval_seconds: 60,
|
||||||
source_type: "auto_discovery",
|
enabled: true,
|
||||||
source_id: storage.id,
|
config: %{
|
||||||
interval_seconds: 60,
|
"storage_index" => storage.storage_index,
|
||||||
enabled: true,
|
"storage_descr" => storage.description
|
||||||
config: %{
|
}
|
||||||
"storage_index" => storage.storage_index,
|
})
|
||||||
"storage_descr" => storage.description
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{:ok, _job} <- Monitoring.schedule_check(check) do
|
|
||||||
{:ok, check}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp log_check_creation_results(device_id, check_counts) do
|
defp log_check_creation_results(device_id, check_counts) do
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.Airfiber do
|
||||||
case Client.get(v1_opts, "1.3.6.1.2.1.1.5.0") do
|
case Client.get(v1_opts, "1.3.6.1.2.1.1.5.0") do
|
||||||
{:ok, sys_name} when is_binary(sys_name) and sys_name != "" ->
|
{:ok, sys_name} when is_binary(sys_name) and sys_name != "" ->
|
||||||
"Ubiquiti AirFiber (#{sys_name})"
|
"Ubiquiti AirFiber (#{sys_name})"
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
"Ubiquiti AirFiber"
|
"Ubiquiti AirFiber"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule Towerops.Topology do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Towerops.Capacity
|
||||||
alias Towerops.Devices
|
alias Towerops.Devices
|
||||||
alias Towerops.Devices.Device
|
alias Towerops.Devices.Device
|
||||||
alias Towerops.Repo
|
alias Towerops.Repo
|
||||||
|
|
@ -22,7 +23,6 @@ defmodule Towerops.Topology do
|
||||||
alias Towerops.Topology.DeviceNeighbor
|
alias Towerops.Topology.DeviceNeighbor
|
||||||
alias Towerops.Topology.Identifier
|
alias Towerops.Topology.Identifier
|
||||||
alias Towerops.Topology.Lldp
|
alias Towerops.Topology.Lldp
|
||||||
alias Towerops.Capacity
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -1402,57 +1402,57 @@ defmodule Towerops.Topology do
|
||||||
defp enrich_edges_with_utilization(edges, device_ids) do
|
defp enrich_edges_with_utilization(edges, device_ids) do
|
||||||
# Get all interfaces with configured capacity for these devices
|
# Get all interfaces with configured capacity for these devices
|
||||||
interface_utilizations = get_interface_utilizations(device_ids)
|
interface_utilizations = get_interface_utilizations(device_ids)
|
||||||
|
|
||||||
Enum.map(edges, fn edge ->
|
Enum.map(edges, fn edge ->
|
||||||
# Try to find utilization data for the source or target interface
|
# Try to find utilization data for the source or target interface
|
||||||
source_util = get_edge_utilization(edge, :source, interface_utilizations)
|
source_util = get_edge_utilization(edge, :source, interface_utilizations)
|
||||||
target_util = get_edge_utilization(edge, :target, interface_utilizations)
|
target_util = get_edge_utilization(edge, :target, interface_utilizations)
|
||||||
|
|
||||||
# Use the higher utilization of the two interfaces
|
# Use the higher utilization of the two interfaces
|
||||||
utilization = combine_interface_utilizations(source_util, target_util)
|
utilization = combine_interface_utilizations(source_util, target_util)
|
||||||
|
|
||||||
Map.merge(edge, utilization)
|
Map.merge(edge, utilization)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_interface_utilizations(device_ids) do
|
defp get_interface_utilizations(device_ids) do
|
||||||
# Query interfaces with capacity configuration and recent stats
|
# Query interfaces with capacity configuration and recent stats
|
||||||
Repo.all(
|
from(i in Interface,
|
||||||
from i in Interface,
|
join: sd in SnmpDevice,
|
||||||
join: sd in SnmpDevice, on: i.snmp_device_id == sd.id,
|
on: i.snmp_device_id == sd.id,
|
||||||
join: d in Device, on: sd.device_id == d.id,
|
join: d in Device,
|
||||||
where: d.id in ^device_ids and not is_nil(i.configured_capacity_bps),
|
on: sd.device_id == d.id,
|
||||||
select: %{
|
where: d.id in ^device_ids and not is_nil(i.configured_capacity_bps),
|
||||||
interface_id: i.id,
|
select: %{
|
||||||
device_id: d.id,
|
interface_id: i.id,
|
||||||
interface_name: i.if_name,
|
device_id: d.id,
|
||||||
capacity_bps: i.configured_capacity_bps,
|
interface_name: i.if_name,
|
||||||
if_index: i.if_index
|
capacity_bps: i.configured_capacity_bps,
|
||||||
}
|
if_index: i.if_index
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|> Repo.all()
|
||||||
|> Enum.map(fn interface ->
|
|> Enum.map(fn interface ->
|
||||||
# Calculate current utilization using the Capacity module
|
# Calculate current utilization using the Capacity module
|
||||||
interface_struct = %Interface{
|
interface_struct = %Interface{
|
||||||
id: interface.interface_id,
|
id: interface.interface_id,
|
||||||
configured_capacity_bps: interface.capacity_bps
|
configured_capacity_bps: interface.capacity_bps
|
||||||
}
|
}
|
||||||
|
|
||||||
utilization = Capacity.get_utilization(interface_struct)
|
utilization = Capacity.get_utilization(interface_struct)
|
||||||
|
|
||||||
Map.merge(interface, %{
|
Map.put(interface, :utilization_data, utilization)
|
||||||
utilization_data: utilization
|
|
||||||
})
|
|
||||||
end)
|
end)
|
||||||
|> Map.new(fn interface -> {interface.interface_id, interface} end)
|
|> Map.new(fn interface -> {interface.interface_id, interface} end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_edge_utilization(edge, direction, interface_utilizations) do
|
defp get_edge_utilization(edge, direction, interface_utilizations) do
|
||||||
interface_id =
|
interface_id =
|
||||||
case direction do
|
case direction do
|
||||||
:source -> edge[:source_interface_id]
|
:source -> edge[:source_interface_id]
|
||||||
:target -> edge[:target_interface_id]
|
:target -> edge[:target_interface_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
case interface_id do
|
case interface_id do
|
||||||
nil -> nil
|
nil -> nil
|
||||||
id -> Map.get(interface_utilizations, id)
|
id -> Map.get(interface_utilizations, id)
|
||||||
|
|
@ -1469,18 +1469,18 @@ defmodule Towerops.Topology do
|
||||||
capacity_bps: nil,
|
capacity_bps: nil,
|
||||||
utilization_text: nil
|
utilization_text: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
{util, nil} ->
|
{util, nil} ->
|
||||||
process_single_utilization(util)
|
process_single_utilization(util)
|
||||||
|
|
||||||
{nil, util} ->
|
{nil, util} ->
|
||||||
process_single_utilization(util)
|
process_single_utilization(util)
|
||||||
|
|
||||||
{source, target} ->
|
{source, target} ->
|
||||||
# Use the higher utilization of the two interfaces
|
# Use the higher utilization of the two interfaces
|
||||||
source_data = process_single_utilization(source)
|
source_data = process_single_utilization(source)
|
||||||
target_data = process_single_utilization(target)
|
target_data = process_single_utilization(target)
|
||||||
|
|
||||||
if (source_data.utilization_pct || 0) >= (target_data.utilization_pct || 0) do
|
if (source_data.utilization_pct || 0) >= (target_data.utilization_pct || 0) do
|
||||||
source_data
|
source_data
|
||||||
else
|
else
|
||||||
|
|
@ -1499,11 +1499,11 @@ defmodule Towerops.Topology do
|
||||||
capacity_bps: util_data.capacity_bps,
|
capacity_bps: util_data.capacity_bps,
|
||||||
utilization_text: nil
|
utilization_text: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
util ->
|
util ->
|
||||||
utilization_pct = Float.round(util.utilization_pct, 1)
|
utilization_pct = Float.round(util.utilization_pct, 1)
|
||||||
level = classify_utilization_level(utilization_pct)
|
level = classify_utilization_level(utilization_pct)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
utilization_pct: utilization_pct,
|
utilization_pct: utilization_pct,
|
||||||
utilization_level: level,
|
utilization_level: level,
|
||||||
|
|
@ -1517,7 +1517,7 @@ defmodule Towerops.Topology do
|
||||||
|
|
||||||
defp classify_utilization_level(nil), do: nil
|
defp classify_utilization_level(nil), do: nil
|
||||||
defp classify_utilization_level(pct) when pct < 30, do: :low
|
defp classify_utilization_level(pct) when pct < 30, do: :low
|
||||||
defp classify_utilization_level(pct) when pct < 60, do: :medium
|
defp classify_utilization_level(pct) when pct < 60, do: :medium
|
||||||
defp classify_utilization_level(pct) when pct < 80, do: :high
|
defp classify_utilization_level(pct) when pct < 80, do: :high
|
||||||
defp classify_utilization_level(_pct), do: :critical
|
defp classify_utilization_level(_pct), do: :critical
|
||||||
|
|
||||||
|
|
@ -1528,22 +1528,26 @@ defmodule Towerops.Topology do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_bps(nil), do: "N/A"
|
defp format_bps(nil), do: "N/A"
|
||||||
|
|
||||||
defp format_bps(bps) when bps >= 1_000_000_000 do
|
defp format_bps(bps) when bps >= 1_000_000_000 do
|
||||||
"#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
"#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_bps(bps) when bps >= 1_000_000 do
|
defp format_bps(bps) when bps >= 1_000_000 do
|
||||||
"#{Float.round(bps / 1_000_000, 0)} Mbps"
|
"#{Float.round(bps / 1_000_000, 0)} Mbps"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_bps(bps) when bps >= 1_000 do
|
defp format_bps(bps) when bps >= 1_000 do
|
||||||
"#{Float.round(bps / 1_000, 0)} Kbps"
|
"#{Float.round(bps / 1_000, 0)} Kbps"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp format_bps(bps) do
|
defp format_bps(bps) do
|
||||||
"#{Float.round(bps, 0)} bps"
|
"#{Float.round(bps, 0)} bps"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compute utilization statistics for the weathermap dashboard.
|
# Compute utilization statistics for the weathermap dashboard.
|
||||||
defp compute_utilization_stats(edges) do
|
defp compute_utilization_stats(edges) do
|
||||||
utilization_counts =
|
utilization_counts =
|
||||||
edges
|
edges
|
||||||
|> Enum.map(& &1[:utilization_level])
|
|> Enum.map(& &1[:utilization_level])
|
||||||
|> Enum.frequencies()
|
|> Enum.frequencies()
|
||||||
|
|
|
||||||
|
|
@ -1013,7 +1013,9 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
fn interface ->
|
fn interface ->
|
||||||
stat_data =
|
stat_data =
|
||||||
case get_airfiber_stats(af_overrides, interface, client_opts) do
|
case get_airfiber_stats(af_overrides, interface, client_opts) do
|
||||||
{:ok, data} -> data
|
{:ok, data} ->
|
||||||
|
data
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
{:ok, data} = get_interface_stats(client_opts, interface.if_index)
|
{:ok, data} = get_interface_stats(client_opts, interface.if_index)
|
||||||
data
|
data
|
||||||
|
|
@ -1048,8 +1050,9 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
# Ubiquiti uses two enterprise OIDs:
|
# Ubiquiti uses two enterprise OIDs:
|
||||||
# - 1.3.6.1.4.1.41112 (new UBNT enterprise OID)
|
# - 1.3.6.1.4.1.41112 (new UBNT enterprise OID)
|
||||||
# - 1.3.6.1.4.1.10002 (old UBNT enterprise OID, used by AirFiber AF11/AF24)
|
# - 1.3.6.1.4.1.10002 (old UBNT enterprise OID, used by AirFiber AF11/AF24)
|
||||||
is_ubnt = String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or
|
is_ubnt =
|
||||||
String.starts_with?(sys_oid, "1.3.6.1.4.1.10002")
|
String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or
|
||||||
|
String.starts_with?(sys_oid, "1.3.6.1.4.1.10002")
|
||||||
|
|
||||||
if is_ubnt do
|
if is_ubnt do
|
||||||
# Probe for the AirFiber statistics table — if it exists, this device
|
# Probe for the AirFiber statistics table — if it exists, this device
|
||||||
|
|
@ -1058,7 +1061,9 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
|
|
||||||
# Check for LTU first (more specific)
|
# Check for LTU first (more specific)
|
||||||
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.10.1.2.2.0") do
|
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.10.1.2.2.0") do
|
||||||
{:ok, val} when val != nil -> :airfiber_ltu
|
{:ok, val} when val != nil ->
|
||||||
|
:airfiber_ltu
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
# Check for regular AirFiber stats table (index field)
|
# Check for regular AirFiber stats table (index field)
|
||||||
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.3.3.1.1.1") do
|
case Client.get(v1_opts, "1.3.6.1.4.1.41112.1.3.3.1.1.1") do
|
||||||
|
|
@ -1066,8 +1071,6 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
_ -> nil
|
_ -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -1077,8 +1080,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
# since the proprietary counters represent the radio link's actual throughput.
|
# since the proprietary counters represent the radio link's actual throughput.
|
||||||
# Only skip lo and sit0 (loopback/tunnel with no real traffic).
|
# Only skip lo and sit0 (loopback/tunnel with no real traffic).
|
||||||
defp get_airfiber_stats(nil, _interface, _client_opts), do: :not_airfiber
|
defp get_airfiber_stats(nil, _interface, _client_opts), do: :not_airfiber
|
||||||
defp get_airfiber_stats(_af_type, %{if_descr: descr}, _client_opts)
|
defp get_airfiber_stats(_af_type, %{if_descr: descr}, _client_opts) when descr in ["lo", "sit0"], do: :skip_loopback
|
||||||
when descr in ["lo", "sit0"], do: :skip_loopback
|
|
||||||
|
|
||||||
defp get_airfiber_stats(:airfiber, _interface, client_opts) do
|
defp get_airfiber_stats(:airfiber, _interface, client_opts) do
|
||||||
# UBNT-AirFIBER-MIB: airFiberStatistics table (1.3.6.1.4.1.41112.1.3.3.1)
|
# UBNT-AirFIBER-MIB: airFiberStatistics table (1.3.6.1.4.1.41112.1.3.3.1)
|
||||||
|
|
@ -1090,20 +1092,28 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
||||||
v1_opts = Keyword.put(client_opts, :version, "1")
|
v1_opts = Keyword.put(client_opts, :version, "1")
|
||||||
|
|
||||||
oids = [
|
oids = [
|
||||||
{"1.3.6.1.4.1.41112.1.3.3.1.7.1", :if_in_octets}, # rxOctetsOK
|
# rxOctetsOK
|
||||||
{"1.3.6.1.4.1.41112.1.3.3.1.6.1", :if_out_octets}, # txOctetsOK
|
{"1.3.6.1.4.1.41112.1.3.3.1.7.1", :if_in_octets},
|
||||||
{"1.3.6.1.4.1.41112.1.3.3.1.10.1", :if_in_errors}, # rxErroredFrames
|
# txOctetsOK
|
||||||
{"1.3.6.1.4.1.41112.1.3.3.1.11.1", :if_out_errors} # txErroredFrames
|
{"1.3.6.1.4.1.41112.1.3.3.1.6.1", :if_out_octets},
|
||||||
|
# rxErroredFrames
|
||||||
|
{"1.3.6.1.4.1.41112.1.3.3.1.10.1", :if_in_errors},
|
||||||
|
# txErroredFrames
|
||||||
|
{"1.3.6.1.4.1.41112.1.3.3.1.11.1", :if_out_errors}
|
||||||
]
|
]
|
||||||
|
|
||||||
fetch_proprietary_stats(v1_opts, oids, true)
|
fetch_proprietary_stats(v1_opts, oids, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_airfiber_stats(:airfiber_ltu, _interface, client_opts) do
|
defp get_airfiber_stats(:airfiber_ltu, _interface, client_opts) do
|
||||||
# UBNT-AFLTU-MIB: afLTUeth table (1.3.6.1.4.1.41112.1.10.1.6)
|
# UBNT-AFLTU-MIB: afLTUeth table (1.3.6.1.4.1.41112.1.10.1.6)
|
||||||
oids = [
|
oids = [
|
||||||
{"1.3.6.1.4.1.41112.1.10.1.6.1.6.0", :if_in_octets}, # afLTUethRxBytes
|
# afLTUethRxBytes
|
||||||
{"1.3.6.1.4.1.41112.1.10.1.6.1.4.0", :if_out_octets} # afLTUethTxBytes
|
{"1.3.6.1.4.1.41112.1.10.1.6.1.6.0", :if_in_octets},
|
||||||
|
# afLTUethTxBytes
|
||||||
|
{"1.3.6.1.4.1.41112.1.10.1.6.1.4.0", :if_out_octets}
|
||||||
]
|
]
|
||||||
|
|
||||||
fetch_proprietary_stats(client_opts, oids, true)
|
fetch_proprietary_stats(client_opts, oids, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ defmodule ToweropsWeb.WeathermapLive do
|
||||||
def handle_event("toggle_fullscreen", _params, socket) do
|
def handle_event("toggle_fullscreen", _params, socket) do
|
||||||
new_fullscreen = not socket.assigns.fullscreen
|
new_fullscreen = not socket.assigns.fullscreen
|
||||||
|
|
||||||
path_with_params =
|
path_with_params =
|
||||||
if new_fullscreen do
|
if new_fullscreen do
|
||||||
~p"/weathermap?#{%{tab: socket.assigns.active_tab, fullscreen: true}}"
|
~p"/weathermap?#{%{tab: socket.assigns.active_tab, fullscreen: true}}"
|
||||||
else
|
else
|
||||||
|
|
@ -192,4 +192,4 @@ defmodule ToweropsWeb.WeathermapLive do
|
||||||
detail -> assign(socket, :selected_node_detail, detail)
|
detail -> assign(socket, :selected_node_detail, detail)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
<div class={["h-screen flex flex-col", if(@fullscreen, do: "fixed inset-0 z-50 bg-white dark:bg-gray-900")]}>
|
<div class={[
|
||||||
|
"h-screen flex flex-col",
|
||||||
|
if(@fullscreen, do: "fixed inset-0 z-50 bg-white dark:bg-gray-900")
|
||||||
|
]}>
|
||||||
<%= if not @fullscreen do %>
|
<%= if not @fullscreen do %>
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
|
|
@ -64,8 +67,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<div class="border-b border-gray-200 dark:border-white/10 px-4 bg-white dark:bg-gray-900">
|
<div class="border-b border-gray-200 dark:border-white/10 px-4 bg-white dark:bg-gray-900">
|
||||||
<nav class="-mb-px flex space-x-8">
|
<nav class="-mb-px flex space-x-8">
|
||||||
<.link
|
<.link
|
||||||
|
|
@ -232,7 +235,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-white/10 p-3">
|
<div class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-white/10 p-3">
|
||||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -263,7 +266,8 @@
|
||||||
High Utilization (60%+)
|
High Utilization (60%+)
|
||||||
<%= if (@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0) > 0 do %>
|
<%= if (@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0) > 0 do %>
|
||||||
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-orange-800 bg-orange-200 rounded-full dark:bg-orange-900 dark:text-orange-300">
|
<span class="ml-1 inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-orange-800 bg-orange-200 rounded-full dark:bg-orange-900 dark:text-orange-300">
|
||||||
{(@topology.stats[:high_utilization_links] || 0) + (@topology.stats[:overutilized_links] || 0)}
|
{(@topology.stats[:high_utilization_links] || 0) +
|
||||||
|
(@topology.stats[:overutilized_links] || 0)}
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -338,7 +342,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Network Weathermap Container -->
|
<!-- Network Weathermap Container -->
|
||||||
<div class="flex-1 bg-white dark:bg-gray-800 relative overflow-hidden">
|
<div class="flex-1 bg-white dark:bg-gray-800 relative overflow-hidden">
|
||||||
<div class="absolute inset-0 border-t border-gray-200 dark:border-white/10">
|
<div class="absolute inset-0 border-t border-gray-200 dark:border-white/10">
|
||||||
<div
|
<div
|
||||||
|
|
@ -349,7 +353,7 @@
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zoom Controls -->
|
<!-- Zoom Controls -->
|
||||||
<div class="absolute bottom-4 left-4 flex flex-col gap-1 z-10">
|
<div class="absolute bottom-4 left-4 flex flex-col gap-1 z-10">
|
||||||
<button
|
<button
|
||||||
id="cy-zoom-in"
|
id="cy-zoom-in"
|
||||||
|
|
@ -376,8 +380,8 @@
|
||||||
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
|
<.icon name="hero-arrows-pointing-out" class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Utilization Legend -->
|
<!-- Utilization Legend -->
|
||||||
<div class="absolute bottom-4 right-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 z-10">
|
<div class="absolute bottom-4 right-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-white/10 rounded-lg shadow-lg p-4 z-10">
|
||||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Link Utilization</h4>
|
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-3">Link Utilization</h4>
|
||||||
<div class="space-y-2 text-xs">
|
<div class="space-y-2 text-xs">
|
||||||
|
|
@ -407,7 +411,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Node Detail Panel (slide-out) -->
|
<!-- Node Detail Panel (slide-out) -->
|
||||||
<%= if @selected_node_detail do %>
|
<%= if @selected_node_detail do %>
|
||||||
<div
|
<div
|
||||||
id="node-detail-panel"
|
id="node-detail-panel"
|
||||||
|
|
@ -427,7 +431,7 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device Info -->
|
<!-- Device Info -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-base font-medium text-gray-900 dark:text-white">
|
<h4 class="text-base font-medium text-gray-900 dark:text-white">
|
||||||
|
|
@ -494,7 +498,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Utilization Stats (for devices with bandwidth data) -->
|
<!-- Utilization Stats (for devices with bandwidth data) -->
|
||||||
<%= if @selected_node_detail[:utilization_stats] do %>
|
<%= if @selected_node_detail[:utilization_stats] do %>
|
||||||
<div class="mt-4 pt-3 border-t border-gray-200 dark:border-white/10">
|
<div class="mt-4 pt-3 border-t border-gray-200 dark:border-white/10">
|
||||||
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
|
@ -529,7 +533,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Connections -->
|
<!-- Connections -->
|
||||||
<%= if length(@selected_node_detail.connections) > 0 do %>
|
<%= if length(@selected_node_detail.connections) > 0 do %>
|
||||||
<div class="mt-5 pt-4 border-t border-gray-200 dark:border-white/10">
|
<div class="mt-5 pt-4 border-t border-gray-200 dark:border-white/10">
|
||||||
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
<h4 class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
|
||||||
|
|
@ -574,7 +578,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- View Device Link (managed only) -->
|
<!-- View Device Link (managed only) -->
|
||||||
<%= if @selected_node_detail.type == :managed do %>
|
<%= if @selected_node_detail.type == :managed do %>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<.link
|
<.link
|
||||||
|
|
@ -592,7 +596,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<%= if @topology.stats.total_devices == 0 do %>
|
<%= if @topology.stats.total_devices == 0 do %>
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<div class="flex-1 flex items-center justify-center">
|
||||||
<div class="text-center py-16">
|
<div class="text-center py-16">
|
||||||
|
|
@ -601,7 +605,9 @@
|
||||||
{t("No network data available")}
|
{t("No network data available")}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{t("Add devices with SNMP enabled and configure interface capacities to view utilization.")}
|
{t(
|
||||||
|
"Add devices with SNMP enabled and configure interface capacities to view utilization."
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<.button navigate={~p"/devices/new"} variant="primary">
|
<.button navigate={~p"/devices/new"} variant="primary">
|
||||||
|
|
@ -612,4 +618,4 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
2
mix.lock
2
mix.lock
|
|
@ -62,7 +62,7 @@
|
||||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
|
||||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
|
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
|
||||||
"oban": {:hex, :oban, "2.21.0", "25b2d22061628f60ffa93e5c945bdd7dcd90ab3892ae0734da41ec6ede23b9ed", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3209e3d008a2dc46085ed9dce2f9cf0b4e1c58d1ab32615cf64ac1830bdb90bf"},
|
"oban": {:hex, :oban, "2.21.1", "4b6af7b901ef9baca09e239b5a991ef2fa429cf5a13799bc429a131d610ff692", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8162a160924cf4a25905fed2a9242e7787d88e320e3b5b0dcf324eb17c51c4e6"},
|
||||||
"oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"},
|
"oban_met": {:hex, :oban_met, "1.0.6", "2a5500aff496b7ac4b830b0b03b08e920625a051bb6890981fbb53b15f1cbdc0", [:mix], [{:oban, "~> 2.19", [hex: :oban, repo: "hexpm", optional: false]}], "hexpm", "15ea3303de76225878a8e6c25a9d62bd1e2e9dd1c46ac8487d873b9f99e8dcee"},
|
||||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||||
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
defmodule Towerops.Repo.Migrations.DeduplicateDiscoveryChecks do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# Delete duplicate auto-discovered checks, keeping the oldest one per
|
||||||
|
# (device_id, check_type, source_id) combination.
|
||||||
|
execute("""
|
||||||
|
DELETE FROM checks
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY device_id, check_type, source_id
|
||||||
|
ORDER BY inserted_at ASC
|
||||||
|
) AS rn
|
||||||
|
FROM checks
|
||||||
|
WHERE source_type = 'auto_discovery'
|
||||||
|
AND source_id IS NOT NULL
|
||||||
|
) ranked
|
||||||
|
WHERE rn > 1
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Prevent future duplicates for auto-discovered checks.
|
||||||
|
create(
|
||||||
|
unique_index(:checks, [:device_id, :check_type, :source_id],
|
||||||
|
where: "source_type = 'auto_discovery' AND source_id IS NOT NULL",
|
||||||
|
name: :checks_device_type_source_unique_index
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists(
|
||||||
|
index(:checks, [:device_id, :check_type, :source_id],
|
||||||
|
name: :checks_device_type_source_unique_index
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -620,6 +620,112 @@ defmodule Towerops.MonitoringTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "ensure_discovery_check/1" do
|
||||||
|
test "creates a new check when none exists", %{organization: org, device: device} do
|
||||||
|
attrs = %{
|
||||||
|
organization_id: org.id,
|
||||||
|
device_id: device.id,
|
||||||
|
name: "eth0 Status",
|
||||||
|
check_type: "snmp_interface",
|
||||||
|
source_type: "auto_discovery",
|
||||||
|
source_id: Ecto.UUID.generate(),
|
||||||
|
interval_seconds: 60,
|
||||||
|
enabled: true,
|
||||||
|
config: %{"if_index" => 1, "if_descr" => "eth0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, check} = Monitoring.ensure_discovery_check(attrs)
|
||||||
|
assert check.name == "eth0 Status"
|
||||||
|
assert check.check_type == "snmp_interface"
|
||||||
|
assert check.source_type == "auto_discovery"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is idempotent - returns existing check on repeated calls", %{organization: org, device: device} do
|
||||||
|
source_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
organization_id: org.id,
|
||||||
|
device_id: device.id,
|
||||||
|
name: "eth0 Status",
|
||||||
|
check_type: "snmp_interface",
|
||||||
|
source_type: "auto_discovery",
|
||||||
|
source_id: source_id,
|
||||||
|
interval_seconds: 60,
|
||||||
|
enabled: true,
|
||||||
|
config: %{"if_index" => 1, "if_descr" => "eth0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, first} = Monitoring.ensure_discovery_check(attrs)
|
||||||
|
assert {:ok, second} = Monitoring.ensure_discovery_check(attrs)
|
||||||
|
assert first.id == second.id
|
||||||
|
|
||||||
|
# Verify only one check exists in DB
|
||||||
|
checks =
|
||||||
|
Repo.all(
|
||||||
|
from(c in Check,
|
||||||
|
where: c.device_id == ^device.id,
|
||||||
|
where: c.check_type == "snmp_interface",
|
||||||
|
where: c.source_id == ^source_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert length(checks) == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates name if it changed", %{organization: org, device: device} do
|
||||||
|
source_id = Ecto.UUID.generate()
|
||||||
|
|
||||||
|
attrs = %{
|
||||||
|
organization_id: org.id,
|
||||||
|
device_id: device.id,
|
||||||
|
name: "eth0 Status",
|
||||||
|
check_type: "snmp_interface",
|
||||||
|
source_type: "auto_discovery",
|
||||||
|
source_id: source_id,
|
||||||
|
interval_seconds: 60,
|
||||||
|
enabled: true,
|
||||||
|
config: %{"if_index" => 1, "if_descr" => "eth0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {:ok, first} = Monitoring.ensure_discovery_check(attrs)
|
||||||
|
assert first.name == "eth0 Status"
|
||||||
|
|
||||||
|
updated_attrs = %{attrs | name: "Interface eth0"}
|
||||||
|
assert {:ok, second} = Monitoring.ensure_discovery_check(updated_attrs)
|
||||||
|
assert second.id == first.id
|
||||||
|
assert second.name == "Interface eth0"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "differentiates checks by source_id", %{organization: org, device: device} do
|
||||||
|
base_attrs = %{
|
||||||
|
organization_id: org.id,
|
||||||
|
device_id: device.id,
|
||||||
|
check_type: "snmp_interface",
|
||||||
|
source_type: "auto_discovery",
|
||||||
|
interval_seconds: 60,
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs1 =
|
||||||
|
Map.merge(base_attrs, %{
|
||||||
|
name: "eth0 Status",
|
||||||
|
source_id: Ecto.UUID.generate(),
|
||||||
|
config: %{"if_index" => 1, "if_descr" => "eth0"}
|
||||||
|
})
|
||||||
|
|
||||||
|
attrs2 =
|
||||||
|
Map.merge(base_attrs, %{
|
||||||
|
name: "eth1 Status",
|
||||||
|
source_id: Ecto.UUID.generate(),
|
||||||
|
config: %{"if_index" => 2, "if_descr" => "eth1"}
|
||||||
|
})
|
||||||
|
|
||||||
|
assert {:ok, check1} = Monitoring.ensure_discovery_check(attrs1)
|
||||||
|
assert {:ok, check2} = Monitoring.ensure_discovery_check(attrs2)
|
||||||
|
refute check1.id == check2.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "ensure_default_ping_check/1" do
|
describe "ensure_default_ping_check/1" do
|
||||||
test "creates a ping check for a device that has none", %{organization: org, device: device} do
|
test "creates a ping check for a device that has none", %{organization: org, device: device} do
|
||||||
assert {:ok, check} = Monitoring.ensure_default_ping_check(device)
|
assert {:ok, check} = Monitoring.ensure_default_ping_check(device)
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirfiberTest do
|
||||||
|
|
||||||
describe "detect_hardware/1" do
|
describe "detect_hardware/1" do
|
||||||
test "returns hardware string with sysName when available" do
|
test "returns hardware string with sysName when available" do
|
||||||
SnmpMock
|
expect(SnmpMock, :get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
|
||||||
|> expect(:get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
|
|
||||||
{:ok, {:octet_string, "Climax-380 AF24"}}
|
{:ok, {:octet_string, "Climax-380 AF24"}}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -33,8 +32,7 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirfiberTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns generic hardware string when sysName unavailable" do
|
test "returns generic hardware string when sysName unavailable" do
|
||||||
SnmpMock
|
expect(SnmpMock, :get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
|
||||||
|> expect(:get, fn _target, "1.3.6.1.2.1.1.5.0", _opts ->
|
|
||||||
{:error, :timeout}
|
{:error, :timeout}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue