tests: raise coverage to 70% via helper promotion + new unit/property tests
Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
This commit is contained in:
parent
36e58d4d9f
commit
3ca0834ef0
154 changed files with 12212 additions and 4429 deletions
|
|
@ -274,7 +274,8 @@ defmodule Mix.Tasks.Geoip.Import do
|
|||
end)
|
||||
end
|
||||
|
||||
defp parse_location_line(line) do
|
||||
@doc false
|
||||
def parse_location_line(line) do
|
||||
# CSV format: geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,
|
||||
# subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,
|
||||
# city_name,metro_code,time_zone,is_in_european_union
|
||||
|
|
@ -340,13 +341,12 @@ defmodule Mix.Tasks.Geoip.Import do
|
|||
end)
|
||||
end
|
||||
|
||||
defp parse_block_line(line) do
|
||||
# Format: network,geoname_id,registered_country_geoname_id,...
|
||||
@doc false
|
||||
def parse_block_line(line) do
|
||||
parts = String.split(line, ",")
|
||||
|
||||
case parts do
|
||||
[network, geoname_id, registered_country_id | _rest] ->
|
||||
# Try geoname_id first, fall back to registered_country_id
|
||||
geoname_id_int = parse_geoname_id(geoname_id)
|
||||
registered_id_int = parse_geoname_id(registered_country_id)
|
||||
final_geoname_id = geoname_id_int || registered_id_int
|
||||
|
|
@ -358,9 +358,10 @@ defmodule Mix.Tasks.Geoip.Import do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_block_entry(_network, nil, _registered_id_int), do: nil
|
||||
@doc false
|
||||
def build_block_entry(_network, nil, _registered_id_int), do: nil
|
||||
|
||||
defp build_block_entry(network, final_geoname_id, registered_id_int) do
|
||||
def build_block_entry(network, final_geoname_id, registered_id_int) do
|
||||
case parse_cidr(network) do
|
||||
{:ok, start_int, end_int} ->
|
||||
%{
|
||||
|
|
@ -376,16 +377,18 @@ defmodule Mix.Tasks.Geoip.Import do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_geoname_id(""), do: nil
|
||||
@doc false
|
||||
def parse_geoname_id(""), do: nil
|
||||
|
||||
defp parse_geoname_id(geoname_id_str) do
|
||||
def parse_geoname_id(geoname_id_str) do
|
||||
case Integer.parse(geoname_id_str) do
|
||||
{geoname_id, _} -> geoname_id
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_cidr(cidr) do
|
||||
@doc false
|
||||
def parse_cidr(cidr) do
|
||||
case String.split(cidr, "/") do
|
||||
[ip_str, prefix_str] ->
|
||||
with {:ok, {a, b, c, d}} <- parse_ip_address(ip_str),
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ defmodule Towerops.Accounts.HIBP do
|
|||
def check_password(_), do: {:ok, :unknown}
|
||||
|
||||
# Hash password to SHA-1 (HIBP uses SHA-1, not for security but for API compatibility)
|
||||
defp hash_password(password) do
|
||||
@doc false
|
||||
def hash_password(password) do
|
||||
:sha
|
||||
|> :crypto.hash(password)
|
||||
|> Base.encode16()
|
||||
|
|
@ -77,7 +78,8 @@ defmodule Towerops.Accounts.HIBP do
|
|||
end
|
||||
|
||||
# Parse API response and check if suffix exists
|
||||
defp check_suffix(results, suffix) do
|
||||
@doc false
|
||||
def check_suffix(results, suffix) do
|
||||
results
|
||||
|> String.split("\r\n", trim: true)
|
||||
|> Enum.find_value({:ok, 0}, fn line ->
|
||||
|
|
|
|||
|
|
@ -405,7 +405,8 @@ defmodule Towerops.Alerts.StormDetector do
|
|||
end
|
||||
end
|
||||
|
||||
defp prune_old_timestamps(queue, cutoff_ms) do
|
||||
@doc false
|
||||
def prune_old_timestamps(queue, cutoff_ms) do
|
||||
case :queue.peek(queue) do
|
||||
{:value, ts} when ts < cutoff_ms ->
|
||||
{_, queue} = :queue.out(queue)
|
||||
|
|
@ -416,7 +417,8 @@ defmodule Towerops.Alerts.StormDetector do
|
|||
end
|
||||
end
|
||||
|
||||
defp limit_queue_size(queue, max_size) do
|
||||
@doc false
|
||||
def limit_queue_size(queue, max_size) do
|
||||
current_size = :queue.len(queue)
|
||||
|
||||
if current_size > max_size do
|
||||
|
|
|
|||
|
|
@ -165,11 +165,8 @@ defmodule Towerops.CnMaestro.Client do
|
|||
e -> {:error, Exception.message(e)}
|
||||
end
|
||||
|
||||
defp maybe_add_test_plug(opts) do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
opts
|
||||
end
|
||||
end
|
||||
defp maybe_add_test_plug(opts), do: maybe_attach_test_plug(opts, Application.get_env(:towerops, :env))
|
||||
|
||||
defp maybe_attach_test_plug(opts, :test), do: Keyword.put(opts, :plug, {Req.Test, __MODULE__})
|
||||
defp maybe_attach_test_plug(opts, _env), do: opts
|
||||
end
|
||||
|
|
|
|||
|
|
@ -71,15 +71,11 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp process_network(network, org_id, {count, acc}) do
|
||||
name = network["name"]
|
||||
network_id = network["id"] || network["name"]
|
||||
@doc false
|
||||
def process_network(%{"name" => name}, _org_id, state) when name in [nil, ""], do: state
|
||||
|
||||
if is_nil(name) or name == "" do
|
||||
{count, acc}
|
||||
else
|
||||
upsert_network_site(org_id, name, network_id, count, acc)
|
||||
end
|
||||
def process_network(network, org_id, {count, acc}) do
|
||||
upsert_network_site(org_id, network["name"], network["id"] || network["name"], count, acc)
|
||||
end
|
||||
|
||||
defp upsert_network_site(org_id, name, network_id, count, acc) do
|
||||
|
|
@ -113,14 +109,15 @@ defmodule Towerops.CnMaestro.Sync do
|
|||
defp process_device(device_data, org_id, site_map, acc) do
|
||||
mac = device_data["mac"]
|
||||
ip = device_data["ip"]
|
||||
name = device_data["name"] || device_data["mac"]
|
||||
network = device_data["network"]
|
||||
local_site = site_map[network]
|
||||
|
||||
if is_nil(ip) and is_nil(mac) do
|
||||
acc
|
||||
else
|
||||
sync_single_device(org_id, ip, mac, name, local_site, acc)
|
||||
case {ip, mac} do
|
||||
{nil, nil} ->
|
||||
acc
|
||||
|
||||
_ ->
|
||||
name = device_data["name"] || mac
|
||||
local_site = site_map[device_data["network"]]
|
||||
sync_single_device(org_id, ip, mac, name, local_site, acc)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,22 +13,24 @@ defmodule Towerops.ConfigChanges do
|
|||
|
||||
require Logger
|
||||
|
||||
# Patterns allow optional unified-diff line prefixes (+, -, " ") before the
|
||||
# leading "/" so they match both raw MikroTik configs and unified-diff output.
|
||||
@mikrotik_sections %{
|
||||
"firewall" => ~r/^\/(ip )?firewall/m,
|
||||
"interface" => ~r/^\/interface/m,
|
||||
"routing" => ~r/^\/(ip |routing )(route|ospf|bgp|rip)/m,
|
||||
"wireless" => ~r/^\/interface wireless/m,
|
||||
"queue" => ~r/^\/queue/m,
|
||||
"nat" => ~r/^\/(ip )?firewall nat/m,
|
||||
"address" => ~r/^\/ip address/m,
|
||||
"dns" => ~r/^\/ip dns/m,
|
||||
"dhcp" => ~r/^\/ip dhcp/m,
|
||||
"bridge" => ~r/^\/interface bridge/m,
|
||||
"vlan" => ~r/^\/interface vlan/m,
|
||||
"snmp" => ~r/^\/snmp/m,
|
||||
"user" => ~r/^\/user/m,
|
||||
"system" => ~r/^\/system/m,
|
||||
"ppp" => ~r/^\/ppp/m
|
||||
"firewall" => ~r/^[+\- ]?\/(ip )?firewall/m,
|
||||
"interface" => ~r/^[+\- ]?\/interface/m,
|
||||
"routing" => ~r/^[+\- ]?\/(ip |routing )(route|ospf|bgp|rip)/m,
|
||||
"wireless" => ~r/^[+\- ]?\/interface wireless/m,
|
||||
"queue" => ~r/^[+\- ]?\/queue/m,
|
||||
"nat" => ~r/^[+\- ]?\/(ip )?firewall nat/m,
|
||||
"address" => ~r/^[+\- ]?\/ip address/m,
|
||||
"dns" => ~r/^[+\- ]?\/ip dns/m,
|
||||
"dhcp" => ~r/^[+\- ]?\/ip dhcp/m,
|
||||
"bridge" => ~r/^[+\- ]?\/interface bridge/m,
|
||||
"vlan" => ~r/^[+\- ]?\/interface vlan/m,
|
||||
"snmp" => ~r/^[+\- ]?\/snmp/m,
|
||||
"user" => ~r/^[+\- ]?\/user/m,
|
||||
"system" => ~r/^[+\- ]?\/system/m,
|
||||
"ppp" => ~r/^[+\- ]?\/ppp/m
|
||||
}
|
||||
|
||||
@doc """
|
||||
|
|
@ -153,14 +155,22 @@ defmodule Towerops.ConfigChanges do
|
|||
|> Repo.preload([:device, :backup_before, :backup_after])
|
||||
end
|
||||
|
||||
# Detect which MikroTik config sections were modified based on diff content
|
||||
defp detect_changed_sections(diff) do
|
||||
@doc """
|
||||
Detects which MikroTik config sections were modified in a unified diff.
|
||||
|
||||
Returns a sorted list of section names (strings) matching sections like
|
||||
`firewall`, `interface`, `routing`, etc.
|
||||
"""
|
||||
@spec detect_changed_sections(String.t()) :: [String.t()]
|
||||
def detect_changed_sections(diff) when is_binary(diff) do
|
||||
@mikrotik_sections
|
||||
|> Enum.filter(fn {_name, pattern} -> Regex.match?(pattern, diff) end)
|
||||
|> Enum.map(fn {name, _} -> name end)
|
||||
|> Enum.sort()
|
||||
end
|
||||
|
||||
def detect_changed_sections(_), do: []
|
||||
|
||||
defp apply_date_filters(query, opts) do
|
||||
query
|
||||
|> maybe_filter_after(Keyword.get(opts, :after))
|
||||
|
|
|
|||
|
|
@ -155,7 +155,14 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
)
|
||||
end
|
||||
|
||||
defp build_description(deltas, event) do
|
||||
@doc """
|
||||
Builds a human-readable description of a config change's impact.
|
||||
|
||||
Includes sections from `event.sections_changed` when present, and
|
||||
includes metric deltas that exceed built-in reporting thresholds
|
||||
(latency/loss > 10% increase, throughput > 5% drop).
|
||||
"""
|
||||
def build_description(deltas, event) do
|
||||
parts =
|
||||
[]
|
||||
|> maybe_add(
|
||||
|
|
@ -171,38 +178,49 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
deltas.throughput_delta < -0.05
|
||||
)
|
||||
|
||||
sections =
|
||||
if Enum.any?(event.sections_changed),
|
||||
do: " (sections: #{Enum.join(event.sections_changed, ", ")})",
|
||||
else: ""
|
||||
|
||||
"After config change#{sections}: #{Enum.join(parts, ", ")}."
|
||||
"After config change#{format_sections(event)}: #{Enum.join(parts, ", ")}."
|
||||
end
|
||||
|
||||
# Calculate percentage change between two values.
|
||||
# Returns 0.0 if old value is zero or negative.
|
||||
defp safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old
|
||||
defp safe_pct_change(_old, _new_val), do: 0.0
|
||||
defp format_sections(%{sections_changed: sections}) when is_list(sections) and sections != [],
|
||||
do: " (sections: #{Enum.join(sections, ", ")})"
|
||||
|
||||
# Determine severity based on metric deltas.
|
||||
# - "critical" when latency > 50%, loss > 100%, or throughput < -30%
|
||||
# - "warning" when latency > 20%, loss > 50%, or throughput < -15%
|
||||
# - "info" otherwise
|
||||
defp determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta) do
|
||||
cond do
|
||||
latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30 -> "critical"
|
||||
latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15 -> "warning"
|
||||
true -> "info"
|
||||
end
|
||||
end
|
||||
defp format_sections(_event), do: ""
|
||||
|
||||
# Append text to list if condition is true, otherwise return list unchanged.
|
||||
defp maybe_add(parts, text, true), do: parts ++ [text]
|
||||
defp maybe_add(parts, _text, false), do: parts
|
||||
@doc """
|
||||
Calculates percentage change between two values.
|
||||
|
||||
defp to_float(nil), do: 0.0
|
||||
defp to_float(v) when is_float(v), do: v
|
||||
defp to_float(v) when is_integer(v), do: v / 1
|
||||
Returns `0.0` if the old value is zero or negative (avoids division by zero
|
||||
and treats "no baseline" as "no change").
|
||||
"""
|
||||
@spec safe_pct_change(number(), number()) :: float()
|
||||
def safe_pct_change(old, new_val) when old > 0.0, do: (new_val - old) / old
|
||||
def safe_pct_change(_old, _new_val), do: 0.0
|
||||
|
||||
@doc """
|
||||
Determines severity based on metric deltas expressed as ratios
|
||||
(0.5 = 50% increase). Thresholds:
|
||||
|
||||
* `"critical"`: latency > 50% · loss > 100% · throughput < -30%
|
||||
* `"warning"`: latency > 20% · loss > 50% · throughput < -15%
|
||||
* `"info"`: otherwise
|
||||
"""
|
||||
@spec determine_severity_from_deltas(number(), number(), number()) :: String.t()
|
||||
def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta)
|
||||
when latency_delta > 0.50 or loss_delta > 1.0 or throughput_delta < -0.30, do: "critical"
|
||||
|
||||
def determine_severity_from_deltas(latency_delta, loss_delta, throughput_delta)
|
||||
when latency_delta > 0.20 or loss_delta > 0.50 or throughput_delta < -0.15, do: "warning"
|
||||
|
||||
def determine_severity_from_deltas(_latency, _loss, _throughput), do: "info"
|
||||
|
||||
@doc false
|
||||
def maybe_add(parts, text, true), do: parts ++ [text]
|
||||
def maybe_add(parts, _text, false), do: parts
|
||||
|
||||
@doc false
|
||||
def to_float(nil), do: 0.0
|
||||
def to_float(v) when is_float(v), do: v
|
||||
def to_float(v) when is_integer(v), do: v / 1
|
||||
|
||||
defp stringify_metrics(metrics) do
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -31,18 +31,22 @@ defmodule Towerops.Geocoding do
|
|||
end
|
||||
|
||||
defp get_api_key do
|
||||
api_key = get_system_api_key()
|
||||
|
||||
if api_key && String.trim(api_key) != "" do
|
||||
{:ok, String.trim(api_key)}
|
||||
else
|
||||
{:error, :no_api_key}
|
||||
end
|
||||
:towerops
|
||||
|> Application.get_env(:google_maps_api_key)
|
||||
|> fallback_env("GOOGLE_MAPS_API_KEY")
|
||||
|> validate_key()
|
||||
end
|
||||
|
||||
defp get_system_api_key do
|
||||
Application.get_env(:towerops, :google_maps_api_key) ||
|
||||
System.get_env("GOOGLE_MAPS_API_KEY")
|
||||
defp fallback_env(nil, env_var), do: System.get_env(env_var)
|
||||
defp fallback_env(value, _env_var), do: value
|
||||
|
||||
defp validate_key(nil), do: {:error, :no_api_key}
|
||||
|
||||
defp validate_key(key) when is_binary(key) do
|
||||
case String.trim(key) do
|
||||
"" -> {:error, :no_api_key}
|
||||
trimmed -> {:ok, trimmed}
|
||||
end
|
||||
end
|
||||
|
||||
defp make_geocoding_request(address, api_key) do
|
||||
|
|
@ -51,7 +55,7 @@ defmodule Towerops.Geocoding do
|
|||
"key" => api_key
|
||||
}
|
||||
|
||||
case HTTP.get(__MODULE__, @google_geocoding_url, params: params, timeout: 10_000) do
|
||||
case HTTP.get(__MODULE__, @google_geocoding_url, params: params, receive_timeout: 10_000) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
{:ok, body}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,16 +71,18 @@ defmodule Towerops.Monitoring.Executors.DnsExecutor do
|
|||
{:error, "Exception: #{Exception.message(e)}"}
|
||||
end
|
||||
|
||||
defp record_type_to_atom("A"), do: :a
|
||||
defp record_type_to_atom("AAAA"), do: :aaaa
|
||||
defp record_type_to_atom("CNAME"), do: :cname
|
||||
defp record_type_to_atom("MX"), do: :mx
|
||||
defp record_type_to_atom("TXT"), do: :txt
|
||||
defp record_type_to_atom("NS"), do: :ns
|
||||
defp record_type_to_atom("PTR"), do: :ptr
|
||||
defp record_type_to_atom(_), do: :a
|
||||
@doc false
|
||||
def record_type_to_atom("A"), do: :a
|
||||
def record_type_to_atom("AAAA"), do: :aaaa
|
||||
def record_type_to_atom("CNAME"), do: :cname
|
||||
def record_type_to_atom("MX"), do: :mx
|
||||
def record_type_to_atom("TXT"), do: :txt
|
||||
def record_type_to_atom("NS"), do: :ns
|
||||
def record_type_to_atom("PTR"), do: :ptr
|
||||
def record_type_to_atom(_), do: :a
|
||||
|
||||
defp parse_server(server_str) do
|
||||
@doc false
|
||||
def parse_server(server_str) when is_binary(server_str) do
|
||||
case String.split(server_str, ".") do
|
||||
[a, b, c, d] ->
|
||||
with {a_int, ""} <- Integer.parse(a),
|
||||
|
|
@ -98,6 +100,8 @@ defmodule Towerops.Monitoring.Executors.DnsExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
def parse_server(_), do: {{8, 8, 8, 8}, 53}
|
||||
|
||||
defp process_dns_response(dns_rec, record_type, expected, response_time) do
|
||||
answers = extract_answers(dns_rec, record_type)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,18 +83,19 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
|
|||
end
|
||||
|
||||
# Loopback
|
||||
defp private_ip?({127, _, _, _}), do: true
|
||||
@doc false
|
||||
def private_ip?({127, _, _, _}), do: true
|
||||
# Private class A
|
||||
defp private_ip?({10, _, _, _}), do: true
|
||||
def private_ip?({10, _, _, _}), do: true
|
||||
# Private class B
|
||||
defp private_ip?({172, b, _, _}) when b >= 16 and b <= 31, do: true
|
||||
def private_ip?({172, b, _, _}) when b >= 16 and b <= 31, do: true
|
||||
# Private class C
|
||||
defp private_ip?({192, 168, _, _}), do: true
|
||||
def private_ip?({192, 168, _, _}), do: true
|
||||
# Link-local / AWS metadata
|
||||
defp private_ip?({169, 254, _, _}), do: true
|
||||
def private_ip?({169, 254, _, _}), do: true
|
||||
# Unspecified
|
||||
defp private_ip?({0, 0, 0, 0}), do: true
|
||||
defp private_ip?(_), do: false
|
||||
def private_ip?({0, 0, 0, 0}), do: true
|
||||
def private_ip?(_), do: false
|
||||
|
||||
defp build_req_opts(config, timeout_ms) do
|
||||
method = String.downcase(Map.get(config, "method", "GET"))
|
||||
|
|
@ -120,15 +121,16 @@ defmodule Towerops.Monitoring.Executors.HttpExecutor do
|
|||
end
|
||||
|
||||
# Normalize HTTP method to atom using whitelist to prevent atom table pollution
|
||||
defp normalize_http_method("get"), do: :get
|
||||
defp normalize_http_method("post"), do: :post
|
||||
defp normalize_http_method("put"), do: :put
|
||||
defp normalize_http_method("patch"), do: :patch
|
||||
defp normalize_http_method("delete"), do: :delete
|
||||
defp normalize_http_method("head"), do: :head
|
||||
defp normalize_http_method("options"), do: :options
|
||||
@doc false
|
||||
def normalize_http_method("get"), do: :get
|
||||
def normalize_http_method("post"), do: :post
|
||||
def normalize_http_method("put"), do: :put
|
||||
def normalize_http_method("patch"), do: :patch
|
||||
def normalize_http_method("delete"), do: :delete
|
||||
def normalize_http_method("head"), do: :head
|
||||
def normalize_http_method("options"), do: :options
|
||||
# Default to GET for unknown methods
|
||||
defp normalize_http_method(_), do: :get
|
||||
def normalize_http_method(_), do: :get
|
||||
|
||||
defp validate_response(%Req.Response{status: status, body: body}, config, response_time) do
|
||||
expected_status = Map.get(config, "expected_status", 200)
|
||||
|
|
|
|||
|
|
@ -88,34 +88,23 @@ defmodule Towerops.Monitoring.Executors.PingExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp validate_host(host) do
|
||||
# Only allow valid hostnames and IP addresses — no shell metacharacters
|
||||
if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do
|
||||
# \A and \z anchor to absolute start/end, unlike ^/$ which also match around
|
||||
# a trailing newline (which would allow shell injection via `host\ninjected`).
|
||||
@host_pattern ~r/\A[a-zA-Z0-9\.\-\:]+\z/
|
||||
|
||||
@doc false
|
||||
def validate_host(host) when is_binary(host) do
|
||||
if Regex.match?(@host_pattern, host) do
|
||||
:ok
|
||||
else
|
||||
{:error, "Invalid host: contains disallowed characters"}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_args(host, count, timeout_ms) do
|
||||
count_str = Integer.to_string(count)
|
||||
def validate_host(_host), do: {:error, "Invalid host: must be a string"}
|
||||
|
||||
case :os.type() do
|
||||
{:unix, :darwin} ->
|
||||
# macOS: -W is in milliseconds
|
||||
["-c", count_str, "-W", Integer.to_string(timeout_ms), host]
|
||||
|
||||
{:unix, _} ->
|
||||
# Linux: -w is in seconds (total deadline)
|
||||
deadline = max(div(timeout_ms, 1000), count + 1)
|
||||
["-c", count_str, "-w", Integer.to_string(deadline), host]
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_packet_loss(output) do
|
||||
# Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss"
|
||||
# or "3 packets transmitted, 3 received, 0% packet loss"
|
||||
# or "1 packets transmitted, 0 received, +1 errors, 100% packet loss"
|
||||
@doc false
|
||||
def parse_packet_loss(output) do
|
||||
case Regex.run(
|
||||
~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received,(?:\s*\+\d+ errors,)? ([\d.]+)% packet loss/,
|
||||
output
|
||||
|
|
@ -129,20 +118,33 @@ defmodule Towerops.Monitoring.Executors.PingExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_rtt(output) do
|
||||
# macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms"
|
||||
# Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms"
|
||||
@doc false
|
||||
def parse_rtt(output) do
|
||||
case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do
|
||||
[_, avg_str] ->
|
||||
{avg_ms, _} = Float.parse(avg_str)
|
||||
{:ok, avg_ms}
|
||||
|
||||
nil ->
|
||||
# No RTT stats (100% loss case)
|
||||
{:error, "100% packet loss"}
|
||||
end
|
||||
end
|
||||
|
||||
defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%"
|
||||
defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%"
|
||||
@doc false
|
||||
def format_loss(loss_pct) when loss_pct == 0.0, do: "0%"
|
||||
def format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%"
|
||||
|
||||
@doc false
|
||||
def build_args(host, count, timeout_ms) do
|
||||
count_str = Integer.to_string(count)
|
||||
|
||||
case :os.type() do
|
||||
{:unix, :darwin} ->
|
||||
["-c", count_str, "-W", Integer.to_string(timeout_ms), host]
|
||||
|
||||
{:unix, _} ->
|
||||
deadline = max(div(timeout_ms, 1000), count + 1)
|
||||
["-c", count_str, "-w", Integer.to_string(deadline), host]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do
|
|||
{:ok, opts}
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
@doc false
|
||||
def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
opts
|
||||
|> Keyword.put(:security_name, device.snmpv3_username || "")
|
||||
|> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv")
|
||||
|
|
@ -100,7 +101,7 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do
|
|||
|> Keyword.put(:priv_password, device.snmpv3_priv_password || "")
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, device) do
|
||||
def add_snmp_credentials(opts, device) do
|
||||
Keyword.put(opts, :community, device.snmp_community || "public")
|
||||
end
|
||||
|
||||
|
|
@ -114,36 +115,29 @@ defmodule Towerops.Monitoring.Executors.SnmpInterfaceExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp determine_status(oper_status, interface) do
|
||||
# ifOperStatus values from IF-MIB:
|
||||
# 1 = up, 2 = down, 3 = testing, 4 = unknown, 5 = dormant, 6 = notPresent, 7 = lowerLayerDown
|
||||
# ifOperStatus values from IF-MIB:
|
||||
# 1 = up, 2 = down, 3 = testing, 4 = unknown, 5 = dormant, 6 = notPresent, 7 = lowerLayerDown
|
||||
@doc false
|
||||
def determine_status(1, _interface), do: 0
|
||||
def determine_status(2, %{if_admin_status: "down"}), do: 0
|
||||
def determine_status(2, _interface), do: 2
|
||||
def determine_status(status, _interface) when status in [3, 5], do: 1
|
||||
def determine_status(_status, _interface), do: 2
|
||||
|
||||
cond do
|
||||
# Interface is up - OK
|
||||
oper_status == 1 -> 0
|
||||
# Interface is down - check if this is expected
|
||||
oper_status == 2 && interface.if_admin_status == "down" -> 0
|
||||
# Interface is down but should be up - CRITICAL
|
||||
oper_status == 2 -> 2
|
||||
# Testing, dormant - WARNING
|
||||
oper_status in [3, 5] -> 1
|
||||
# Unknown, notPresent, lowerLayerDown - CRITICAL
|
||||
true -> 2
|
||||
end
|
||||
end
|
||||
|
||||
defp format_output(interface, oper_status) do
|
||||
@doc false
|
||||
def format_output(interface, oper_status) do
|
||||
status_str = oper_status_to_string(oper_status)
|
||||
interface_name = interface.if_alias || interface.if_name || interface.if_descr || "Interface #{interface.if_index}"
|
||||
"#{interface_name}: #{status_str}"
|
||||
end
|
||||
|
||||
defp oper_status_to_string(1), do: "Up"
|
||||
defp oper_status_to_string(2), do: "Down"
|
||||
defp oper_status_to_string(3), do: "Testing"
|
||||
defp oper_status_to_string(4), do: "Unknown"
|
||||
defp oper_status_to_string(5), do: "Dormant"
|
||||
defp oper_status_to_string(6), do: "Not Present"
|
||||
defp oper_status_to_string(7), do: "Lower Layer Down"
|
||||
defp oper_status_to_string(_), do: "Unknown Status"
|
||||
@doc false
|
||||
def oper_status_to_string(1), do: "Up"
|
||||
def oper_status_to_string(2), do: "Down"
|
||||
def oper_status_to_string(3), do: "Testing"
|
||||
def oper_status_to_string(4), do: "Unknown"
|
||||
def oper_status_to_string(5), do: "Dormant"
|
||||
def oper_status_to_string(6), do: "Not Present"
|
||||
def oper_status_to_string(7), do: "Lower Layer Down"
|
||||
def oper_status_to_string(_), do: "Unknown Status"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -93,7 +93,8 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do
|
|||
{:ok, opts}
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
@doc false
|
||||
def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
opts
|
||||
|> Keyword.put(:security_name, device.snmpv3_username || "")
|
||||
|> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv")
|
||||
|
|
@ -103,7 +104,7 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do
|
|||
|> Keyword.put(:priv_password, device.snmpv3_priv_password || "")
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, device) do
|
||||
def add_snmp_credentials(opts, device) do
|
||||
Keyword.put(opts, :community, device.snmp_community || "public")
|
||||
end
|
||||
|
||||
|
|
@ -118,7 +119,8 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_processor_oid(processor) do
|
||||
@doc false
|
||||
def build_processor_oid(processor) do
|
||||
# Extract numeric index from processor_index string
|
||||
# Examples: "1" -> "1", "cisco_1" -> "1", "hr_1" -> "1"
|
||||
index = String.replace(processor.processor_index, ~r/[^\d]/, "")
|
||||
|
|
@ -130,18 +132,13 @@ defmodule Towerops.Monitoring.Executors.SnmpProcessorExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp determine_status(load_percent) do
|
||||
cond do
|
||||
# CRITICAL
|
||||
load_percent >= 90 -> 2
|
||||
# WARNING
|
||||
load_percent >= 80 -> 1
|
||||
# OK
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
@doc false
|
||||
def determine_status(load_percent) when load_percent >= 90, do: 2
|
||||
def determine_status(load_percent) when load_percent >= 80, do: 1
|
||||
def determine_status(_), do: 0
|
||||
|
||||
defp format_output(processor, load_percent) do
|
||||
@doc false
|
||||
def format_output(processor, load_percent) do
|
||||
description = processor.description || "Processor #{processor.processor_index}"
|
||||
"#{description}: #{Float.round(load_percent, 1)}% load"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do
|
|||
{:ok, opts}
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
@doc false
|
||||
def add_snmp_credentials(opts, %{snmp_version: "3"} = device) do
|
||||
opts
|
||||
|> Keyword.put(:security_name, device.snmpv3_username || "")
|
||||
|> Keyword.put(:security_level, device.snmpv3_security_level || "noAuthNoPriv")
|
||||
|
|
@ -107,7 +108,7 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do
|
|||
|> Keyword.put(:priv_password, device.snmpv3_priv_password || "")
|
||||
end
|
||||
|
||||
defp add_snmp_credentials(opts, device) do
|
||||
def add_snmp_credentials(opts, device) do
|
||||
Keyword.put(opts, :community, device.snmp_community || "public")
|
||||
end
|
||||
|
||||
|
|
@ -131,28 +132,22 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do
|
|||
end
|
||||
end
|
||||
|
||||
defp ensure_integer(value) when is_integer(value), do: value
|
||||
defp ensure_integer(value) when is_float(value), do: round(value)
|
||||
defp ensure_integer(_), do: 0
|
||||
@doc false
|
||||
def ensure_integer(value) when is_integer(value), do: value
|
||||
def ensure_integer(value) when is_float(value), do: round(value)
|
||||
def ensure_integer(_), do: 0
|
||||
|
||||
defp calculate_usage_percent(%{size_units: 0}), do: 0.0
|
||||
@doc false
|
||||
def calculate_usage_percent(%{size_units: 0}), do: 0.0
|
||||
def calculate_usage_percent(%{size_units: size, used_units: used}), do: used / size * 100.0
|
||||
|
||||
defp calculate_usage_percent(%{size_units: size, used_units: used}) do
|
||||
used / size * 100.0
|
||||
end
|
||||
@doc false
|
||||
def determine_status(usage_percent) when usage_percent >= 95, do: 2
|
||||
def determine_status(usage_percent) when usage_percent >= 85, do: 1
|
||||
def determine_status(_), do: 0
|
||||
|
||||
defp determine_status(usage_percent) do
|
||||
cond do
|
||||
# CRITICAL
|
||||
usage_percent >= 95 -> 2
|
||||
# WARNING
|
||||
usage_percent >= 85 -> 1
|
||||
# OK
|
||||
true -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp format_output(storage, usage_percent, storage_data) do
|
||||
@doc false
|
||||
def format_output(storage, usage_percent, storage_data) do
|
||||
description = storage.description || storage.device_name || "Storage #{storage.storage_index}"
|
||||
total_bytes = storage_data.allocation_units * storage_data.size_units
|
||||
used_bytes = storage_data.allocation_units * storage_data.used_units
|
||||
|
|
@ -160,21 +155,10 @@ defmodule Towerops.Monitoring.Executors.SnmpStorageExecutor do
|
|||
"#{description}: #{Float.round(usage_percent, 1)}% used (#{format_bytes(used_bytes)} / #{format_bytes(total_bytes)})"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1_099_511_627_776 do
|
||||
"#{Float.round(bytes / 1_099_511_627_776, 2)} TB"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1_073_741_824 do
|
||||
"#{Float.round(bytes / 1_073_741_824, 2)} GB"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1_048_576 do
|
||||
"#{Float.round(bytes / 1_048_576, 2)} MB"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes) when bytes >= 1024 do
|
||||
"#{Float.round(bytes / 1024, 2)} KB"
|
||||
end
|
||||
|
||||
defp format_bytes(bytes), do: "#{bytes} B"
|
||||
@doc false
|
||||
def format_bytes(bytes) when bytes >= 1_099_511_627_776, do: "#{Float.round(bytes / 1_099_511_627_776, 2)} TB"
|
||||
def format_bytes(bytes) when bytes >= 1_073_741_824, do: "#{Float.round(bytes / 1_073_741_824, 2)} GB"
|
||||
def format_bytes(bytes) when bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 2)} MB"
|
||||
def format_bytes(bytes) when bytes >= 1024, do: "#{Float.round(bytes / 1024, 2)} KB"
|
||||
def format_bytes(bytes), do: "#{bytes} B"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -76,32 +76,27 @@ defmodule Towerops.Monitoring.Ping do
|
|||
{:error, :command_not_found}
|
||||
end
|
||||
|
||||
# Validate IP address format using Erlang's inet module
|
||||
defp validate_ip_address(""), do: {:error, :invalid_ip}
|
||||
@doc false
|
||||
def validate_ip_address(""), do: {:error, :invalid_ip}
|
||||
|
||||
defp validate_ip_address(ip_address) do
|
||||
def validate_ip_address(ip_address) when is_binary(ip_address) do
|
||||
case :inet.parse_address(String.to_charlist(ip_address)) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, _} -> {:error, :invalid_ip}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse the ping output to extract round-trip time
|
||||
# macOS format: "round-trip min/avg/max/stddev = 1.234/1.234/1.234/0.000 ms"
|
||||
# Linux format: "rtt min/avg/max/mdev = 1.234/1.234/1.234/0.000 ms"
|
||||
defp parse_ping_output(output) do
|
||||
@doc false
|
||||
def parse_ping_output(output) do
|
||||
cond do
|
||||
# macOS format
|
||||
match = Regex.run(~r/round-trip.*=\s*[\d.]+\/([\d.]+)\//, output) ->
|
||||
[_, avg_ms] = match
|
||||
{:ok, String.to_float(avg_ms)}
|
||||
|
||||
# Linux format
|
||||
match = Regex.run(~r/rtt.*=\s*[\d.]+\/([\d.]+)\//, output) ->
|
||||
[_, avg_ms] = match
|
||||
{:ok, String.to_float(avg_ms)}
|
||||
|
||||
# Alternative: extract from "time=X.XX ms" in the reply line
|
||||
match = Regex.run(~r/time[=<]([\d.]+)\s*ms/, output) ->
|
||||
[_, time_ms] = match
|
||||
{:ok, String.to_float(time_ms)}
|
||||
|
|
|
|||
|
|
@ -145,17 +145,16 @@ defmodule Towerops.NetBox.Client do
|
|||
|
||||
@invalid_url_error "NetBox URL is missing or invalid — a full URL with https:// is required"
|
||||
|
||||
defp normalize_url(nil), do: {:error, @invalid_url_error}
|
||||
@doc false
|
||||
def normalize_url(nil), do: {:error, @invalid_url_error}
|
||||
|
||||
defp normalize_url(url) when is_binary(url) do
|
||||
def normalize_url(url) when is_binary(url) do
|
||||
trimmed = url |> String.trim() |> String.trim_trailing("/")
|
||||
|
||||
case URI.parse(trimmed) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] ->
|
||||
{:ok, trimmed}
|
||||
|
||||
_ ->
|
||||
{:error, @invalid_url_error}
|
||||
end
|
||||
trimmed |> URI.parse() |> check_scheme(trimmed)
|
||||
end
|
||||
|
||||
def normalize_url(_), do: {:error, @invalid_url_error}
|
||||
|
||||
defp check_scheme(%URI{scheme: scheme}, url) when scheme in ["http", "https"], do: {:ok, url}
|
||||
defp check_scheme(_uri, _url), do: {:error, @invalid_url_error}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -161,63 +161,78 @@ defmodule Towerops.NetBox.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp resolve_site_id(nil, _site_by_name), do: nil
|
||||
@doc false
|
||||
def resolve_site_id(nil, _site_by_name), do: nil
|
||||
|
||||
defp resolve_site_id(site_name, site_by_name) do
|
||||
case Map.get(site_by_name, String.downcase(site_name)) do
|
||||
nil -> nil
|
||||
site -> site.id
|
||||
def resolve_site_id(site_name, site_by_name) when is_binary(site_name) do
|
||||
site_by_name |> Map.get(String.downcase(site_name)) |> site_id_from()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def site_id_from(nil), do: nil
|
||||
def site_id_from(%{id: id}), do: id
|
||||
|
||||
@doc false
|
||||
def find_existing_device(name, ip, device_by_name, device_by_ip) do
|
||||
case Map.get(device_by_name, String.downcase(name)) do
|
||||
nil -> lookup_by_ip(ip, device_by_ip)
|
||||
device -> device
|
||||
end
|
||||
end
|
||||
|
||||
defp find_existing_device(name, ip, device_by_name, device_by_ip) do
|
||||
Map.get(device_by_name, String.downcase(name)) ||
|
||||
(ip && Map.get(device_by_ip, ip))
|
||||
end
|
||||
@doc false
|
||||
def lookup_by_ip(nil, _device_by_ip), do: nil
|
||||
def lookup_by_ip(ip, device_by_ip), do: Map.get(device_by_ip, ip)
|
||||
|
||||
# --- Helpers ---
|
||||
# --- Helpers (public for testability) ---
|
||||
|
||||
defp extract_ip(nil), do: nil
|
||||
@doc false
|
||||
def extract_ip(nil), do: nil
|
||||
|
||||
defp extract_ip(%{"address" => addr}) when is_binary(addr) do
|
||||
def extract_ip(%{"address" => addr}) when is_binary(addr) do
|
||||
# NetBox returns "10.0.0.1/24" format
|
||||
addr |> String.split("/") |> List.first()
|
||||
end
|
||||
|
||||
defp extract_ip(_), do: nil
|
||||
def extract_ip(_), do: nil
|
||||
|
||||
defp map_status("active"), do: "up"
|
||||
defp map_status("offline"), do: "down"
|
||||
defp map_status("planned"), do: "pending"
|
||||
defp map_status("staged"), do: "pending"
|
||||
defp map_status("decommissioning"), do: "down"
|
||||
defp map_status(other), do: other
|
||||
@doc false
|
||||
def map_status("active"), do: "up"
|
||||
def map_status("offline"), do: "down"
|
||||
def map_status("planned"), do: "pending"
|
||||
def map_status("staged"), do: "pending"
|
||||
def map_status("decommissioning"), do: "down"
|
||||
def map_status(other), do: other
|
||||
|
||||
defp parse_decimal(nil), do: nil
|
||||
defp parse_decimal(val) when is_float(val), do: Decimal.from_float(val)
|
||||
@doc false
|
||||
def parse_decimal(nil), do: nil
|
||||
def parse_decimal(val) when is_float(val), do: Decimal.from_float(val)
|
||||
|
||||
defp parse_decimal(val) when is_binary(val) do
|
||||
def parse_decimal(val) when is_binary(val) do
|
||||
case Decimal.parse(val) do
|
||||
{dec, _} -> dec
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_decimal(val) when is_integer(val), do: Decimal.new(val)
|
||||
defp parse_decimal(_), do: nil
|
||||
def parse_decimal(val) when is_integer(val), do: Decimal.new(val)
|
||||
def parse_decimal(_), do: nil
|
||||
|
||||
defp build_device_params(credentials) do
|
||||
@doc false
|
||||
def build_device_params(credentials) do
|
||||
%{}
|
||||
|> maybe_put("role", credentials["device_role_filter"])
|
||||
|> maybe_put("site", credentials["site_filter"])
|
||||
|> maybe_put("tag", credentials["tag_filter"])
|
||||
end
|
||||
|
||||
defp maybe_put(params, _key, nil), do: params
|
||||
defp maybe_put(params, _key, ""), do: params
|
||||
defp maybe_put(params, key, value), do: Map.put(params, key, value)
|
||||
@doc false
|
||||
def maybe_put(params, _key, nil), do: params
|
||||
def maybe_put(params, _key, ""), do: params
|
||||
def maybe_put(params, key, value), do: Map.put(params, key, value)
|
||||
|
||||
defp build_site_params(credentials) do
|
||||
@doc false
|
||||
def build_site_params(credentials) do
|
||||
case credentials["tag_filter"] do
|
||||
nil -> %{}
|
||||
"" -> %{}
|
||||
|
|
@ -225,11 +240,12 @@ defmodule Towerops.NetBox.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your NetBox API token"
|
||||
@doc false
|
||||
def humanize_sync_error(:unauthorized), do: "Authentication failed — check your NetBox API token"
|
||||
|
||||
defp humanize_sync_error(:not_found), do: "API endpoint not found — check your NetBox URL"
|
||||
def humanize_sync_error(:not_found), do: "API endpoint not found — check your NetBox URL"
|
||||
|
||||
defp humanize_sync_error(reason) when is_binary(reason) do
|
||||
def humanize_sync_error(reason) when is_binary(reason) do
|
||||
if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do
|
||||
"SSL certificate verification failed — unable to establish a secure connection"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -71,19 +71,17 @@ defmodule Towerops.Profiles.ProfileWatcher do
|
|||
{:noreply, state}
|
||||
end
|
||||
|
||||
# Check if the file is a YAML profile file
|
||||
defp yaml_file?(path) do
|
||||
@doc false
|
||||
def yaml_file?(path) do
|
||||
String.ends_with?(path, [".yaml", ".yml"])
|
||||
end
|
||||
|
||||
# Check if the events indicate we should reload
|
||||
# Reload on created, modified, or removed events
|
||||
# Don't reload on :undefined or other metadata-only changes
|
||||
defp should_reload?(events) when is_list(events) do
|
||||
@doc false
|
||||
def should_reload?(events) when is_list(events) do
|
||||
Enum.any?(events, fn event ->
|
||||
event in [:created, :modified, :removed, :renamed]
|
||||
end)
|
||||
end
|
||||
|
||||
defp should_reload?(_), do: false
|
||||
def should_reload?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -129,9 +129,17 @@ defmodule Towerops.Reports do
|
|||
{:ok, header <> rows}
|
||||
end
|
||||
|
||||
defp csv_escape(nil), do: ""
|
||||
@doc """
|
||||
Escapes a value for inclusion in a CSV cell.
|
||||
|
||||
defp csv_escape(value) when is_binary(value) do
|
||||
Wraps values containing commas, quotes, or newlines in double-quotes and
|
||||
doubles any embedded quotes. `nil` becomes the empty string; non-binary
|
||||
non-nil values are coerced via `to_string/1`.
|
||||
"""
|
||||
@spec csv_escape(term()) :: String.t()
|
||||
def csv_escape(nil), do: ""
|
||||
|
||||
def csv_escape(value) when is_binary(value) do
|
||||
if String.contains?(value, [",", "\"", "\n"]) do
|
||||
"\"#{String.replace(value, "\"", "\"\"")}\""
|
||||
else
|
||||
|
|
@ -139,11 +147,17 @@ defmodule Towerops.Reports do
|
|||
end
|
||||
end
|
||||
|
||||
defp csv_escape(value), do: to_string(value)
|
||||
def csv_escape(value), do: to_string(value)
|
||||
|
||||
defp parse_time_range(scope) do
|
||||
@doc """
|
||||
Parses the `days` field of a report's scope map into a `%{from, to}` range
|
||||
ending at `DateTime.utc_now/0`. Defaults to 7 days when `days` is missing.
|
||||
"""
|
||||
@spec parse_time_range(map()) :: %{from: DateTime.t(), to: DateTime.t()}
|
||||
def parse_time_range(scope) when is_map(scope) do
|
||||
days = Map.get(scope, "days", 7)
|
||||
%{from: DateTime.add(DateTime.utc_now(), -days, :day), to: DateTime.utc_now()}
|
||||
now = DateTime.utc_now()
|
||||
%{from: DateTime.add(now, -days, :day), to: now}
|
||||
end
|
||||
|
||||
# -- Schedule Helpers --
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
"""
|
||||
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Snmp.WirelessClientDiscovery.Parser
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -48,29 +49,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
Determines the vendor profile string from an SNMP device's sys_object_id and manufacturer.
|
||||
"""
|
||||
@spec detect_vendor_profile(map()) :: String.t() | nil
|
||||
def detect_vendor_profile(snmp_device) do
|
||||
sys_oid = snmp_device.sys_object_id || ""
|
||||
manufacturer = String.downcase(snmp_device.manufacturer || "")
|
||||
|
||||
detect_cambium(sys_oid, manufacturer) ||
|
||||
detect_ubiquiti(sys_oid, manufacturer) ||
|
||||
detect_mikrotik(sys_oid, manufacturer)
|
||||
end
|
||||
|
||||
defp detect_cambium(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium"),
|
||||
do: "epmp"
|
||||
end
|
||||
|
||||
defp detect_ubiquiti(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti"),
|
||||
do: "airos"
|
||||
end
|
||||
|
||||
defp detect_mikrotik(sys_oid, manufacturer) do
|
||||
if String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik"),
|
||||
do: "routeros"
|
||||
end
|
||||
defdelegate detect_vendor_profile(snmp_device), to: Parser
|
||||
|
||||
# --- Cambium ePMP ---
|
||||
|
||||
|
|
@ -225,96 +204,12 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do
|
|||
|> Enum.reject(fn {idx, _} -> idx == "" end)
|
||||
end
|
||||
|
||||
defp get_indexed_value(walk_results, base_oid, idx) do
|
||||
Map.get(walk_results, "#{base_oid}.#{idx}")
|
||||
end
|
||||
|
||||
defp get_indexed_int(walk_results, base_oid, idx) do
|
||||
case get_indexed_value(walk_results, base_oid, idx) do
|
||||
v when is_integer(v) -> v
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_indexed_string(walk_results, base_oid, idx) do
|
||||
case get_indexed_value(walk_results, base_oid, idx) do
|
||||
v when is_binary(v) and v != "" -> v
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Format MAC from binary (6 bytes) or string to lowercase colon-separated
|
||||
defp format_mac(value) when is_binary(value) and byte_size(value) == 6 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp format_mac(value) when is_binary(value) do
|
||||
if String.contains?(value, ":") or String.contains?(value, "-") do
|
||||
# String MAC with separators - normalize to ensure proper padding
|
||||
value
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^0-9a-f]/, "")
|
||||
|> case do
|
||||
<<a::binary-2, b::binary-2, c::binary-2, d::binary-2, e::binary-2, f::binary-2>> ->
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}"
|
||||
|
||||
# Not 12 hex chars - try to parse as byte string
|
||||
_ ->
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
else
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
|
||||
defp format_mac(_), do: nil
|
||||
|
||||
# Format IP address from SNMP IpAddress type or string
|
||||
defp format_ip(value) when is_binary(value) and byte_size(value) == 4 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.join(".")
|
||||
end
|
||||
|
||||
defp format_ip(value) when is_binary(value) do
|
||||
if String.match?(value, ~r/^\d+\.\d+\.\d+\.\d+$/) do
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ip(_), do: nil
|
||||
|
||||
# Pick the best (non-nil) of two values, preferring the first
|
||||
defp best_value(nil, b), do: if(is_integer(b), do: b)
|
||||
defp best_value(a, _) when is_integer(a), do: a
|
||||
defp best_value(_, b) when is_integer(b), do: b
|
||||
defp best_value(_, _), do: nil
|
||||
|
||||
# Parse ePMP session time (DisplayString like "1d 2h 3m 4s" or seconds)
|
||||
defp parse_session_time(nil), do: nil
|
||||
|
||||
defp parse_session_time(value) when is_binary(value) do
|
||||
# Try to parse common formats
|
||||
case Integer.parse(value) do
|
||||
{seconds, ""} -> seconds
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_session_time(value) when is_integer(value), do: value
|
||||
defp parse_session_time(_), do: nil
|
||||
|
||||
# Convert TimeTicks (hundredths of seconds) to seconds
|
||||
defp timeticks_to_seconds(nil), do: nil
|
||||
defp timeticks_to_seconds(value) when is_integer(value), do: div(value, 100)
|
||||
defp timeticks_to_seconds(_), do: nil
|
||||
defdelegate get_indexed_value(walk_results, base_oid, idx), to: Parser
|
||||
defdelegate get_indexed_int(walk_results, base_oid, idx), to: Parser
|
||||
defdelegate get_indexed_string(walk_results, base_oid, idx), to: Parser
|
||||
defdelegate format_mac(value), to: Parser
|
||||
defdelegate format_ip(value), to: Parser
|
||||
defdelegate best_value(a, b), to: Parser
|
||||
defdelegate parse_session_time(value), to: Parser
|
||||
defdelegate timeticks_to_seconds(value), to: Parser
|
||||
end
|
||||
|
|
|
|||
174
lib/towerops/snmp/wireless_client_discovery/parser.ex
Normal file
174
lib/towerops/snmp/wireless_client_discovery/parser.ex
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
defmodule Towerops.Snmp.WirelessClientDiscovery.Parser do
|
||||
@moduledoc """
|
||||
Pure parsing helpers extracted from `Towerops.Snmp.WirelessClientDiscovery`.
|
||||
|
||||
These functions handle SNMP response normalization (MAC/IP formatting,
|
||||
indexed lookups, session time parsing) without any SNMP or network I/O,
|
||||
so they can be unit-tested exhaustively.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Detects the vendor profile for a wireless device based on its SNMP
|
||||
sys_object_id and manufacturer string. Returns `"epmp"`, `"airos"`,
|
||||
`"routeros"`, or `nil`.
|
||||
"""
|
||||
@spec detect_vendor_profile(map()) :: String.t() | nil
|
||||
def detect_vendor_profile(%{} = snmp_device) do
|
||||
sys_oid = Map.get(snmp_device, :sys_object_id) || ""
|
||||
manufacturer = String.downcase(Map.get(snmp_device, :manufacturer) || "")
|
||||
|
||||
detect_cambium(sys_oid, manufacturer) ||
|
||||
detect_ubiquiti(sys_oid, manufacturer) ||
|
||||
detect_mikrotik(sys_oid, manufacturer)
|
||||
end
|
||||
|
||||
def detect_vendor_profile(_), do: nil
|
||||
|
||||
defp detect_cambium("1.3.6.1.4.1.17713" <> _, _manufacturer), do: "epmp"
|
||||
|
||||
defp detect_cambium(_oid, manufacturer) do
|
||||
if String.contains?(manufacturer, "cambium"), do: "epmp"
|
||||
end
|
||||
|
||||
defp detect_ubiquiti("1.3.6.1.4.1.41112" <> _, _manufacturer), do: "airos"
|
||||
|
||||
defp detect_ubiquiti(_oid, manufacturer) do
|
||||
if String.contains?(manufacturer, "ubiquiti"), do: "airos"
|
||||
end
|
||||
|
||||
defp detect_mikrotik("1.3.6.1.4.1.14988" <> _, _manufacturer), do: "routeros"
|
||||
|
||||
defp detect_mikrotik(_oid, manufacturer) do
|
||||
if String.contains?(manufacturer, "mikrotik"), do: "routeros"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Extracts indexed value from a walk result map keyed by `#{"{base_oid}.{idx}"}`.
|
||||
"""
|
||||
@spec get_indexed_value(map(), String.t(), String.t()) :: term()
|
||||
def get_indexed_value(walk_results, base_oid, idx) when is_map(walk_results) do
|
||||
Map.get(walk_results, "#{base_oid}.#{idx}")
|
||||
end
|
||||
|
||||
def get_indexed_value(_, _, _), do: nil
|
||||
|
||||
@doc """
|
||||
Like `get_indexed_value/3` but returns `nil` unless the value is an integer.
|
||||
"""
|
||||
@spec get_indexed_int(map(), String.t(), String.t()) :: integer() | nil
|
||||
def get_indexed_int(walk_results, base_oid, idx) do
|
||||
case get_indexed_value(walk_results, base_oid, idx) do
|
||||
v when is_integer(v) -> v
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Like `get_indexed_value/3` but returns `nil` unless the value is a non-empty string.
|
||||
"""
|
||||
@spec get_indexed_string(map(), String.t(), String.t()) :: String.t() | nil
|
||||
def get_indexed_string(walk_results, base_oid, idx) do
|
||||
case get_indexed_value(walk_results, base_oid, idx) do
|
||||
v when is_binary(v) and v != "" -> v
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a MAC address to lowercase colon-separated form.
|
||||
|
||||
Accepts:
|
||||
- 6-byte binary (SNMP OctetString)
|
||||
- Separated strings like `"AA:BB:CC:DD:EE:01"` or `"aa-bb-cc-dd-ee-01"`
|
||||
- Other binary forms are treated as raw bytes
|
||||
"""
|
||||
@spec format_mac(term()) :: String.t() | nil
|
||||
def format_mac(value) when is_binary(value) and byte_size(value) == 6 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
def format_mac(value) when is_binary(value) do
|
||||
if has_separator?(value) do
|
||||
normalize_separated_mac(value)
|
||||
else
|
||||
bytes_to_mac(value)
|
||||
end
|
||||
end
|
||||
|
||||
def format_mac(_), do: nil
|
||||
|
||||
defp has_separator?(value) do
|
||||
String.contains?(value, ":") or String.contains?(value, "-")
|
||||
end
|
||||
|
||||
defp normalize_separated_mac(value) do
|
||||
cleaned = value |> String.downcase() |> String.replace(~r/[^0-9a-f]/, "")
|
||||
|
||||
case cleaned do
|
||||
<<a::binary-2, b::binary-2, c::binary-2, d::binary-2, e::binary-2, f::binary-2>> ->
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}"
|
||||
|
||||
_ ->
|
||||
bytes_to_mac(value)
|
||||
end
|
||||
end
|
||||
|
||||
defp bytes_to_mac(value) do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats an IP address from a 4-byte binary (SNMP IpAddress) or pre-formatted
|
||||
dotted string. Returns `nil` for invalid input.
|
||||
"""
|
||||
@spec format_ip(term()) :: String.t() | nil
|
||||
def format_ip(value) when is_binary(value) and byte_size(value) == 4 do
|
||||
value |> :binary.bin_to_list() |> Enum.join(".")
|
||||
end
|
||||
|
||||
def format_ip(value) when is_binary(value) do
|
||||
if String.match?(value, ~r/\A\d+\.\d+\.\d+\.\d+\z/), do: value
|
||||
end
|
||||
|
||||
def format_ip(_), do: nil
|
||||
|
||||
@doc """
|
||||
Returns the "better" of two values — prefers the first if it's an integer,
|
||||
otherwise uses the second if integer, otherwise `nil`.
|
||||
"""
|
||||
@spec best_value(term(), term()) :: integer() | nil
|
||||
def best_value(a, _) when is_integer(a), do: a
|
||||
def best_value(_, b) when is_integer(b), do: b
|
||||
def best_value(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Parses a session-time value. Accepts integer (returned as-is), integer
|
||||
string (parsed), or anything else → `nil`.
|
||||
"""
|
||||
@spec parse_session_time(term()) :: integer() | nil
|
||||
def parse_session_time(nil), do: nil
|
||||
def parse_session_time(v) when is_integer(v), do: v
|
||||
|
||||
def parse_session_time(v) when is_binary(v) do
|
||||
case Integer.parse(v) do
|
||||
{seconds, ""} -> seconds
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def parse_session_time(_), do: nil
|
||||
|
||||
@doc """
|
||||
Converts SNMP TimeTicks (hundredths of a second) to seconds.
|
||||
"""
|
||||
@spec timeticks_to_seconds(term()) :: integer() | nil
|
||||
def timeticks_to_seconds(nil), do: nil
|
||||
def timeticks_to_seconds(v) when is_integer(v), do: div(v, 100)
|
||||
def timeticks_to_seconds(_), do: nil
|
||||
end
|
||||
|
|
@ -151,19 +151,18 @@ defmodule Towerops.Sonar.Client do
|
|||
json: %{"query" => query_string, "variables" => variables}
|
||||
]
|
||||
|
||||
req_opts =
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
req_opts
|
||||
end
|
||||
|
||||
Req.request(req_opts)
|
||||
req_opts
|
||||
|> maybe_attach_test_plug(Application.get_env(:towerops, :env))
|
||||
|> Req.request()
|
||||
rescue
|
||||
exception ->
|
||||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp maybe_attach_test_plug(req_opts, :test), do: Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
|
||||
defp maybe_attach_test_plug(req_opts, _env), do: req_opts
|
||||
|
||||
defp get_retry_after(headers) when is_map(headers) do
|
||||
case Map.get(headers, "retry-after") do
|
||||
[value | _] -> parse_retry_after(value)
|
||||
|
|
|
|||
|
|
@ -50,32 +50,39 @@ defmodule Towerops.Sonar.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp calculate_total_mrr(accounts) do
|
||||
@doc false
|
||||
def calculate_total_mrr(accounts) do
|
||||
Enum.reduce(accounts, 0.0, fn account, total ->
|
||||
services = account["account_services"] || []
|
||||
total + sum_service_prices(services)
|
||||
end)
|
||||
end
|
||||
|
||||
defp sum_service_prices(services) do
|
||||
Enum.reduce(services, 0.0, fn service, acc ->
|
||||
price = service["price_override"]
|
||||
if is_number(price), do: acc + price, else: acc
|
||||
end)
|
||||
@doc false
|
||||
def sum_service_prices(services) do
|
||||
Enum.reduce(services, 0.0, fn service, acc -> add_price(acc, service["price_override"]) end)
|
||||
end
|
||||
|
||||
defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token"
|
||||
defp humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions"
|
||||
defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s"
|
||||
defp humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}"
|
||||
@doc false
|
||||
def add_price(acc, price) when is_number(price), do: acc + price
|
||||
def add_price(acc, _price), do: acc
|
||||
|
||||
defp humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do
|
||||
@doc false
|
||||
def humanize_sync_error(:unauthorized), do: "Authentication failed — check your API token"
|
||||
|
||||
def humanize_sync_error(:forbidden), do: "Access denied — your API token may not have sufficient permissions"
|
||||
|
||||
def humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Sonar — retry after #{retry_after}s"
|
||||
|
||||
def humanize_sync_error({:unexpected_status, status}), do: "Sonar returned unexpected HTTP #{status}"
|
||||
|
||||
def humanize_sync_error({:graphql_errors, errors}) when is_list(errors) do
|
||||
messages = Enum.map_join(errors, "; ", &(&1["message"] || inspect(&1)))
|
||||
"GraphQL errors: #{messages}"
|
||||
end
|
||||
|
||||
defp humanize_sync_error(reason) when is_binary(reason) do
|
||||
if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do
|
||||
def humanize_sync_error(reason) when is_binary(reason) do
|
||||
if ssl_failure?(reason) do
|
||||
"SSL certificate verification failed — unable to establish a secure connection"
|
||||
else
|
||||
Logger.warning("Sync failed with raw error: #{reason}")
|
||||
|
|
@ -83,8 +90,10 @@ defmodule Towerops.Sonar.Sync do
|
|||
end
|
||||
end
|
||||
|
||||
defp humanize_sync_error(reason) do
|
||||
def humanize_sync_error(reason) do
|
||||
Logger.warning("Sonar sync failed with unexpected error: #{inspect(reason)}")
|
||||
"An unexpected error occurred during sync"
|
||||
end
|
||||
|
||||
defp ssl_failure?(reason), do: String.contains?(reason, ["CA trust store", "cacert", "certificate"])
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ defmodule Towerops.Topology.Lldp do
|
|||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Topology.Lldp.Parser
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -210,69 +211,10 @@ defmodule Towerops.Topology.Lldp do
|
|||
|> Enum.reject(fn n -> n.neighbor_name == "" end)
|
||||
end
|
||||
|
||||
# Extracts the suffix after the base OID
|
||||
defp extract_suffix(oid, base) do
|
||||
prefix = base <> "."
|
||||
defdelegate extract_suffix(oid, base), to: Parser
|
||||
defdelegate parse_remote_key(oid, base), to: Parser
|
||||
|
||||
if String.starts_with?(oid, prefix) do
|
||||
String.replace_prefix(oid, prefix, "")
|
||||
end
|
||||
end
|
||||
|
||||
# Parses remote table key from OID: timeMark.portNum.remIndex
|
||||
defp parse_remote_key(oid, base) do
|
||||
case extract_suffix(oid, base) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
suffix ->
|
||||
case String.split(suffix, ".", parts: 4) do
|
||||
[time_mark, port_num, rem_index | _] ->
|
||||
{time_mark, port_num, rem_index}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses management address from OID index
|
||||
# Format: timeMark.portNum.remIndex.addrSubtype.addrLen.addr[bytes]
|
||||
# addrSubtype: 1=IPv4, 2=IPv6
|
||||
defp parse_management_address(oid) do
|
||||
with suffix when not is_nil(suffix) <- extract_suffix(oid, @oid_rem_man_addr),
|
||||
parts = String.split(suffix, "."),
|
||||
true <- length(parts) >= 9,
|
||||
[time_mark, port_num, rem_index, addr_subtype | rest] <- parts,
|
||||
ip when not is_nil(ip) <- parse_ip_from_addr_subtype(addr_subtype, rest) do
|
||||
{{time_mark, port_num, rem_index}, ip}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parses IP address based on address subtype
|
||||
defp parse_ip_from_addr_subtype("1", rest), do: parse_ipv4(Enum.slice(rest, 1, 4))
|
||||
defp parse_ip_from_addr_subtype("2", rest), do: parse_ipv6(Enum.slice(rest, 1, 16))
|
||||
defp parse_ip_from_addr_subtype(_, _), do: nil
|
||||
|
||||
# Parse IPv4 address from OID octets
|
||||
defp parse_ipv4(octets) when length(octets) == 4 do
|
||||
Enum.join(octets, ".")
|
||||
end
|
||||
|
||||
defp parse_ipv4(_), do: nil
|
||||
|
||||
# Parse IPv6 address from OID octets
|
||||
defp parse_ipv6(octets) when length(octets) == 16 do
|
||||
octets
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Enum.chunk_every(2)
|
||||
|> Enum.map_join(":", fn [a, b] -> Integer.to_string(a * 256 + b, 16) end)
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp parse_ipv6(_), do: nil
|
||||
defp parse_management_address(oid), do: Parser.parse_management_address(oid, @oid_rem_man_addr)
|
||||
|
||||
# Builds SNMP client options from device configuration
|
||||
defp build_client_opts(device) do
|
||||
|
|
@ -284,30 +226,23 @@ defmodule Towerops.Topology.Lldp do
|
|||
port: device.snmp_port || 161
|
||||
]
|
||||
|
||||
# Add version-specific credentials
|
||||
if snmp_config.version == "3" do
|
||||
v3_config = Devices.get_snmpv3_config(device)
|
||||
|
||||
base_opts ++
|
||||
[
|
||||
security_name: v3_config.username,
|
||||
security_level: v3_config.security_level,
|
||||
auth_protocol: v3_config.auth_protocol,
|
||||
auth_password: v3_config.auth_password,
|
||||
priv_protocol: v3_config.priv_protocol,
|
||||
priv_password: v3_config.priv_password
|
||||
]
|
||||
else
|
||||
base_opts ++ [community: snmp_config.community]
|
||||
end
|
||||
base_opts ++ version_credentials(snmp_config, device)
|
||||
end
|
||||
|
||||
# Cleans string values from SNMP (removes control characters, trims whitespace)
|
||||
defp clean_string(value) when is_binary(value) do
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.replace(~r/[\x00-\x1F\x7F]/, "")
|
||||
defp version_credentials(%{version: "3"}, device) do
|
||||
v3 = Devices.get_snmpv3_config(device)
|
||||
|
||||
[
|
||||
security_name: v3.username,
|
||||
security_level: v3.security_level,
|
||||
auth_protocol: v3.auth_protocol,
|
||||
auth_password: v3.auth_password,
|
||||
priv_protocol: v3.priv_protocol,
|
||||
priv_password: v3.priv_password
|
||||
]
|
||||
end
|
||||
|
||||
defp clean_string(value), do: to_string(value)
|
||||
defp version_credentials(snmp_config, _device), do: [community: snmp_config.community]
|
||||
|
||||
defdelegate clean_string(value), to: Parser
|
||||
end
|
||||
|
|
|
|||
105
lib/towerops/topology/lldp/parser.ex
Normal file
105
lib/towerops/topology/lldp/parser.ex
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
defmodule Towerops.Topology.Lldp.Parser do
|
||||
@moduledoc """
|
||||
Pure parsers for LLDP-MIB OID suffixes and management addresses.
|
||||
|
||||
Extracted from `Towerops.Topology.Lldp` so parsing logic can be tested
|
||||
without SNMP or database dependencies.
|
||||
"""
|
||||
|
||||
@type remote_key :: {time_mark :: String.t(), port_num :: String.t(), rem_index :: String.t()}
|
||||
|
||||
@doc """
|
||||
Extracts the portion of `oid` after `base <> "."`, or `nil` if the OID
|
||||
does not start with the expected prefix.
|
||||
"""
|
||||
@spec extract_suffix(String.t(), String.t()) :: String.t() | nil
|
||||
def extract_suffix(oid, base) do
|
||||
prefix = base <> "."
|
||||
|
||||
if String.starts_with?(oid, prefix) do
|
||||
String.replace_prefix(oid, prefix, "")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a remote-table OID key (timeMark.portNum.remIndex.*).
|
||||
|
||||
Returns the 3-tuple or `nil` when the suffix cannot be decomposed.
|
||||
"""
|
||||
@spec parse_remote_key(String.t(), String.t()) :: remote_key() | nil
|
||||
def parse_remote_key(oid, base) do
|
||||
with suffix when not is_nil(suffix) <- extract_suffix(oid, base),
|
||||
[time_mark, port_num, rem_index | _] <- String.split(suffix, ".", parts: 4) do
|
||||
{time_mark, port_num, rem_index}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Parses a management address OID of the form:
|
||||
`base.timeMark.portNum.remIndex.addrSubtype.addrLen.addr[bytes]`.
|
||||
|
||||
Returns `{remote_key, ip_string}` or `nil` on any parse failure.
|
||||
`addrSubtype` is `"1"` for IPv4 and `"2"` for IPv6.
|
||||
"""
|
||||
@spec parse_management_address(String.t(), String.t()) ::
|
||||
{remote_key(), String.t()} | nil
|
||||
def parse_management_address(oid, base) do
|
||||
with suffix when not is_nil(suffix) <- extract_suffix(oid, base),
|
||||
parts = String.split(suffix, "."),
|
||||
true <- length(parts) >= 9,
|
||||
[time_mark, port_num, rem_index, addr_subtype | rest] <- parts,
|
||||
ip when not is_nil(ip) <- parse_ip_from_addr_subtype(addr_subtype, rest) do
|
||||
{{time_mark, port_num, rem_index}, ip}
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds an IP string from an OID-encoded address given its subtype.
|
||||
|
||||
Only `"1"` (IPv4) and `"2"` (IPv6) are recognized; all other subtypes
|
||||
return `nil`. Returns `nil` if the byte list is malformed.
|
||||
"""
|
||||
@spec parse_ip_from_addr_subtype(String.t(), [String.t()]) :: String.t() | nil
|
||||
def parse_ip_from_addr_subtype("1", rest), do: parse_ipv4(Enum.slice(rest, 1, 4))
|
||||
def parse_ip_from_addr_subtype("2", rest), do: parse_ipv6(Enum.slice(rest, 1, 16))
|
||||
def parse_ip_from_addr_subtype(_, _), do: nil
|
||||
|
||||
@doc """
|
||||
Joins 4 string octets into a dotted IPv4 string, or returns `nil`.
|
||||
"""
|
||||
@spec parse_ipv4([String.t()]) :: String.t() | nil
|
||||
def parse_ipv4(octets) when length(octets) == 4, do: Enum.join(octets, ".")
|
||||
def parse_ipv4(_), do: nil
|
||||
|
||||
@doc """
|
||||
Converts 16 decimal-string octets into a lowercase colon-separated IPv6
|
||||
string, or returns `nil` if input shape is wrong.
|
||||
"""
|
||||
@spec parse_ipv6([String.t()]) :: String.t() | nil
|
||||
def parse_ipv6(octets) when length(octets) == 16 do
|
||||
octets
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Enum.chunk_every(2)
|
||||
|> Enum.map_join(":", fn [a, b] -> Integer.to_string(a * 256 + b, 16) end)
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
def parse_ipv6(_), do: nil
|
||||
|
||||
@doc """
|
||||
Strips control characters and surrounding whitespace from an SNMP string.
|
||||
Non-binary values are coerced via `to_string/1`.
|
||||
"""
|
||||
@spec clean_string(term()) :: String.t()
|
||||
def clean_string(value) when is_binary(value) do
|
||||
value
|
||||
|> String.trim()
|
||||
|> String.replace(~r/[\x00-\x1F\x7F]/, "")
|
||||
end
|
||||
|
||||
def clean_string(value), do: to_string(value)
|
||||
end
|
||||
|
|
@ -433,23 +433,34 @@ defmodule Towerops.Trace do
|
|||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp format_address(nil), do: nil
|
||||
@doc """
|
||||
Formats a structured address map into a single comma-separated string.
|
||||
|
||||
defp format_address(addr) when is_map(addr) do
|
||||
parts =
|
||||
Enum.reject(
|
||||
[
|
||||
addr["street1"] || addr["line1"],
|
||||
addr["street2"] || addr["line2"],
|
||||
addr["city"],
|
||||
addr["state"] || addr["province"],
|
||||
addr["zip"] || addr["postal_code"]
|
||||
],
|
||||
&(is_nil(&1) or String.trim(&1) == "")
|
||||
)
|
||||
Accepts either `line1/line2` or `street1/street2` styling and either
|
||||
`state/province` and `zip/postal_code`. Returns `nil` for empty maps
|
||||
or non-map input.
|
||||
"""
|
||||
@spec format_address(map() | nil | term()) :: String.t() | nil
|
||||
def format_address(nil), do: nil
|
||||
|
||||
if parts == [], do: nil, else: Enum.join(parts, ", ")
|
||||
def format_address(addr) when is_map(addr) do
|
||||
[
|
||||
addr["street1"] || addr["line1"],
|
||||
addr["street2"] || addr["line2"],
|
||||
addr["city"],
|
||||
addr["state"] || addr["province"],
|
||||
addr["zip"] || addr["postal_code"]
|
||||
]
|
||||
|> Enum.reject(&blank?/1)
|
||||
|> join_or_nil()
|
||||
end
|
||||
|
||||
defp format_address(_), do: nil
|
||||
def format_address(_), do: nil
|
||||
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(s) when is_binary(s), do: String.trim(s) == ""
|
||||
defp blank?(_), do: false
|
||||
|
||||
defp join_or_nil([]), do: nil
|
||||
defp join_or_nil(parts), do: Enum.join(parts, ", ")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -41,7 +41,8 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
Client.request_raw(:get, "#{base_url}/devices/#{device_id}", api_key)
|
||||
end
|
||||
|
||||
defp store_if_changed(device, config_data, acc) do
|
||||
@doc false
|
||||
def store_if_changed(device, config_data, acc) do
|
||||
config_json = Jason.encode!(config_data)
|
||||
config_hash = :sha256 |> :crypto.hash(config_json) |> Base.encode16(case: :lower)
|
||||
|
||||
|
|
@ -73,9 +74,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
Gets the latest config snapshot for a device.
|
||||
"""
|
||||
def get_latest_snapshot(device_id) do
|
||||
device_id = cast_uuid(device_id)
|
||||
|
||||
Repo.one(
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
where: s.device_id == type(^device_id, :binary_id),
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: 1,
|
||||
select: %{
|
||||
|
|
@ -93,9 +96,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
Lists config snapshots for a device, most recent first.
|
||||
"""
|
||||
def list_snapshots(device_id, limit \\ 20) do
|
||||
device_id = cast_uuid(device_id)
|
||||
|
||||
Repo.all(
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.device_id == ^device_id,
|
||||
where: s.device_id == type(^device_id, :binary_id),
|
||||
order_by: [desc: s.snapshot_at],
|
||||
limit: ^limit,
|
||||
select: %{
|
||||
|
|
@ -113,9 +118,11 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
Gets and decompresses a specific snapshot's config.
|
||||
"""
|
||||
def get_config(snapshot_id) do
|
||||
snapshot_id = cast_uuid(snapshot_id)
|
||||
|
||||
case Repo.one(
|
||||
from(s in "uisp_config_snapshots",
|
||||
where: s.id == ^snapshot_id,
|
||||
where: s.id == type(^snapshot_id, :binary_id),
|
||||
select: s.config_compressed
|
||||
)
|
||||
) do
|
||||
|
|
@ -127,6 +134,18 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
end
|
||||
end
|
||||
|
||||
# Normalizes a UUID input — schemas hand us a string; callers may pass a
|
||||
# 16-byte binary. `type(^value, :binary_id)` handles both uniformly as long
|
||||
# as the Elixir value is a string.
|
||||
defp cast_uuid(value) when is_binary(value) and byte_size(value) == 16 do
|
||||
case Ecto.UUID.load(value) do
|
||||
{:ok, str} -> str
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp cast_uuid(value), do: value
|
||||
|
||||
@doc """
|
||||
Diffs two config snapshots, returning added/removed/changed keys.
|
||||
"""
|
||||
|
|
@ -137,36 +156,43 @@ defmodule Towerops.Uisp.ConfigSnapshot do
|
|||
end
|
||||
end
|
||||
|
||||
defp compute_diff(a, b) when is_map(a) and is_map(b) do
|
||||
@doc """
|
||||
Computes a shallow diff of two config maps.
|
||||
|
||||
Returns `%{added: [...], removed: [...], changed: [...]}` where:
|
||||
- `added` entries are `{key, new_value}` (present only in `b`)
|
||||
- `removed` entries are `{key, old_value}` (present only in `a`)
|
||||
- `changed` entries are `{key, old_value, new_value}` (differ between sides)
|
||||
|
||||
Non-map inputs yield an empty diff.
|
||||
"""
|
||||
@spec compute_diff(map(), map()) :: %{added: list(), removed: list(), changed: list()}
|
||||
def compute_diff(a, b) when is_map(a) and is_map(b) do
|
||||
all_keys = MapSet.union(MapSet.new(Map.keys(a)), MapSet.new(Map.keys(b)))
|
||||
|
||||
Enum.reduce(all_keys, %{added: [], removed: [], changed: []}, fn key, acc ->
|
||||
case {Map.get(a, key), Map.get(b, key)} do
|
||||
{nil, new_val} ->
|
||||
Map.update!(acc, :added, &[{key, new_val} | &1])
|
||||
|
||||
{old_val, nil} ->
|
||||
Map.update!(acc, :removed, &[{key, old_val} | &1])
|
||||
|
||||
{same, same} ->
|
||||
acc
|
||||
|
||||
{old_val, new_val} ->
|
||||
Map.update!(acc, :changed, &[{key, old_val, new_val} | &1])
|
||||
end
|
||||
classify_key(acc, key, Map.get(a, key), Map.get(b, key))
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_diff(_a, _b), do: %{added: [], removed: [], changed: []}
|
||||
def compute_diff(_a, _b), do: %{added: [], removed: [], changed: []}
|
||||
|
||||
defp classify_key(acc, key, nil, new_val), do: Map.update!(acc, :added, &[{key, new_val} | &1])
|
||||
|
||||
defp classify_key(acc, key, old_val, nil), do: Map.update!(acc, :removed, &[{key, old_val} | &1])
|
||||
|
||||
defp classify_key(acc, _key, same, same), do: acc
|
||||
|
||||
defp classify_key(acc, key, old_val, new_val), do: Map.update!(acc, :changed, &[{key, old_val, new_val} | &1])
|
||||
|
||||
defp insert_snapshot(attrs) do
|
||||
{:ok, id_binary} = Ecto.UUID.dump(Ecto.UUID.generate())
|
||||
{:ok, device_id_binary} = Ecto.UUID.dump(attrs.device_id)
|
||||
|
||||
row = Map.merge(attrs, %{id: id_binary, device_id: device_id_binary, inserted_at: Towerops.Time.now()})
|
||||
|
||||
"uisp_config_snapshots"
|
||||
|> Repo.insert_all([
|
||||
Map.merge(attrs, %{
|
||||
id: Ecto.UUID.generate(),
|
||||
inserted_at: Towerops.Time.now()
|
||||
})
|
||||
])
|
||||
|> Repo.insert_all([row])
|
||||
|> case do
|
||||
{1, _} -> {:ok, :inserted}
|
||||
_ -> {:error, :insert_failed}
|
||||
|
|
|
|||
|
|
@ -69,22 +69,24 @@ defmodule Towerops.Uisp.GpsSync do
|
|||
end
|
||||
end
|
||||
|
||||
defp valid_coords?(lat, lon) do
|
||||
lat = to_float(lat)
|
||||
lon = to_float(lon)
|
||||
is_number(lat) and is_number(lon) and lat != 0.0 and lon != 0.0
|
||||
end
|
||||
@doc false
|
||||
def valid_coords?(lat, lon), do: check_coords(to_float(lat), to_float(lon))
|
||||
|
||||
defp to_float(nil), do: nil
|
||||
defp to_float(v) when is_float(v), do: v
|
||||
defp to_float(v) when is_integer(v), do: v / 1
|
||||
defp check_coords(lat, lon) when is_number(lat) and is_number(lon) and lat != 0.0 and lon != 0.0, do: true
|
||||
|
||||
defp to_float(v) when is_binary(v) do
|
||||
defp check_coords(_lat, _lon), do: false
|
||||
|
||||
@doc false
|
||||
def to_float(nil), do: nil
|
||||
def to_float(v) when is_float(v), do: v
|
||||
def to_float(v) when is_integer(v), do: v / 1
|
||||
|
||||
def to_float(v) when is_binary(v) do
|
||||
case Float.parse(v) do
|
||||
{f, _} -> f
|
||||
:error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp to_float(_), do: nil
|
||||
def to_float(_), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -59,20 +59,23 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
|
||||
defp process_single_stat(local_device, stat, acc) do
|
||||
interface_name = stat["interfaceName"] || stat["name"] || "unknown"
|
||||
checked_at = parse_timestamp(stat["timestamp"])
|
||||
dispatch_stat(local_device, stat, interface_name, parse_timestamp(stat["timestamp"]), acc)
|
||||
end
|
||||
|
||||
cond do
|
||||
is_nil(checked_at) ->
|
||||
Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
defp dispatch_stat(_dev, _stat, _iface, nil, acc), do: Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
|
||||
recently_polled?(local_device.id, interface_name, checked_at) ->
|
||||
Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
defp dispatch_stat(dev, stat, iface, checked_at, acc) do
|
||||
if recently_polled?(dev.id, iface, checked_at) do
|
||||
Map.update!(acc, :skipped_dedup, &(&1 + 1))
|
||||
else
|
||||
insert_and_record(dev, iface, stat, checked_at, acc)
|
||||
end
|
||||
end
|
||||
|
||||
true ->
|
||||
case insert_stat(local_device, interface_name, stat, checked_at) do
|
||||
{:ok, _} -> Map.update!(acc, :inserted, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
defp insert_and_record(dev, iface, stat, checked_at, acc) do
|
||||
case insert_stat(dev, iface, stat, checked_at) do
|
||||
{:ok, _} -> Map.update!(acc, :inserted, &(&1 + 1))
|
||||
{:error, _} -> acc
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -123,31 +126,36 @@ defmodule Towerops.Uisp.StatisticsSync do
|
|||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp parse_counter(nil), do: 0
|
||||
defp parse_counter(val) when is_integer(val), do: val
|
||||
defp parse_counter(val) when is_float(val), do: round(val)
|
||||
@doc false
|
||||
def parse_counter(nil), do: 0
|
||||
def parse_counter(val) when is_integer(val), do: val
|
||||
def parse_counter(val) when is_float(val), do: round(val)
|
||||
|
||||
defp parse_counter(val) when is_binary(val) do
|
||||
def parse_counter(val) when is_binary(val) do
|
||||
case Integer.parse(val) do
|
||||
{n, _} -> n
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_counter(_), do: 0
|
||||
def parse_counter(_), do: 0
|
||||
|
||||
defp parse_timestamp(nil), do: nil
|
||||
@doc false
|
||||
def parse_timestamp(nil), do: nil
|
||||
|
||||
defp parse_timestamp(ts) when is_binary(ts) do
|
||||
def parse_timestamp(ts) when is_binary(ts) do
|
||||
case DateTime.from_iso8601(ts) do
|
||||
{:ok, dt, _offset} -> DateTime.truncate(dt, :second)
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_timestamp(ts) when is_integer(ts) do
|
||||
ts |> DateTime.from_unix() |> elem(1) |> DateTime.truncate(:second)
|
||||
def parse_timestamp(ts) when is_integer(ts) do
|
||||
case DateTime.from_unix(ts) do
|
||||
{:ok, dt} -> DateTime.truncate(dt, :second)
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_timestamp(_), do: nil
|
||||
def parse_timestamp(_), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -43,18 +43,18 @@ defmodule Towerops.Weather do
|
|||
Returns a map of site_id => observation.
|
||||
"""
|
||||
def latest_observations_for_org(organization_id) do
|
||||
# Subquery to get latest observation per site
|
||||
latest_ids =
|
||||
# Subquery: latest observed_at per site (IDs are UUIDs, so max(id) is invalid).
|
||||
latest_timestamps =
|
||||
from(o in Observation,
|
||||
join: s in assoc(o, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
group_by: o.site_id,
|
||||
select: %{site_id: o.site_id, max_id: max(o.id)}
|
||||
select: %{site_id: o.site_id, max_observed_at: max(o.observed_at)}
|
||||
)
|
||||
|
||||
from(o in Observation,
|
||||
join: l in subquery(latest_ids),
|
||||
on: o.id == l.max_id
|
||||
join: l in subquery(latest_timestamps),
|
||||
on: o.site_id == l.site_id and o.observed_at == l.max_observed_at
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Map.new(fn obs -> {obs.site_id, obs} end)
|
||||
|
|
@ -135,22 +135,21 @@ defmodule Towerops.Weather do
|
|||
Fetches and stores current weather for a site.
|
||||
Requires the site to have latitude and longitude set.
|
||||
"""
|
||||
def fetch_and_store(site) do
|
||||
if site.latitude && site.longitude do
|
||||
case Client.get_current_weather(site.latitude, site.longitude) do
|
||||
{:ok, data} ->
|
||||
attrs = Observation.from_owm(site.id, data)
|
||||
create_observation(attrs)
|
||||
def fetch_and_store(%{latitude: lat, longitude: lon} = site) when not is_nil(lat) and not is_nil(lon) do
|
||||
case Client.get_current_weather(lat, lon) do
|
||||
{:ok, data} ->
|
||||
site.id
|
||||
|> Observation.from_owm(data)
|
||||
|> create_observation()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
{:error, :no_coordinates}
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_and_store(_site), do: {:error, :no_coordinates}
|
||||
|
||||
# ── Correlation Helpers ──────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
|
|
@ -210,16 +209,16 @@ defmodule Towerops.Weather do
|
|||
:clear, :mild, :moderate, :severe
|
||||
"""
|
||||
def severity(%Observation{} = obs) do
|
||||
cond do
|
||||
severe_severity?(obs) -> :severe
|
||||
moderate_severity?(obs) -> :moderate
|
||||
mild_severity?(obs) -> :mild
|
||||
true -> :clear
|
||||
end
|
||||
classify_severity({severe_severity?(obs), moderate_severity?(obs), mild_severity?(obs)})
|
||||
end
|
||||
|
||||
def severity(_), do: :clear
|
||||
|
||||
defp classify_severity({true, _, _}), do: :severe
|
||||
defp classify_severity({_, true, _}), do: :moderate
|
||||
defp classify_severity({_, _, true}), do: :mild
|
||||
defp classify_severity(_), do: :clear
|
||||
|
||||
defp high_wind?(obs), do: (obs.wind_speed_ms || 0) > 15
|
||||
defp strong_gust?(obs), do: (obs.wind_gust_ms || 0) > 20
|
||||
defp heavy_rain?(obs), do: (obs.rain_1h_mm || 0) > 10
|
||||
|
|
|
|||
|
|
@ -18,36 +18,41 @@ defmodule Towerops.Weather.Client do
|
|||
Returns `{:ok, map}` with the raw OWM response or `{:error, reason}`.
|
||||
"""
|
||||
def get_current_weather(lat, lon, opts \\ []) do
|
||||
api_key = opts[:api_key] || api_key()
|
||||
with {:ok, key} <- resolve_api_key(opts[:api_key]) do
|
||||
req_opts =
|
||||
maybe_add_test_plug(
|
||||
[url: "#{@base_url}/weather", params: [lat: lat, lon: lon, appid: key]],
|
||||
opts
|
||||
)
|
||||
|
||||
if is_nil(api_key) or api_key == "" do
|
||||
{:error, :no_api_key}
|
||||
else
|
||||
url = "#{@base_url}/weather"
|
||||
|
||||
req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts)
|
||||
|
||||
case HTTP.get(__MODULE__, req_opts) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %Req.Response{status: 401}} ->
|
||||
{:error, :invalid_api_key}
|
||||
|
||||
{:ok, %Req.Response{status: 429}} ->
|
||||
{:error, :rate_limited}
|
||||
|
||||
{:ok, %Req.Response{status: status, body: body}} ->
|
||||
Logger.warning("OWM API error", status: status, body: inspect(body))
|
||||
{:error, {:api_error, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("OWM API request failed", error: inspect(reason))
|
||||
{:error, reason}
|
||||
end
|
||||
__MODULE__
|
||||
|> HTTP.get(req_opts)
|
||||
|> handle_response()
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_api_key(nil), do: validate_api_key(api_key())
|
||||
defp resolve_api_key(""), do: {:error, :no_api_key}
|
||||
defp resolve_api_key(key) when is_binary(key), do: {:ok, key}
|
||||
|
||||
defp validate_api_key(nil), do: {:error, :no_api_key}
|
||||
defp validate_api_key(""), do: {:error, :no_api_key}
|
||||
defp validate_api_key(key) when is_binary(key), do: {:ok, key}
|
||||
|
||||
defp handle_response({:ok, %Req.Response{status: 200, body: body}}), do: {:ok, body}
|
||||
defp handle_response({:ok, %Req.Response{status: 401}}), do: {:error, :invalid_api_key}
|
||||
defp handle_response({:ok, %Req.Response{status: 429}}), do: {:error, :rate_limited}
|
||||
|
||||
defp handle_response({:ok, %Req.Response{status: status, body: body}}) do
|
||||
Logger.warning("OWM API error", status: status, body: inspect(body))
|
||||
{:error, {:api_error, status}}
|
||||
end
|
||||
|
||||
defp handle_response({:error, reason}) do
|
||||
Logger.warning("OWM API request failed", error: inspect(reason))
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Tests if the API key is valid by making a minimal request.
|
||||
"""
|
||||
|
|
@ -61,10 +66,9 @@ defmodule Towerops.Weather.Client do
|
|||
end
|
||||
|
||||
defp maybe_add_test_plug(req_opts, opts) do
|
||||
if opts[:plug] do
|
||||
Keyword.put(req_opts, :plug, opts[:plug])
|
||||
else
|
||||
req_opts
|
||||
case Keyword.fetch(opts, :plug) do
|
||||
{:ok, plug} -> Keyword.put(req_opts, :plug, plug)
|
||||
:error -> req_opts
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -183,15 +183,22 @@ defmodule Towerops.Workers.CheckWorker do
|
|||
:ok
|
||||
end
|
||||
|
||||
defp state_changed_to_problem?(old_state, check) do
|
||||
# State changed from OK to CRITICAL/WARNING and is now hard state
|
||||
old_state == 0 && check.current_state in [1, 2] && check.current_state_type == "hard"
|
||||
end
|
||||
@doc """
|
||||
True when a check transitioned from OK (0) to a hard non-OK state (1 or 2).
|
||||
|
||||
defp state_changed_to_ok?(old_state, check) do
|
||||
# State changed from CRITICAL/WARNING to OK
|
||||
old_state in [1, 2] && check.current_state == 0
|
||||
end
|
||||
Requires `current_state_type == "hard"` to avoid alerting on soft-state flaps.
|
||||
"""
|
||||
@spec state_changed_to_problem?(integer(), map()) :: boolean()
|
||||
def state_changed_to_problem?(0, %{current_state: s, current_state_type: "hard"}) when s in [1, 2], do: true
|
||||
|
||||
def state_changed_to_problem?(_old, _check), do: false
|
||||
|
||||
@doc """
|
||||
True when a check transitioned from a non-OK state (1 or 2) back to OK (0).
|
||||
"""
|
||||
@spec state_changed_to_ok?(integer(), map()) :: boolean()
|
||||
def state_changed_to_ok?(old, %{current_state: 0}) when old in [1, 2], do: true
|
||||
def state_changed_to_ok?(_old, _check), do: false
|
||||
|
||||
defp handle_check_problem(check, output) do
|
||||
Logger.warning("Check #{check.name} entered problem state",
|
||||
|
|
|
|||
|
|
@ -1416,11 +1416,12 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
defp determine_speed_change_severity(false, old_speed, new_speed) when new_speed < old_speed, do: "warning"
|
||||
defp determine_speed_change_severity(false, _old_speed, _new_speed), do: "info"
|
||||
|
||||
defp format_speed_change_message(if_name, true, _old_speed, new_speed) do
|
||||
@doc false
|
||||
def format_speed_change_message(if_name, true, _old_speed, new_speed) do
|
||||
"Interface #{if_name} speed detected: #{format_speed(new_speed)}"
|
||||
end
|
||||
|
||||
defp format_speed_change_message(if_name, false, old_speed, new_speed) do
|
||||
def format_speed_change_message(if_name, false, old_speed, new_speed) do
|
||||
"Interface #{if_name} speed changed from #{format_speed(old_speed)} to #{format_speed(new_speed)}"
|
||||
end
|
||||
|
||||
|
|
@ -1454,11 +1455,12 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
}
|
||||
end
|
||||
|
||||
defp format_mac_change_message(if_name, true, _old_mac, new_mac) do
|
||||
@doc false
|
||||
def format_mac_change_message(if_name, true, _old_mac, new_mac) do
|
||||
"Interface #{if_name} MAC address detected: #{new_mac}"
|
||||
end
|
||||
|
||||
defp format_mac_change_message(if_name, false, old_mac, new_mac) do
|
||||
def format_mac_change_message(if_name, false, old_mac, new_mac) do
|
||||
"Interface #{if_name} MAC address changed from #{old_mac} to #{new_mac}"
|
||||
end
|
||||
|
||||
|
|
@ -1494,43 +1496,41 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
end)
|
||||
end
|
||||
|
||||
defp parse_interface_integer(value) when is_integer(value), do: value
|
||||
defp parse_interface_integer(_), do: nil
|
||||
@doc false
|
||||
def parse_interface_integer(value) when is_integer(value), do: value
|
||||
def parse_interface_integer(_), do: nil
|
||||
|
||||
defp parse_if_status(1), do: "up"
|
||||
defp parse_if_status(2), do: "down"
|
||||
defp parse_if_status(3), do: "testing"
|
||||
defp parse_if_status(_), do: "unknown"
|
||||
@doc false
|
||||
def parse_if_status(1), do: "up"
|
||||
def parse_if_status(2), do: "down"
|
||||
def parse_if_status(3), do: "testing"
|
||||
def parse_if_status(_), do: "unknown"
|
||||
|
||||
defp format_mac_address(<<>>) when is_binary(<<>>), do: nil
|
||||
defp format_mac_address(nil), do: nil
|
||||
@doc false
|
||||
def format_mac_address(<<>>) when is_binary(<<>>), do: nil
|
||||
def format_mac_address(nil), do: nil
|
||||
|
||||
defp format_mac_address(mac) when is_binary(mac) do
|
||||
def format_mac_address(mac) when is_binary(mac) do
|
||||
mac
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp format_mac_address(_), do: nil
|
||||
def format_mac_address(_), do: nil
|
||||
|
||||
defp format_speed(speed_bps) when is_integer(speed_bps) do
|
||||
cond do
|
||||
speed_bps >= 1_000_000_000 ->
|
||||
"#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
|
||||
@doc false
|
||||
def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000_000_000,
|
||||
do: "#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
|
||||
|
||||
speed_bps >= 1_000_000 ->
|
||||
"#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
|
||||
def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000_000,
|
||||
do: "#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
|
||||
|
||||
speed_bps >= 1_000 ->
|
||||
"#{Float.round(speed_bps / 1_000, 1)} Kbps"
|
||||
def format_speed(speed_bps) when is_integer(speed_bps) and speed_bps >= 1_000,
|
||||
do: "#{Float.round(speed_bps / 1_000, 1)} Kbps"
|
||||
|
||||
true ->
|
||||
"#{speed_bps} bps"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_speed(_), do: "Unknown"
|
||||
def format_speed(speed_bps) when is_integer(speed_bps), do: "#{speed_bps} bps"
|
||||
def format_speed(_), do: "Unknown"
|
||||
|
||||
defp get_interface_stats(client_opts, if_index) do
|
||||
hc_oids = [
|
||||
|
|
@ -1604,11 +1604,12 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp decode_snmp_value(value) when is_number(value) do
|
||||
@doc false
|
||||
def decode_snmp_value(value) when is_number(value) do
|
||||
value
|
||||
end
|
||||
|
||||
defp decode_snmp_value(value) when is_binary(value) do
|
||||
def decode_snmp_value(value) when is_binary(value) do
|
||||
size = byte_size(value)
|
||||
|
||||
result =
|
||||
|
|
@ -1642,7 +1643,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
nil
|
||||
end
|
||||
|
||||
defp decode_snmp_value(other) do
|
||||
def decode_snmp_value(other) do
|
||||
Logger.warning("Unexpected SNMP value type: #{inspect(other)}")
|
||||
nil
|
||||
end
|
||||
|
|
@ -1886,7 +1887,8 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
}
|
||||
end
|
||||
|
||||
defp get_poll_interval(device) do
|
||||
@doc false
|
||||
def get_poll_interval(device) do
|
||||
min_interval = Application.get_env(:towerops, :snmp_min_poll_interval, 300)
|
||||
max(device.check_interval_seconds || @default_poll_interval, min_interval)
|
||||
end
|
||||
|
|
@ -1902,7 +1904,8 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do
|
||||
@doc false
|
||||
def should_skip_poll?(device, poll_interval_seconds, grace_period_seconds) do
|
||||
case device.last_snmp_poll_at do
|
||||
nil ->
|
||||
false
|
||||
|
|
|
|||
|
|
@ -48,7 +48,8 @@ defmodule Towerops.Workers.NetBoxSyncWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
@doc false
|
||||
def should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ defmodule Towerops.Workers.SonarSyncWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
@doc false
|
||||
def should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ defmodule Towerops.Workers.SplynxSyncWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
@doc false
|
||||
def should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
|
|
|||
|
|
@ -73,7 +73,8 @@ defmodule Towerops.Workers.UispSyncWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
@doc false
|
||||
def should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@ defmodule Towerops.Workers.VispSyncWorker do
|
|||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
@doc false
|
||||
def should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
|
|
|||
|
|
@ -295,30 +295,28 @@ defmodule ToweropsWeb.Api.MobileController do
|
|||
}
|
||||
end
|
||||
|
||||
defp format_alert_status(alert) do
|
||||
cond do
|
||||
alert.resolved_at != nil -> "resolved"
|
||||
alert.acknowledged_at != nil -> "acknowledged"
|
||||
true -> "active"
|
||||
end
|
||||
@doc false
|
||||
def format_alert_status(%{resolved_at: t}) when not is_nil(t), do: "resolved"
|
||||
def format_alert_status(%{acknowledged_at: t}) when not is_nil(t), do: "acknowledged"
|
||||
def format_alert_status(_), do: "active"
|
||||
|
||||
@doc false
|
||||
def timeticks_to_string(timeticks) do
|
||||
seconds = div(timeticks, 100)
|
||||
days = div(seconds, 86_400)
|
||||
hours = div(rem(seconds, 86_400), 3600)
|
||||
minutes = div(rem(seconds, 3600), 60)
|
||||
|
||||
format_uptime_components(days, hours, minutes)
|
||||
end
|
||||
|
||||
defp format_uptime_components(days, hours, _min) when days > 0, do: "#{days}d #{hours}h"
|
||||
defp format_uptime_components(_days, hours, minutes) when hours > 0, do: "#{hours}h #{minutes}m"
|
||||
defp format_uptime_components(_, _, minutes), do: "#{minutes}m"
|
||||
|
||||
defp format_uptime(device) do
|
||||
if device.snmp_device && device.snmp_device.sys_uptime do
|
||||
timeticks_to_string(device.snmp_device.sys_uptime)
|
||||
end
|
||||
end
|
||||
|
||||
defp timeticks_to_string(timeticks) do
|
||||
seconds = div(timeticks, 100)
|
||||
days = div(seconds, 86_400)
|
||||
hours = div(rem(seconds, 86_400), 3600)
|
||||
minutes = div(rem(seconds, 3600), 60)
|
||||
|
||||
cond do
|
||||
days > 0 -> "#{days}d #{hours}h"
|
||||
hours > 0 -> "#{hours}h #{minutes}m"
|
||||
true -> "#{minutes}m"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -87,7 +87,8 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
|
|||
end
|
||||
end
|
||||
|
||||
defp parse_signature_header(header) do
|
||||
@doc false
|
||||
def parse_signature_header(header) do
|
||||
parts = String.split(header, ",")
|
||||
|
||||
with {:ok, timestamp} <- extract_part(parts, "t="),
|
||||
|
|
@ -112,20 +113,20 @@ defmodule ToweropsWeb.Api.V1.AgentReleaseWebhookController do
|
|||
end
|
||||
end
|
||||
|
||||
defp check_timestamp(timestamp_str) do
|
||||
@doc false
|
||||
def check_timestamp(timestamp_str) do
|
||||
case Integer.parse(timestamp_str) do
|
||||
{timestamp, ""} ->
|
||||
now = System.system_time(:second)
|
||||
age = now - timestamp
|
||||
|
||||
cond do
|
||||
age < 0 -> {:error, :timestamp_in_future}
|
||||
age > @max_age_seconds -> {:error, :timestamp_expired}
|
||||
true -> :ok
|
||||
end
|
||||
classify_age(age)
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_timestamp}
|
||||
end
|
||||
end
|
||||
|
||||
defp classify_age(age) when age < 0, do: {:error, :timestamp_in_future}
|
||||
defp classify_age(age) when age > @max_age_seconds, do: {:error, :timestamp_expired}
|
||||
defp classify_age(_), do: :ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -172,41 +172,46 @@ defmodule ToweropsWeb.ActivityFeedLive do
|
|||
defp filter_active_class(:device_added), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
defp filter_active_class(_), do: "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
|
||||
|
||||
defp severity_dot_color(:critical, _), do: "bg-red-500"
|
||||
defp severity_dot_color(:warning, _), do: "bg-yellow-500"
|
||||
defp severity_dot_color(:info, :alert_resolved), do: "bg-green-500"
|
||||
defp severity_dot_color(:info, :sync), do: "bg-blue-500"
|
||||
defp severity_dot_color(:info, :device_added), do: "bg-cyan-500"
|
||||
defp severity_dot_color(:info, _), do: "bg-gray-400"
|
||||
defp severity_dot_color(_, _), do: "bg-gray-400"
|
||||
@doc false
|
||||
def severity_dot_color(:critical, _), do: "bg-red-500"
|
||||
def severity_dot_color(:warning, _), do: "bg-yellow-500"
|
||||
def severity_dot_color(:info, :alert_resolved), do: "bg-green-500"
|
||||
def severity_dot_color(:info, :sync), do: "bg-blue-500"
|
||||
def severity_dot_color(:info, :device_added), do: "bg-cyan-500"
|
||||
def severity_dot_color(:info, _), do: "bg-gray-400"
|
||||
def severity_dot_color(_, _), do: "bg-gray-400"
|
||||
|
||||
defp severity_icon_color(:critical, _), do: "text-red-500 dark:text-red-400"
|
||||
defp severity_icon_color(:warning, _), do: "text-yellow-500 dark:text-yellow-400"
|
||||
defp severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400"
|
||||
defp severity_icon_color(:info, :sync), do: "text-blue-500 dark:text-blue-400"
|
||||
defp severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400"
|
||||
defp severity_icon_color(_, _), do: "text-gray-400 dark:text-gray-500"
|
||||
@doc false
|
||||
def severity_icon_color(:critical, _), do: "text-red-500 dark:text-red-400"
|
||||
def severity_icon_color(:warning, _), do: "text-yellow-500 dark:text-yellow-400"
|
||||
def severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400"
|
||||
def severity_icon_color(:info, :sync), do: "text-blue-500 dark:text-blue-400"
|
||||
def severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400"
|
||||
def severity_icon_color(_, _), do: "text-gray-400 dark:text-gray-500"
|
||||
|
||||
defp severity_text_color(:critical, _), do: "text-red-700 dark:text-red-400"
|
||||
defp severity_text_color(:warning, _), do: "text-yellow-700 dark:text-yellow-400"
|
||||
defp severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400"
|
||||
defp severity_text_color(_, _), do: "text-gray-900 dark:text-white"
|
||||
@doc false
|
||||
def severity_text_color(:critical, _), do: "text-red-700 dark:text-red-400"
|
||||
def severity_text_color(:warning, _), do: "text-yellow-700 dark:text-yellow-400"
|
||||
def severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400"
|
||||
def severity_text_color(_, _), do: "text-gray-900 dark:text-white"
|
||||
|
||||
defp type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
defp type_badge_class(:alert_fired), do: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
defp type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
defp type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
defp type_badge_class(:sync), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
defp type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
defp type_badge_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
@doc false
|
||||
def type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
def type_badge_class(:alert_fired), do: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
def type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
def type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
def type_badge_class(:sync), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
def type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
def type_badge_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
|
||||
defp type_label(:config_change), do: "Config"
|
||||
defp type_label(:alert_fired), do: "Alert"
|
||||
defp type_label(:alert_resolved), do: "Resolved"
|
||||
defp type_label(:device_event), do: "Event"
|
||||
defp type_label(:sync), do: "Sync"
|
||||
defp type_label(:device_added), do: "Device"
|
||||
defp type_label(_), do: "Other"
|
||||
@doc false
|
||||
def type_label(:config_change), do: "Config"
|
||||
def type_label(:alert_fired), do: "Alert"
|
||||
def type_label(:alert_resolved), do: "Resolved"
|
||||
def type_label(:device_event), do: "Event"
|
||||
def type_label(:sync), do: "Sync"
|
||||
def type_label(:device_added), do: "Device"
|
||||
def type_label(_), do: "Other"
|
||||
|
||||
defp load_activity(socket) do
|
||||
org_id = socket.assigns.current_scope.organization.id
|
||||
|
|
|
|||
|
|
@ -216,42 +216,39 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
|> assign(:counts, counts)
|
||||
end
|
||||
|
||||
defp filter_alerts(alerts, "all"), do: alerts
|
||||
@doc false
|
||||
def filter_alerts(alerts, "all"), do: alerts
|
||||
|
||||
defp filter_alerts(alerts, "critical"),
|
||||
def filter_alerts(alerts, "critical"),
|
||||
do: Enum.filter(alerts, &(&1.alert_type == "device_down" and is_nil(&1.resolved_at)))
|
||||
|
||||
defp filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at))
|
||||
def filter_alerts(alerts, "unresolved"), do: Enum.filter(alerts, &is_nil(&1.resolved_at))
|
||||
|
||||
defp filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at)))
|
||||
def filter_alerts(alerts, "resolved"), do: Enum.filter(alerts, &(not is_nil(&1.resolved_at)))
|
||||
|
||||
defp filter_alerts(alerts, _), do: alerts
|
||||
def filter_alerts(alerts, _), do: alerts
|
||||
|
||||
defp sort_alerts(alerts, "age", _site_subs) do
|
||||
@doc false
|
||||
def sort_alerts(alerts, "age", _site_subs) do
|
||||
Enum.sort_by(alerts, & &1.triggered_at, {:asc, DateTime})
|
||||
end
|
||||
|
||||
defp sort_alerts(alerts, "impact", site_subs) do
|
||||
def sort_alerts(alerts, "impact", site_subs) do
|
||||
Enum.sort_by(alerts, fn alert ->
|
||||
sub_count = get_subscriber_count(alert, site_subs)
|
||||
-sub_count
|
||||
end)
|
||||
end
|
||||
|
||||
defp sort_alerts(alerts, _severity, _site_subs) do
|
||||
# Default: severity sort — device_down unresolved first, then by age
|
||||
Enum.sort_by(alerts, fn alert ->
|
||||
severity_weight =
|
||||
cond do
|
||||
alert.alert_type == "device_down" and is_nil(alert.resolved_at) -> 0
|
||||
alert.alert_type == "device_down" -> 1
|
||||
true -> 2
|
||||
end
|
||||
|
||||
{severity_weight, alert.triggered_at}
|
||||
end)
|
||||
def sort_alerts(alerts, _severity, _site_subs) do
|
||||
Enum.sort_by(alerts, &{severity_weight(&1), &1.triggered_at})
|
||||
end
|
||||
|
||||
@doc false
|
||||
def severity_weight(%{alert_type: "device_down", resolved_at: nil}), do: 0
|
||||
def severity_weight(%{alert_type: "device_down"}), do: 1
|
||||
def severity_weight(_alert), do: 2
|
||||
|
||||
defp group_by_site(alerts, site_subscribers) do
|
||||
alerts
|
||||
|> Enum.group_by(fn alert ->
|
||||
|
|
@ -278,11 +275,12 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
|> Enum.sort_by(fn group -> {-group.critical_count, -group.total_subscribers, -group.total_count} end)
|
||||
end
|
||||
|
||||
defp get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count
|
||||
defp get_site_subscriber_count(_), do: 0
|
||||
@doc false
|
||||
def get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count
|
||||
def get_site_subscriber_count(_), do: 0
|
||||
|
||||
defp get_subscriber_count(alert, site_subs) do
|
||||
# Use gaiia_impact on alert first, fall back to site-level data
|
||||
@doc false
|
||||
def get_subscriber_count(alert, site_subs) do
|
||||
cond do
|
||||
alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] ->
|
||||
alert.gaiia_impact["total_subscribers"]
|
||||
|
|
@ -306,38 +304,36 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
end
|
||||
|
||||
@doc false
|
||||
def severity_color(alert) do
|
||||
cond do
|
||||
alert.resolved_at ->
|
||||
"gray"
|
||||
def severity_color(%{resolved_at: ra}) when not is_nil(ra), do: "gray"
|
||||
|
||||
alert.alert_type == "device_down" and is_nil(alert.resolved_at) ->
|
||||
age_minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute)
|
||||
|
||||
cond do
|
||||
age_minutes > 60 -> "red"
|
||||
age_minutes > 15 -> "orange"
|
||||
true -> "yellow"
|
||||
end
|
||||
|
||||
true ->
|
||||
"green"
|
||||
end
|
||||
def severity_color(%{alert_type: "device_down", triggered_at: triggered_at}) do
|
||||
DateTime.utc_now()
|
||||
|> DateTime.diff(triggered_at, :minute)
|
||||
|> age_severity_color()
|
||||
end
|
||||
|
||||
def severity_color(_alert), do: "green"
|
||||
|
||||
@doc false
|
||||
def age_severity_color(age_minutes) when age_minutes > 60, do: "red"
|
||||
def age_severity_color(age_minutes) when age_minutes > 15, do: "orange"
|
||||
def age_severity_color(_age_minutes), do: "yellow"
|
||||
|
||||
@doc false
|
||||
def age_text(alert) do
|
||||
minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute)
|
||||
|
||||
cond do
|
||||
minutes < 1 -> "just now"
|
||||
minutes < 60 -> "#{minutes}m ago"
|
||||
minutes < 1440 -> "#{div(minutes, 60)}h ago"
|
||||
true -> "#{div(minutes, 1440)}d ago"
|
||||
end
|
||||
DateTime.utc_now()
|
||||
|> DateTime.diff(alert.triggered_at, :minute)
|
||||
|> format_age_minutes()
|
||||
end
|
||||
|
||||
defp format_number(number) when is_integer(number) do
|
||||
@doc false
|
||||
def format_age_minutes(minutes) when minutes < 1, do: "just now"
|
||||
def format_age_minutes(minutes) when minutes < 60, do: "#{minutes}m ago"
|
||||
def format_age_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h ago"
|
||||
def format_age_minutes(minutes), do: "#{div(minutes, 1440)}d ago"
|
||||
|
||||
@doc false
|
||||
def format_number(number) when is_integer(number) do
|
||||
number
|
||||
|> Integer.to_string()
|
||||
|> String.graphemes()
|
||||
|
|
@ -347,21 +343,25 @@ defmodule ToweropsWeb.AlertLive.Index do
|
|||
|> String.reverse()
|
||||
end
|
||||
|
||||
defp format_number(number), do: to_string(number)
|
||||
def format_number(number), do: to_string(number)
|
||||
|
||||
@doc false
|
||||
def duration_text(alert) do
|
||||
end_time = alert.resolved_at || DateTime.utc_now()
|
||||
minutes = DateTime.diff(end_time, alert.triggered_at, :minute)
|
||||
|
||||
cond do
|
||||
minutes < 1 -> "<1m"
|
||||
minutes < 60 -> "#{minutes}m"
|
||||
minutes < 1440 -> "#{div(minutes, 60)}h #{rem(minutes, 60)}m"
|
||||
true -> "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h"
|
||||
end
|
||||
end_time
|
||||
|> DateTime.diff(alert.triggered_at, :minute)
|
||||
|> format_duration_minutes()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def format_duration_minutes(minutes) when minutes < 1, do: "<1m"
|
||||
def format_duration_minutes(minutes) when minutes < 60, do: "#{minutes}m"
|
||||
|
||||
def format_duration_minutes(minutes) when minutes < 1440, do: "#{div(minutes, 60)}h #{rem(minutes, 60)}m"
|
||||
|
||||
def format_duration_minutes(minutes), do: "#{div(minutes, 1440)}d #{div(rem(minutes, 1440), 60)}h"
|
||||
|
||||
defp severity_badge_class(alert) do
|
||||
color = severity_color(alert)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
defmodule ToweropsWeb.CapacityLive do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Capacity
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
summaries = Capacity.get_organization_capacity_summary(organization.id)
|
||||
|
||||
total_capacity = Enum.reduce(summaries, 0, fn s, acc -> acc + s.total_capacity_bps end)
|
||||
total_throughput = Enum.reduce(summaries, 0.0, fn s, acc -> acc + s.total_throughput_bps end)
|
||||
|
||||
overall_utilization =
|
||||
if total_capacity > 0, do: Float.round(total_throughput / total_capacity * 100, 1), else: 0.0
|
||||
|
||||
sites_at_risk = Enum.count(summaries, fn s -> s.utilization_pct >= 75 end)
|
||||
sites_with_headroom = Enum.count(summaries, fn s -> s.utilization_pct < 70 and s.total_capacity_bps > 0 end)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Capacity")
|
||||
|> assign(:summaries, summaries)
|
||||
|> assign(:total_capacity, total_capacity)
|
||||
|> assign(:total_throughput, total_throughput)
|
||||
|> assign(:overall_utilization, overall_utilization)
|
||||
|> assign(:sites_at_risk, sites_at_risk)
|
||||
|> assign(:sites_with_headroom, sites_with_headroom)}
|
||||
end
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000,
|
||||
do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps), do: "#{bps} bps"
|
||||
defp format_capacity(_), do: "-"
|
||||
|
||||
defp utilization_color(pct) when pct >= 90, do: "bg-red-500"
|
||||
defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500"
|
||||
defp utilization_color(_pct), do: "bg-green-500"
|
||||
|
||||
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
|
||||
defp status_badge(pct) when pct >= 90, do: {"Critical", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
|
||||
|
||||
defp status_badge(pct) when pct >= 70,
|
||||
do: {"Warning", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"}
|
||||
|
||||
defp status_badge(_pct), do: {"Healthy", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
|
||||
end
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="capacity"
|
||||
>
|
||||
<.breadcrumb items={[
|
||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||
%{label: "Capacity"}
|
||||
]} />
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("Network Capacity")}</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{t("Backhaul capacity utilization across all sites")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("Total Capacity")}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{format_capacity(@total_capacity)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("Current Throughput")}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{format_capacity(@total_throughput)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("Sites at Risk")}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
|
||||
{@sites_at_risk}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("Sites with Headroom")}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{@sites_with_headroom}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Site Capacity Table -->
|
||||
<%= if @summaries != [] 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">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Site Capacity")}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Site")}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Capacity")}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Throughput")}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Utilization")}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Status")}</th>
|
||||
<th class="px-4 py-2 text-left font-medium">{t("Interfaces")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for summary <- Enum.sort_by(@summaries, & &1.utilization_pct, :desc) do %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td class="px-4 py-3">
|
||||
<.link
|
||||
navigate={~p"/sites/#{summary.site_id}"}
|
||||
class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
|
||||
>
|
||||
{summary.site_name}
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
|
||||
{format_capacity(summary.total_capacity_bps)}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
|
||||
{format_capacity(summary.total_throughput_bps)}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2 min-w-[120px]">
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class={[
|
||||
"h-full rounded-full transition-all",
|
||||
utilization_color(summary.utilization_pct)
|
||||
]}
|
||||
style={"width: #{min(summary.utilization_pct, 100)}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class={[
|
||||
"font-mono text-xs whitespace-nowrap",
|
||||
utilization_text_color(summary.utilization_pct)
|
||||
]}>
|
||||
{Float.round(summary.utilization_pct, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<% {label, classes} = status_badge(summary.utilization_pct) %>
|
||||
<span class={["px-2 py-0.5 rounded-full text-xs font-medium", classes]}>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{length(summary.interfaces)}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<.icon name="hero-signal" class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600" />
|
||||
<h3 class="mt-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("No capacity data")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Configure capacity on device interfaces to see utilization data here.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -411,7 +411,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
end
|
||||
end
|
||||
|
||||
defp config_to_form_fields(config, "http") do
|
||||
@doc false
|
||||
def config_to_form_fields(config, "http") do
|
||||
%{
|
||||
"url" => config["url"] || "",
|
||||
"method" => config["method"] || "GET",
|
||||
|
|
@ -422,7 +423,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_to_form_fields(config, "tcp") do
|
||||
def config_to_form_fields(config, "tcp") do
|
||||
%{
|
||||
"host" => config["host"] || "",
|
||||
"port" => to_string(config["port"] || ""),
|
||||
|
|
@ -431,7 +432,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_to_form_fields(config, "dns") do
|
||||
def config_to_form_fields(config, "dns") do
|
||||
%{
|
||||
"hostname" => config["hostname"] || "",
|
||||
"record_type" => config["record_type"] || "A",
|
||||
|
|
@ -440,7 +441,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_to_form_fields(config, "ssl") do
|
||||
def config_to_form_fields(config, "ssl") do
|
||||
%{
|
||||
"host" => config["host"] || "",
|
||||
"port" => to_string(config["port"] || 443),
|
||||
|
|
@ -448,13 +449,24 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_to_form_fields(_config, _type), do: %{}
|
||||
def config_to_form_fields(_config, _type), do: %{}
|
||||
|
||||
defp assign_default_config(socket, type) do
|
||||
assign(socket, :default_config, default_config_for_type(socket, type))
|
||||
end
|
||||
|
||||
defp default_config_for_type(_socket, "http") do
|
||||
defp default_config_for_type(_socket, "http"), do: default_config_by_type("http", "")
|
||||
|
||||
defp default_config_for_type(socket, "tcp"), do: default_config_by_type("tcp", device_host_from_socket(socket))
|
||||
|
||||
defp default_config_for_type(_socket, "dns"), do: default_config_by_type("dns", "")
|
||||
|
||||
defp default_config_for_type(socket, "ssl"), do: default_config_by_type("ssl", device_host_from_socket(socket))
|
||||
|
||||
defp default_config_for_type(_socket, _type), do: %{}
|
||||
|
||||
@doc false
|
||||
def default_config_by_type("http", _host) do
|
||||
%{
|
||||
"url" => "",
|
||||
"method" => "GET",
|
||||
|
|
@ -464,32 +476,31 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp default_config_for_type(socket, "tcp") do
|
||||
%{"host" => device_host(socket), "port" => ""}
|
||||
def default_config_by_type("tcp", host), do: %{"host" => host, "port" => ""}
|
||||
def default_config_by_type("dns", _host), do: %{"hostname" => "", "record_type" => "A"}
|
||||
|
||||
def default_config_by_type("ssl", host), do: %{"host" => host, "port" => "443", "warning_days" => "30"}
|
||||
|
||||
def default_config_by_type(_type, _host), do: %{}
|
||||
|
||||
@doc false
|
||||
def device_host(nil), do: ""
|
||||
def device_host(%{ip_address: nil}), do: ""
|
||||
def device_host(%{ip_address: ip}), do: to_string(ip)
|
||||
def device_host(_), do: ""
|
||||
|
||||
defp device_host_from_socket(socket) do
|
||||
device_host(socket.assigns[:device])
|
||||
end
|
||||
|
||||
defp default_config_for_type(_socket, "dns") do
|
||||
%{"hostname" => "", "record_type" => "A"}
|
||||
end
|
||||
|
||||
defp default_config_for_type(socket, "ssl") do
|
||||
%{"host" => device_host(socket), "port" => "443", "warning_days" => "30"}
|
||||
end
|
||||
|
||||
defp default_config_for_type(_socket, _type), do: %{}
|
||||
|
||||
defp device_host(socket) do
|
||||
device = socket.assigns[:device]
|
||||
if device && device.ip_address, do: to_string(device.ip_address), else: ""
|
||||
end
|
||||
|
||||
defp merge_config_params(check_params, type) do
|
||||
@doc false
|
||||
def merge_config_params(check_params, type) do
|
||||
check_params
|
||||
|> Map.put("config", config_from_params(check_params, type))
|
||||
|> Map.put("check_type", type)
|
||||
end
|
||||
|
||||
defp config_from_params(check_params, "http") do
|
||||
def config_from_params(check_params, "http") do
|
||||
maybe_add(
|
||||
%{
|
||||
"url" => check_params["url"],
|
||||
|
|
@ -503,7 +514,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
)
|
||||
end
|
||||
|
||||
defp config_from_params(check_params, "tcp") do
|
||||
def config_from_params(check_params, "tcp") do
|
||||
%{
|
||||
"host" => check_params["host"],
|
||||
"port" => String.to_integer(check_params["port"] || "0")
|
||||
|
|
@ -512,7 +523,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
|> maybe_add("expect", check_params["expect_string"])
|
||||
end
|
||||
|
||||
defp config_from_params(check_params, "dns") do
|
||||
def config_from_params(check_params, "dns") do
|
||||
%{
|
||||
"hostname" => check_params["hostname"],
|
||||
"record_type" => check_params["record_type"] || "A"
|
||||
|
|
@ -521,7 +532,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
|> maybe_add("expected", check_params["expected_result"])
|
||||
end
|
||||
|
||||
defp config_from_params(check_params, "ssl") do
|
||||
def config_from_params(check_params, "ssl") do
|
||||
%{
|
||||
"host" => check_params["host"],
|
||||
"port" => String.to_integer(check_params["port"] || "443"),
|
||||
|
|
@ -529,7 +540,7 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
|
|||
}
|
||||
end
|
||||
|
||||
defp config_from_params(_check_params, _type), do: %{}
|
||||
def config_from_params(_check_params, _type), do: %{}
|
||||
|
||||
defp maybe_add(map, _key, nil), do: map
|
||||
defp maybe_add(map, _key, ""), do: map
|
||||
|
|
|
|||
|
|
@ -256,7 +256,8 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
Monitoring.get_device_check_data(device_id, since)
|
||||
end
|
||||
|
||||
defp timeline_event(event) do
|
||||
@doc false
|
||||
def timeline_event(event) do
|
||||
%{
|
||||
id: event.id,
|
||||
t: DateTime.to_iso8601(event.changed_at),
|
||||
|
|
@ -265,7 +266,8 @@ defmodule ToweropsWeb.ConfigTimelineLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp change_size_class(size) when size > 50, do: "badge-error"
|
||||
defp change_size_class(size) when size > 20, do: "badge-warning"
|
||||
defp change_size_class(_), do: "badge-info"
|
||||
@doc false
|
||||
def change_size_class(size) when is_integer(size) and size > 50, do: "badge-error"
|
||||
def change_size_class(size) when is_integer(size) and size > 20, do: "badge-warning"
|
||||
def change_size_class(_), do: "badge-info"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,315 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.BackupsTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device MikroTik backups tab.
|
||||
|
||||
Displays MikroTik configuration backups with download, compare,
|
||||
and backup-now functionality.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
||||
alias ToweropsWeb.DeviceLive.Helpers.Formatters
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_backups_data()}
|
||||
end
|
||||
|
||||
defp load_backups_data(socket) do
|
||||
device = socket.assigns.device
|
||||
mikrotik_backups = DataLoaders.load_mikrotik_backups(device)
|
||||
|
||||
socket
|
||||
|> assign(:mikrotik_backups, mikrotik_backups)
|
||||
|> assign(:selected_backup_ids, MapSet.new())
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="backups-tab">
|
||||
<%= if false and @device.mikrotik_enabled 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">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Configuration Backups")}
|
||||
<%= if Enum.any?(@mikrotik_backups) do %>
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({length(@mikrotik_backups)})
|
||||
</span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-blue-700 bg-blue-100 rounded dark:bg-blue-900 dark:text-blue-200">
|
||||
<.icon name="hero-beaker" class="h-3 w-3" /> Experimental
|
||||
</span>
|
||||
</div>
|
||||
<%= if @agent_info.agent_token_id do %>
|
||||
<button
|
||||
phx-click="backup_now"
|
||||
phx-throttle="2000"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Backup Now
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("Assign an agent to enable backups")}
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if Enum.any?(@mikrotik_backups) do %>
|
||||
<%!-- Instructions for comparing backups --%>
|
||||
<%= if length(@mikrotik_backups) > 1 do %>
|
||||
<div class="px-4 py-3 bg-blue-50 dark:bg-blue-950/30 border-b border-blue-100 dark:border-blue-900/50">
|
||||
<div class="flex items-start gap-2">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
<div class="text-sm text-blue-900 dark:text-blue-200">
|
||||
<span class="font-medium">Compare configurations:</span>
|
||||
{t("Select any 2 backups using the checkboxes to compare their differences")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th
|
||||
class="px-4 py-3 text-center font-medium w-12"
|
||||
title="Select backups to compare"
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4 mx-auto" />
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Date</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Original Size</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Compressed Size</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Ratio</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Source</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for backup <- @mikrotik_backups do %>
|
||||
<% compression_ratio =
|
||||
if backup.config_size_bytes && backup.config_size_bytes > 0 do
|
||||
Float.round(
|
||||
(1 - backup.compressed_size_bytes / backup.config_size_bytes) * 100,
|
||||
1
|
||||
)
|
||||
else
|
||||
0.0
|
||||
end %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
phx-click="toggle_backup_selection"
|
||||
phx-value-id={backup.id}
|
||||
phx-target={@myself}
|
||||
checked={MapSet.member?(@selected_backup_ids, backup.id)}
|
||||
disabled={
|
||||
!MapSet.member?(@selected_backup_ids, backup.id) &&
|
||||
MapSet.size(@selected_backup_ids) >= 2
|
||||
}
|
||||
title={
|
||||
if MapSet.member?(@selected_backup_ids, backup.id),
|
||||
do: "Selected for comparison",
|
||||
else:
|
||||
if(MapSet.size(@selected_backup_ids) >= 2,
|
||||
do: "Maximum 2 backups can be selected",
|
||||
else: "Select to compare"
|
||||
)
|
||||
}
|
||||
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600 disabled:opacity-50 disabled:cursor-not-allowed dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(backup.backed_up_at, @timezone)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
||||
{format_bytes(backup.config_size_bytes)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono">
|
||||
{format_bytes(backup.compressed_size_bytes)}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{compression_ratio}%
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class={[
|
||||
"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
backup.trigger_source == "daily_cron" &&
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
backup.trigger_source == "manual" &&
|
||||
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
backup.trigger_source == "pre_change" &&
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
]}>
|
||||
{String.replace(backup.trigger_source, "_", " ") |> String.capitalize()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
phx-click="download_backup"
|
||||
phx-value-id={backup.id}
|
||||
class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="h-4 w-4" /> Download
|
||||
</button>
|
||||
<%= if ToweropsWeb.Permissions.owner?(@current_scope) do %>
|
||||
<.button
|
||||
phx-click="delete_backup"
|
||||
phx-value-id={backup.id}
|
||||
data-confirm={
|
||||
t(
|
||||
"Are you sure you want to delete this backup? This action cannot be undone."
|
||||
)
|
||||
}
|
||||
variant="danger"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" /> Delete
|
||||
</.button>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<%!-- Pagination --%>
|
||||
<%= if assigns[:pagination] do %>
|
||||
<div class="px-4 py-3 border-t border-gray-200 dark:border-white/10">
|
||||
<.pagination
|
||||
meta={@pagination}
|
||||
path={~p"/devices/#{@device.id}"}
|
||||
params={%{"tab" => "backups"}}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<%!-- Floating Compare Button --%>
|
||||
<%= if MapSet.size(@selected_backup_ids) > 0 do %>
|
||||
<div class="fixed bottom-8 right-8 z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<%!-- Selection status --%>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<.icon
|
||||
name="hero-check-circle"
|
||||
class="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<div class="text-gray-700 dark:text-gray-300">
|
||||
<span class="font-medium">{MapSet.size(@selected_backup_ids)}</span>
|
||||
of 2 backups selected
|
||||
</div>
|
||||
</div>
|
||||
<%!-- Instructions when only 1 selected --%>
|
||||
<%= if MapSet.size(@selected_backup_ids) == 1 do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 pl-7">
|
||||
{t("Select one more backup to compare")}
|
||||
</div>
|
||||
<% end %>
|
||||
<%!-- Action buttons --%>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<button
|
||||
phx-click="clear_selection"
|
||||
phx-target={@myself}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t("Clear")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="compare_selected"
|
||||
disabled={MapSet.size(@selected_backup_ids) != 2}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors shadow-sm"
|
||||
>
|
||||
<.icon name="hero-arrows-right-left" class="h-4 w-4" />
|
||||
{if MapSet.size(@selected_backup_ids) == 2,
|
||||
do: "Compare Selected",
|
||||
else: "Compare"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="p-8 text-center">
|
||||
<.icon name="hero-document-text" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("No backups yet")}
|
||||
</h3>
|
||||
<%= if @agent_info.agent_token_id do %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Automatic backups run daily at {ToweropsWeb.TimeHelpers.format_utc_hour(
|
||||
7,
|
||||
@timezone
|
||||
)}.
|
||||
</p>
|
||||
<% else %>
|
||||
<p class="mt-1 text-sm text-amber-600 dark:text-amber-400">
|
||||
{t("Assign an agent to enable automatic backups.")}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("MikroTik API not enabled")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("Enable MikroTik API in device settings to enable configuration backups.")}
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<.button navigate={~p"/devices/#{@device.id}/edit"}>
|
||||
{t("Edit Device Settings")}
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_backup_selection", %{"id" => backup_id}, socket) do
|
||||
selected = socket.assigns.selected_backup_ids
|
||||
|
||||
updated_selected =
|
||||
if MapSet.member?(selected, backup_id) do
|
||||
MapSet.delete(selected, backup_id)
|
||||
else
|
||||
# Limit to 2 selections max
|
||||
if MapSet.size(selected) >= 2 do
|
||||
selected
|
||||
else
|
||||
MapSet.put(selected, backup_id)
|
||||
end
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :selected_backup_ids, updated_selected)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("clear_selection", _params, socket) do
|
||||
{:noreply, assign(socket, :selected_backup_ids, MapSet.new())}
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
defp format_bytes(bytes), do: Formatters.format_bytes(bytes)
|
||||
end
|
||||
|
|
@ -1,292 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.ChecksTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device monitoring checks tab.
|
||||
|
||||
Displays all monitoring checks for the device grouped by type
|
||||
(SNMP, HTTP, TCP, DNS, SSL, ping).
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias Towerops.Monitoring
|
||||
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_checks_data()}
|
||||
end
|
||||
|
||||
defp load_checks_data(socket) do
|
||||
device_id = socket.assigns.device.id
|
||||
organization_id = socket.assigns.device.organization_id
|
||||
checks_data = DataLoaders.load_checks_data(device_id, organization_id)
|
||||
|
||||
socket
|
||||
|> assign(:checks, checks_data.checks)
|
||||
|> assign(:grouped_checks, checks_data.grouped_checks)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="checks-tab">
|
||||
<%= if Enum.empty?(@checks) do %>
|
||||
<!-- Empty State -->
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="px-4 py-12 text-center">
|
||||
<.icon name="hero-clipboard-document-check" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("No checks configured")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
"Run discovery to automatically detect sensors, interfaces, and other monitorable items, or add a service check manually."
|
||||
)}
|
||||
</p>
|
||||
<div class="mt-6 flex justify-center gap-3">
|
||||
<%= if @device.snmp_enabled do %>
|
||||
<.button type="button" phx-click="run_discovery">
|
||||
<.icon name="hero-magnifying-glass" class="h-4 w-4" /> Run Discovery
|
||||
</.button>
|
||||
<% end %>
|
||||
<.button type="button" phx-click="add_check">
|
||||
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Checks List -->
|
||||
<div class="space-y-6">
|
||||
<!-- Header with count and Add button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Checks ({length(@checks)})
|
||||
</h2>
|
||||
<.button type="button" phx-click="add_check">
|
||||
<.icon name="hero-plus" class="h-4 w-4" /> Add Check
|
||||
</.button>
|
||||
</div>
|
||||
<%= for {group_key, group_checks} <- @grouped_checks do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<!-- Group Header -->
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-gray-900/50">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{group_title(group_key)} ({length(group_checks)})
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Checks Table -->
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-900/30">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
||||
>
|
||||
{t("Name")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
||||
>
|
||||
{t("Status")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
||||
>
|
||||
{t("Value")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
|
||||
>
|
||||
{t("Last Checked")}
|
||||
</th>
|
||||
<th scope="col" class="relative px-4 py-3">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for check <- group_checks do %>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/30">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
|
||||
{check.name}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-left">
|
||||
{render_status_badge(check.current_state)}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= if check.last_check_at do %>
|
||||
{get_latest_value(check)}
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
Pending
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= if check.last_check_at do %>
|
||||
{format_relative_time(check.last_check_at)}
|
||||
<% else %>
|
||||
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
|
||||
Never
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/check?check_id=#{check.id}&range=24h"
|
||||
}
|
||||
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
{t("Graph")}
|
||||
</.link>
|
||||
<%= if check.check_type in ["http", "tcp", "dns", "ssl"] do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="edit_check"
|
||||
phx-value-id={check.id}
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
<.icon name="hero-pencil-square" class="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="delete_check"
|
||||
phx-value-id={check.id}
|
||||
data-confirm="Are you sure you want to delete this check?"
|
||||
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<.icon name="hero-trash" class="h-4 w-4" />
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
defp group_title(:snmp_sensors), do: "SNMP Sensors"
|
||||
defp group_title(:snmp_interfaces), do: "SNMP Interfaces"
|
||||
defp group_title(:snmp_processors), do: "SNMP Processors"
|
||||
defp group_title(:snmp_storage), do: "SNMP Storage"
|
||||
defp group_title(:http_checks), do: "HTTP Checks"
|
||||
defp group_title(:tcp_checks), do: "TCP Checks"
|
||||
defp group_title(:dns_checks), do: "DNS Checks"
|
||||
defp group_title(:ssl_checks), do: "SSL Certificate Checks"
|
||||
defp group_title(:ping_checks), do: "Ping Checks"
|
||||
defp group_title(:other_checks), do: "Other Checks"
|
||||
|
||||
defp render_status_badge(0) do
|
||||
assigns = %{}
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
|
||||
OK
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_status_badge(1) do
|
||||
assigns = %{}
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
WARNING
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_status_badge(2) do
|
||||
assigns = %{}
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
CRITICAL
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_status_badge(3) do
|
||||
assigns = %{}
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
||||
UNKNOWN
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_status_badge(_) do
|
||||
assigns = %{}
|
||||
|
||||
~H"""
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400">
|
||||
PENDING
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp get_latest_value(check) do
|
||||
# Get the latest check result to show current value
|
||||
case Monitoring.get_latest_check_result(check.id) do
|
||||
nil -> "-"
|
||||
result -> format_check_value(result, check)
|
||||
end
|
||||
end
|
||||
|
||||
defp format_check_value(%{value: nil}, _check), do: "-"
|
||||
|
||||
defp format_check_value(%{value: value}, check) when is_number(value) do
|
||||
case check.check_type do
|
||||
"snmp_sensor" -> format_check_sensor_value(value, check.config)
|
||||
"snmp_processor" -> "#{Float.round(value, 1)}%"
|
||||
"snmp_storage" -> "#{Float.round(value, 1)}%"
|
||||
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms"
|
||||
_ -> value |> Float.round(2) |> to_string()
|
||||
end
|
||||
end
|
||||
|
||||
defp format_check_value(_result, _check), do: "-"
|
||||
|
||||
@sensor_value_formats %{
|
||||
"temperature" => {1, "°C"},
|
||||
"voltage" => {2, "V"},
|
||||
"current" => {2, "A"},
|
||||
"power" => {1, "W"},
|
||||
"frequency" => {0, "Hz"},
|
||||
"humidity" => {1, "%"}
|
||||
}
|
||||
|
||||
defp format_check_sensor_value(value, %{"sensor_type" => "fanspeed", "sensor_unit" => unit}) do
|
||||
"#{round(value)}#{unit || " RPM"}"
|
||||
end
|
||||
|
||||
defp format_check_sensor_value(value, config) do
|
||||
sensor_type = config["sensor_type"]
|
||||
unit = config["sensor_unit"]
|
||||
{precision, default_unit} = Map.get(@sensor_value_formats, sensor_type, {2, ""})
|
||||
"#{Float.round(value, precision)}#{unit || default_unit}"
|
||||
end
|
||||
|
||||
defp format_relative_time(datetime) do
|
||||
ToweropsWeb.TimeHelpers.format_time_ago(datetime)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,331 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.GaiiaTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device Gaiia integration tab.
|
||||
|
||||
Displays Gaiia inventory item data, assigned account, subscriptions,
|
||||
and network site information.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_gaiia_data()}
|
||||
end
|
||||
|
||||
defp load_gaiia_data(socket) do
|
||||
device = socket.assigns.device
|
||||
gaiia_data = DataLoaders.load_gaiia_data(device)
|
||||
|
||||
socket
|
||||
|> assign(:gaiia_item, gaiia_data.gaiia_item)
|
||||
|> assign(:gaiia_account, gaiia_data.gaiia_account)
|
||||
|> assign(:gaiia_subscriptions, gaiia_data.gaiia_subscriptions)
|
||||
|> assign(:gaiia_network_site, gaiia_data.gaiia_network_site)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="gaiia-tab">
|
||||
<%= if @gaiia_item do %>
|
||||
<div class="space-y-6">
|
||||
<%!-- Inventory Item Details --%>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("Gaiia Inventory Item")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_item.name || "---"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Gaiia ID</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{@gaiia_item.gaiia_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="mt-1">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
case @gaiia_item.status do
|
||||
"active" ->
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
|
||||
"decommissioned" ->
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
||||
|
||||
_ ->
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
end
|
||||
]}>
|
||||
{@gaiia_item.status || "unknown"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @gaiia_item.model_name do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Model</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_item.model_name}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_item.manufacturer_name do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Manufacturer")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_item.manufacturer_name}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_item.category do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Category")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_item.category}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_item.serial_number do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Serial Number")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{@gaiia_item.serial_number}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_item.mac_address do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("MAC Address")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{@gaiia_item.mac_address}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_item.ip_address do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("IP Address (Gaiia)")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{@gaiia_item.ip_address}
|
||||
<%= if @device.ip_address && @gaiia_item.ip_address != @device.ip_address do %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<.icon name="hero-exclamation-triangle-mini" class="mr-0.5 h-3 w-3" />
|
||||
{t("Mismatch")}
|
||||
</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Network Site --%>
|
||||
<%= if @gaiia_network_site do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("Gaiia Network Site")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Site Name")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_network_site.name}
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @gaiia_network_site.account_count do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Subscribers")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_network_site.account_count}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @gaiia_network_site.total_mrr do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Monthly Revenue")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
${Decimal.round(@gaiia_network_site.total_mrr, 2)}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Linked Subscriber --%>
|
||||
<%= if @gaiia_account do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("Linked Subscriber")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-4">
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
{@gaiia_account.name}
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @gaiia_account.readable_id do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Account ID")}
|
||||
</dt>
|
||||
<dd class="mt-1 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{@gaiia_account.readable_id}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="mt-1">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
case @gaiia_account.status do
|
||||
"active" ->
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
|
||||
"suspended" ->
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
_ ->
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{@gaiia_account.status || "unknown"}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<%= if @gaiia_account.mrr do %>
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 dark:text-gray-400">MRR</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
|
||||
${Decimal.round(@gaiia_account.mrr, 2)}
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Billing Subscriptions --%>
|
||||
<%= if @gaiia_subscriptions != [] do %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 bg-gray-50 dark:bg-white/5">
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("Billing Subscriptions")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-white/5">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Plan")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("Status")}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{t("MRR")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
|
||||
<%= for sub <- @gaiia_subscriptions do %>
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-white/5">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{sub.product_name || "---"}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
case sub.status do
|
||||
"active" ->
|
||||
"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||
|
||||
"suspended" ->
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
_ ->
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400"
|
||||
end
|
||||
]}>
|
||||
{sub.status || "unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm text-gray-900 dark:text-white">
|
||||
<%= if sub.mrr_amount do %>
|
||||
${Decimal.round(sub.mrr_amount, 2)}
|
||||
<span class="text-gray-400">
|
||||
{sub.currency || ""}
|
||||
</span>
|
||||
<% else %>
|
||||
---
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No Gaiia inventory item linked to this device.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,331 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.PortsTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device ports (interfaces) tab.
|
||||
|
||||
Displays network interfaces grouped by type (Ethernet, VLAN, Wireless, etc.)
|
||||
with traffic statistics and utilization metrics.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias ToweropsWeb.DeviceLive.Helpers.Formatters
|
||||
|
||||
# SNMP interface type to category mappings
|
||||
@interface_type_categories %{
|
||||
6 => "Ethernet",
|
||||
24 => "Loopback",
|
||||
23 => "PPP",
|
||||
108 => "PPPoE",
|
||||
131 => "Tunnel",
|
||||
135 => "VLAN",
|
||||
136 => "VLAN",
|
||||
161 => "IEEE 802.11",
|
||||
244 => "WWP"
|
||||
}
|
||||
|
||||
# Display order for interface categories
|
||||
@interface_category_order %{
|
||||
"Ethernet" => 1,
|
||||
"VLAN" => 2,
|
||||
"IEEE 802.11" => 3,
|
||||
"PPPoE" => 4,
|
||||
"PPP" => 5,
|
||||
"Tunnel" => 6,
|
||||
"Loopback" => 7,
|
||||
"WWP" => 8,
|
||||
"Other" => 99
|
||||
}
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_ports_data()}
|
||||
end
|
||||
|
||||
defp load_ports_data(socket) do
|
||||
interfaces = socket.assigns.snmp_interfaces
|
||||
|
||||
interfaces_with_utilization =
|
||||
Enum.map(interfaces, fn interface ->
|
||||
utilization =
|
||||
if interface.configured_capacity_bps do
|
||||
Towerops.Capacity.get_utilization(interface)
|
||||
end
|
||||
|
||||
Map.put(interface, :utilization, utilization)
|
||||
end)
|
||||
|
||||
interfaces_by_type = group_interfaces_by_type(interfaces_with_utilization)
|
||||
|
||||
assign(socket, :interfaces_by_type, interfaces_by_type)
|
||||
end
|
||||
|
||||
# Group interfaces by type for organized display
|
||||
defp group_interfaces_by_type(interfaces) do
|
||||
interfaces
|
||||
|> Enum.group_by(&get_interface_type_category(&1.if_type))
|
||||
|> Enum.map(fn {category, interfaces} ->
|
||||
{category, Enum.sort_by(interfaces, & &1.if_index)}
|
||||
end)
|
||||
|> Enum.sort_by(fn {category, _} -> interface_type_order(category) end)
|
||||
end
|
||||
|
||||
# Map SNMP interface types to human-readable categories
|
||||
defp get_interface_type_category(if_type) when is_integer(if_type) do
|
||||
Map.get(@interface_type_categories, if_type, "Other")
|
||||
end
|
||||
|
||||
defp get_interface_type_category(_), do: "Other"
|
||||
|
||||
# Define display order for interface categories
|
||||
defp interface_type_order(category) do
|
||||
Map.get(@interface_category_order, category, 99)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="ports-tab">
|
||||
<%= if @snmp_interfaces && length(@snmp_interfaces) > 0 do %>
|
||||
<div class="space-y-6">
|
||||
<%= for {category, interfaces} <- @interfaces_by_type 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">
|
||||
{category} Interfaces
|
||||
<span class="ml-2 text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({length(interfaces)})
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-3 py-2 text-left font-medium">#</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Name</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th class="px-3 py-2 text-right font-medium">Speed</th>
|
||||
<th class="px-3 py-2 text-left font-medium">IP Address</th>
|
||||
<th class="px-3 py-2 text-left font-medium">MAC</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Capacity</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Utilization</th>
|
||||
<th class="px-3 py-2 text-right font-medium">In</th>
|
||||
<th class="px-3 py-2 text-right font-medium">Out</th>
|
||||
<th class="px-3 py-2 text-right font-medium">Errors</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for interface <- interfaces do %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td class="px-3 py-2 text-gray-500 dark:text-gray-400 font-mono text-xs">
|
||||
{interface.if_index}
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/traffic?interface_id=#{interface.id}"
|
||||
}
|
||||
class="hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
<div class="font-medium text-gray-900 dark:text-white hover:underline text-sm">
|
||||
{interface.if_name || interface.if_descr}
|
||||
</div>
|
||||
<%= if interface.if_alias do %>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
{interface.if_alias}
|
||||
</div>
|
||||
<% end %>
|
||||
</.link>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs">
|
||||
<span class={[
|
||||
"h-2 w-2 rounded-full flex-shrink-0",
|
||||
interface.if_oper_status == "up" && "bg-green-500",
|
||||
interface.if_oper_status == "down" && "bg-red-500",
|
||||
interface.if_oper_status not in ["up", "down"] && "bg-gray-400"
|
||||
]} />
|
||||
<span class={[
|
||||
"font-medium",
|
||||
interface.if_oper_status == "up" &&
|
||||
"text-green-700 dark:text-green-400",
|
||||
interface.if_oper_status == "down" &&
|
||||
"text-red-700 dark:text-red-400",
|
||||
interface.if_oper_status not in ["up", "down"] &&
|
||||
"text-gray-600 dark:text-gray-400"
|
||||
]}>
|
||||
{String.upcase(interface.if_oper_status || "unknown")}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right text-gray-900 dark:text-white text-xs font-mono">
|
||||
{format_speed(interface.if_speed)}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<% interface_ips =
|
||||
Map.get(@ip_addresses_by_interface, interface.id, []) %>
|
||||
<%= if Enum.empty?(interface_ips) do %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% else %>
|
||||
<div class="space-y-0.5">
|
||||
<%= for ip <- interface_ips do %>
|
||||
<div
|
||||
class="cursor-pointer group/iip"
|
||||
phx-click={JS.dispatch("phx:copy", detail: %{text: ip.ip_address})}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{ip.ip_address}
|
||||
<%= if ip.prefix_length do %>
|
||||
/{ip.prefix_length}
|
||||
<% end %>
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/iip:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<span
|
||||
:if={interface.if_phys_address}
|
||||
class="cursor-pointer group/mac"
|
||||
phx-click={
|
||||
JS.dispatch("phx:copy", detail: %{text: interface.if_phys_address})
|
||||
}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{interface.if_phys_address}
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</span>
|
||||
<span :if={!interface.if_phys_address} class="text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<%= if interface.configured_capacity_bps do %>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="font-mono text-gray-900 dark:text-white">
|
||||
{format_speed(interface.configured_capacity_bps)}
|
||||
</span>
|
||||
<span class={[
|
||||
"px-1 py-0.5 rounded text-[10px] font-medium",
|
||||
capacity_source_class(interface.capacity_source)
|
||||
]}>
|
||||
{capacity_source_label(interface.capacity_source)}
|
||||
</span>
|
||||
<button
|
||||
phx-click="clear_capacity"
|
||||
phx-value-interface_id={interface.id}
|
||||
class="text-gray-400 hover:text-red-500 transition-colors ml-0.5"
|
||||
title={t("Clear capacity")}
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click={
|
||||
JS.push("set_capacity",
|
||||
value: %{
|
||||
interface_id: interface.id,
|
||||
capacity_mbps:
|
||||
if(interface.if_speed,
|
||||
do: to_string(div(interface.if_speed, 1_000_000)),
|
||||
else: ""
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{t("Set")}
|
||||
</button>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<%= if interface.utilization do %>
|
||||
<div class="flex items-center gap-2 min-w-[100px]">
|
||||
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class={[
|
||||
"h-full rounded-full transition-all",
|
||||
utilization_color(interface.utilization.utilization_pct)
|
||||
]}
|
||||
style={"width: #{min(interface.utilization.utilization_pct, 100)}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class={[
|
||||
"font-mono whitespace-nowrap",
|
||||
utilization_text_color(interface.utilization.utilization_pct)
|
||||
]}>
|
||||
{Float.round(interface.utilization.utilization_pct, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{if interface.latest_stat && interface.latest_stat.if_in_octets,
|
||||
do: format_bytes(interface.latest_stat.if_in_octets),
|
||||
else: "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
{if interface.latest_stat && interface.latest_stat.if_out_octets,
|
||||
do: format_bytes(interface.latest_stat.if_out_octets),
|
||||
else: "-"}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs">
|
||||
<% total_errors =
|
||||
interface.latest_stat &&
|
||||
(interface.latest_stat.if_in_errors || 0) +
|
||||
(interface.latest_stat.if_out_errors || 0) +
|
||||
(interface.latest_stat.if_in_discards || 0) +
|
||||
(interface.latest_stat.if_out_discards || 0) %>
|
||||
<%= if total_errors && total_errors > 0 do %>
|
||||
<.link
|
||||
navigate={
|
||||
~p"/devices/#{@device.id}/graph/interface_errors?interface_id=#{interface.id}"
|
||||
}
|
||||
class="text-red-600 dark:text-red-400 hover:underline"
|
||||
>
|
||||
{total_errors}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<p class="text-gray-500 dark:text-gray-400">No interfaces discovered</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions - delegate to Formatters module
|
||||
defp format_speed(speed_bps), do: Formatters.format_speed(speed_bps)
|
||||
defp format_bytes(bytes), do: Formatters.format_bytes(bytes)
|
||||
defp capacity_source_label(source), do: Formatters.capacity_source_label(source)
|
||||
defp capacity_source_class(source), do: Formatters.capacity_source_class(source)
|
||||
defp utilization_color(pct), do: Formatters.utilization_color(pct)
|
||||
|
||||
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
end
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.PreseemTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device Preseem integration tab.
|
||||
|
||||
Displays Preseem access point data, subscriber metrics, and device insights.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_preseem_data()}
|
||||
end
|
||||
|
||||
defp load_preseem_data(socket) do
|
||||
device = socket.assigns.device
|
||||
preseem_data = DataLoaders.load_preseem_data(device)
|
||||
|
||||
socket
|
||||
|> assign(:preseem_access_point, preseem_data.preseem_access_point)
|
||||
|> assign(:preseem_metrics, preseem_data.preseem_metrics)
|
||||
|> assign(:preseem_insights, preseem_data.preseem_insights)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="preseem-tab">
|
||||
<%= if @preseem_access_point do %>
|
||||
<div class="space-y-6">
|
||||
<%!-- Insights section --%>
|
||||
<%= if @preseem_insights != [] do %>
|
||||
<div class="space-y-3">
|
||||
<%= for insight <- @preseem_insights do %>
|
||||
<div class={[
|
||||
"rounded-lg border p-4 flex items-start justify-between",
|
||||
case insight.urgency do
|
||||
"critical" ->
|
||||
"bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
||||
|
||||
"warning" ->
|
||||
"bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
|
||||
|
||||
_ ->
|
||||
"bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800"
|
||||
end
|
||||
]}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class={[
|
||||
"mt-0.5",
|
||||
case insight.urgency do
|
||||
"critical" -> "text-red-500"
|
||||
"warning" -> "text-yellow-500"
|
||||
_ -> "text-blue-500"
|
||||
end
|
||||
]}>
|
||||
<%= case insight.urgency do %>
|
||||
<% "critical" -> %>
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
||||
<% "warning" -> %>
|
||||
<.icon name="hero-exclamation-circle" class="h-5 w-5" />
|
||||
<% _ -> %>
|
||||
<.icon name="hero-information-circle" class="h-5 w-5" />
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{insight.title}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{insight.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
phx-click="dismiss_insight"
|
||||
phx-value-id={insight.id}
|
||||
phx-target={@myself}
|
||||
class="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 flex-shrink-0"
|
||||
title="Dismiss"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Score cards --%>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">QoE Score</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{if @preseem_access_point.qoe_score,
|
||||
do: Float.round(@preseem_access_point.qoe_score, 1),
|
||||
else: "---"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Capacity Score")}
|
||||
</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{if @preseem_access_point.capacity_score,
|
||||
do: Float.round(@preseem_access_point.capacity_score, 1),
|
||||
else: "---"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">RF Score</div>
|
||||
<div class="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{if @preseem_access_point.rf_score,
|
||||
do: Float.round(@preseem_access_point.rf_score, 1),
|
||||
else: "---"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Details card --%>
|
||||
<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">
|
||||
{t("Access Point Details")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
{@preseem_access_point.name || "---"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Preseem ID")}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
{@preseem_access_point.preseem_id}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Subscribers")}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
{@preseem_access_point.subscriber_count || "---"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Busy Hours")}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
<%= if @preseem_access_point.busy_hours do %>
|
||||
{@preseem_access_point.busy_hours} hrs/day
|
||||
<% else %>
|
||||
---
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Airtime Utilization")}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
<%= if @preseem_access_point.airtime_utilization do %>
|
||||
{Float.round(@preseem_access_point.airtime_utilization, 1)}%
|
||||
<% else %>
|
||||
---
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{t("Match Type")}
|
||||
</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900 dark:text-white">
|
||||
{@preseem_access_point.match_confidence}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Recent metrics table --%>
|
||||
<%= if @preseem_metrics != [] 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">
|
||||
{t("Recent QoE Metrics")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Time")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Latency (ms)")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("P95 Latency")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Jitter (ms)")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Loss (%)")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Throughput")}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">
|
||||
{t("Subscribers")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for metric <- @preseem_metrics do %>
|
||||
<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{ToweropsWeb.TimeHelpers.format_iso8601(
|
||||
metric.recorded_at,
|
||||
@current_scope.timezone,
|
||||
@current_scope.time_format
|
||||
)}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{if metric.avg_latency,
|
||||
do: Float.round(metric.avg_latency, 1),
|
||||
else: "---"}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{if metric.p95_latency,
|
||||
do: Float.round(metric.p95_latency, 1),
|
||||
else: "---"}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{if metric.avg_jitter,
|
||||
do: Float.round(metric.avg_jitter, 1),
|
||||
else: "---"}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{if metric.avg_throughput,
|
||||
do: "#{Float.round(metric.avg_throughput, 1)} Mbps",
|
||||
else: "---"}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-900 dark:text-white">
|
||||
{metric.subscriber_count || "---"}
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-8 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No Preseem data linked to this device.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do
|
||||
case Towerops.Preseem.dismiss_insight(insight_id) do
|
||||
{:ok, _} ->
|
||||
# Reload preseem data after dismissing insight
|
||||
{:noreply, load_preseem_data(socket)}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to dismiss insight")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
defmodule ToweropsWeb.DeviceLive.Components.WirelessTab do
|
||||
@moduledoc """
|
||||
LiveComponent for the device wireless clients tab.
|
||||
|
||||
Displays wireless clients connected to the device with optional
|
||||
subscriber matching from Gaiia integration.
|
||||
"""
|
||||
use ToweropsWeb, :live_component
|
||||
|
||||
alias ToweropsWeb.DeviceLive.Helpers.DataLoaders
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> load_wireless_data()}
|
||||
end
|
||||
|
||||
defp load_wireless_data(socket) do
|
||||
device_id = socket.assigns.device.id
|
||||
wireless_data = DataLoaders.load_wireless_data(device_id)
|
||||
|
||||
socket
|
||||
|> assign(:wireless_clients, wireless_data.wireless_clients)
|
||||
|> assign(:wireless_client_subscribers, wireless_data.wireless_client_subscribers)
|
||||
|> assign(:wireless_client_count, wireless_data.wireless_client_count)
|
||||
|> assign(:wireless_matched_count, wireless_data.wireless_matched_count)
|
||||
|> assign(:wireless_last_updated, wireless_data.wireless_last_updated)
|
||||
|> assign(:wireless_clients_available, wireless_data.wireless_clients_available)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id="wireless-tab">
|
||||
<%= if @wireless_clients && length(@wireless_clients) > 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 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{t("Connected Wireless Clients")}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{length(@wireless_clients)} clients — {@wireless_matched_count} subscribers matched
|
||||
<%= if @wireless_last_updated do %>
|
||||
· Last updated {format_relative_time(@wireless_last_updated)}
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800/75">
|
||||
<tr class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<th class="px-3 py-2 text-left font-medium">MAC Address</th>
|
||||
<th class="px-3 py-2 text-left font-medium">IP Address</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Subscriber</th>
|
||||
<th class="px-3 py-2 text-left font-medium">Signal</th>
|
||||
<th class="px-3 py-2 text-left font-medium">SNR</th>
|
||||
<th class="px-3 py-2 text-right font-medium">Distance</th>
|
||||
<th class="px-3 py-2 text-right font-medium">TX Rate</th>
|
||||
<th class="px-3 py-2 text-right font-medium">RX Rate</th>
|
||||
<th class="px-3 py-2 text-right font-medium">Uptime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for client <- @wireless_clients do %>
|
||||
<% subscriber_link =
|
||||
Map.get(@wireless_client_subscribers, String.downcase(client.mac_address)) %>
|
||||
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||
<td class="px-3 py-2 font-mono text-xs">
|
||||
<span
|
||||
class="cursor-pointer group/mac text-gray-900 dark:text-white"
|
||||
phx-click={JS.dispatch("phx:copy", detail: %{text: client.mac_address})}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{client.mac_address}
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/mac:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<%= if client.ip_address do %>
|
||||
<span
|
||||
class="cursor-pointer group/ip"
|
||||
phx-click={JS.dispatch("phx:copy", detail: %{text: client.ip_address})}
|
||||
title={t("Click to copy")}
|
||||
>
|
||||
{client.ip_address}
|
||||
<.icon
|
||||
name="hero-clipboard-document"
|
||||
class="h-3 w-3 inline ml-0.5 opacity-0 group-hover/ip:opacity-100 transition-opacity text-gray-400"
|
||||
/>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<%= if subscriber_link && subscriber_link.gaiia_account do %>
|
||||
<span class="text-gray-900 dark:text-white font-medium">
|
||||
{subscriber_link.gaiia_account.account_name}
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<%= if client.signal_strength do %>
|
||||
<.signal_badge value={client.signal_strength} />
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs">
|
||||
<%= if client.snr do %>
|
||||
<.snr_badge value={client.snr} />
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
||||
<%= if client.distance do %>
|
||||
{client.distance}m
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
||||
{format_rate(client.tx_rate)}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-900 dark:text-white">
|
||||
{format_rate(client.rx_rate)}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<%= if client.uptime_seconds do %>
|
||||
{format_uptime(client.uptime_seconds * 100)}
|
||||
<% else %>
|
||||
<span class="text-gray-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<.icon
|
||||
name="hero-signal"
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-600"
|
||||
/>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{t("No wireless clients")}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("No clients are currently connected to this access point.")}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
defp format_relative_time(datetime) do
|
||||
ToweropsWeb.TimeHelpers.format_time_ago(datetime)
|
||||
end
|
||||
|
||||
defp format_rate(nil), do: "—"
|
||||
|
||||
defp format_rate(kbps) when is_integer(kbps) do
|
||||
mbps = kbps / 1000
|
||||
"#{Float.round(mbps, 1)} Mbps"
|
||||
end
|
||||
|
||||
defp format_uptime(timeticks) when is_integer(timeticks) do
|
||||
seconds = div(timeticks, 100)
|
||||
hours = div(seconds, 3600)
|
||||
remainder = rem(seconds, 3600)
|
||||
minutes = div(remainder, 60)
|
||||
|
||||
cond do
|
||||
hours > 0 -> "#{hours}h #{minutes}m"
|
||||
minutes > 0 -> "#{minutes}m"
|
||||
true -> "<1m"
|
||||
end
|
||||
end
|
||||
|
||||
defp format_uptime(_), do: "—"
|
||||
end
|
||||
|
|
@ -617,13 +617,15 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
defp handle_agent_assignment(_device_id, _), do: :ok
|
||||
|
||||
# Sanitize device params - trim whitespace from IP address and handle device role source
|
||||
defp sanitize_device_params(params) do
|
||||
@doc false
|
||||
def sanitize_device_params(params) do
|
||||
params
|
||||
|> sanitize_ip_address()
|
||||
|> sanitize_device_role()
|
||||
end
|
||||
|
||||
defp sanitize_ip_address(params) do
|
||||
@doc false
|
||||
def sanitize_ip_address(params) do
|
||||
case Map.get(params, "ip_address") do
|
||||
ip when is_binary(ip) ->
|
||||
Map.put(params, "ip_address", String.trim(ip))
|
||||
|
|
@ -633,14 +635,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
# Device role sanitization (auto-inference disabled, all roles are manual)
|
||||
defp sanitize_device_role(params) do
|
||||
@doc false
|
||||
def sanitize_device_role(params) do
|
||||
case Map.get(params, "device_role") do
|
||||
role when is_binary(role) and role != "" ->
|
||||
params
|
||||
|
||||
_ ->
|
||||
# Empty string or nil - just pass through (schema default will apply)
|
||||
params
|
||||
end
|
||||
end
|
||||
|
|
@ -692,20 +693,23 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_device_agent(%{"agent_token_id" => id}) when is_binary(id) and id != "", do: id
|
||||
defp get_device_agent(_), do: nil
|
||||
@doc false
|
||||
def get_device_agent(%{"agent_token_id" => id}) when is_binary(id) and id != "", do: id
|
||||
def get_device_agent(_), do: nil
|
||||
|
||||
defp get_site_agent(%{"site_id" => site_id}, %{available_sites: sites}) when is_binary(site_id) do
|
||||
@doc false
|
||||
def get_site_agent(%{"site_id" => site_id}, %{available_sites: sites}) when is_binary(site_id) do
|
||||
case Enum.find(sites, &(&1.id == site_id)) do
|
||||
%{agent_token_id: id} when not is_nil(id) -> id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_site_agent(_, _), do: nil
|
||||
def get_site_agent(_, _), do: nil
|
||||
|
||||
defp get_org_agent(%{organization: %{default_agent_token_id: id}}) when not is_nil(id), do: id
|
||||
defp get_org_agent(_), do: nil
|
||||
@doc false
|
||||
def get_org_agent(%{organization: %{default_agent_token_id: id}}) when not is_nil(id), do: id
|
||||
def get_org_agent(_), do: nil
|
||||
|
||||
defp send_credential_test_to_agent(agent_token_id, device_map, test_id) do
|
||||
require Logger
|
||||
|
|
@ -742,11 +746,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
}
|
||||
end
|
||||
|
||||
defp get_value(map, key, default), do: map[key] || default
|
||||
@doc false
|
||||
def get_value(map, key, default), do: map[key] || default
|
||||
|
||||
defp normalize_port(port) when is_binary(port), do: String.to_integer(port)
|
||||
defp normalize_port(port) when is_integer(port), do: port
|
||||
defp normalize_port(_), do: 161
|
||||
@doc false
|
||||
def normalize_port(port) when is_binary(port), do: String.to_integer(port)
|
||||
def normalize_port(port) when is_integer(port), do: port
|
||||
def normalize_port(_), do: 161
|
||||
|
||||
# Check if a non-routable IP is being used with cloud poller
|
||||
# Skip this check in dev mode or for specific users
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
}
|
||||
end
|
||||
|
||||
defp processor_reading_to_data_point(reading) do
|
||||
@doc false
|
||||
def processor_reading_to_data_point(reading) do
|
||||
%{
|
||||
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
||||
y: if(reading.load_percent, do: Float.round(reading.load_percent, 1))
|
||||
|
|
@ -97,7 +98,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
}
|
||||
end
|
||||
|
||||
defp storage_reading_to_data_point(reading) do
|
||||
@doc false
|
||||
def storage_reading_to_data_point(reading) do
|
||||
%{
|
||||
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
||||
y: if(reading.usage_percent, do: Float.round(reading.usage_percent, 1))
|
||||
|
|
@ -144,7 +146,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
}
|
||||
end
|
||||
|
||||
defp reading_to_data_point(reading) do
|
||||
@doc false
|
||||
def reading_to_data_point(reading) do
|
||||
%{
|
||||
x: DateTime.to_unix(reading.checked_at, :millisecond),
|
||||
y: if(reading.value, do: Float.round(reading.value, 1))
|
||||
|
|
@ -177,7 +180,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
end
|
||||
end
|
||||
|
||||
defp latency_check_to_data_point(check) do
|
||||
@doc false
|
||||
def latency_check_to_data_point(check) do
|
||||
%{
|
||||
x: DateTime.to_unix(check.checked_at, :millisecond),
|
||||
y: check.response_time_ms
|
||||
|
|
@ -232,7 +236,8 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
end
|
||||
|
||||
# Calculate BPS for a single interface's stats
|
||||
defp calculate_interface_bps(stats) do
|
||||
@doc false
|
||||
def calculate_interface_bps(stats) do
|
||||
stats
|
||||
|> Enum.chunk_every(2, 1, :discard)
|
||||
|> Enum.map(fn [s1, s2] ->
|
||||
|
|
@ -385,9 +390,11 @@ defmodule ToweropsWeb.DeviceLive.Helpers.ChartBuilders do
|
|||
end
|
||||
|
||||
# Convert capacity sensor value (in Mbps) to bps
|
||||
defp capacity_value_to_bps(nil), do: 0
|
||||
defp capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000)
|
||||
defp capacity_value_to_bps(_), do: 0
|
||||
@doc false
|
||||
@spec capacity_value_to_bps(term()) :: non_neg_integer()
|
||||
def capacity_value_to_bps(nil), do: 0
|
||||
def capacity_value_to_bps(value) when is_number(value), do: round(value * 1_000_000)
|
||||
def capacity_value_to_bps(_), do: 0
|
||||
|
||||
# Build capacity data points from sensor readings
|
||||
defp build_capacity_data_from_sensors(rx_readings, tx_readings, rx_sensor, tx_sensor) do
|
||||
|
|
|
|||
|
|
@ -58,11 +58,17 @@ defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do
|
|||
}
|
||||
end
|
||||
|
||||
# Agent is considered offline if it hasn't been seen in the last 5 minutes
|
||||
defp agent_offline?(%{is_cloud_poller: true}), do: false
|
||||
defp agent_offline?(%{last_seen_at: nil}), do: true
|
||||
@doc """
|
||||
True if an agent should be considered offline.
|
||||
|
||||
defp agent_offline?(%{last_seen_at: last_seen_at}) do
|
||||
Cloud pollers are never offline. Agents without a `last_seen_at` are always
|
||||
offline. Otherwise the cutoff is 5 minutes from `DateTime.utc_now/0`.
|
||||
"""
|
||||
@spec agent_offline?(map()) :: boolean()
|
||||
def agent_offline?(%{is_cloud_poller: true}), do: false
|
||||
def agent_offline?(%{last_seen_at: nil}), do: true
|
||||
|
||||
def agent_offline?(%{last_seen_at: last_seen_at}) do
|
||||
five_minutes_ago = DateTime.add(DateTime.utc_now(), -5, :minute)
|
||||
DateTime.before?(last_seen_at, five_minutes_ago)
|
||||
end
|
||||
|
|
@ -176,28 +182,36 @@ defmodule ToweropsWeb.DeviceLive.Helpers.DataLoaders do
|
|||
end
|
||||
end
|
||||
|
||||
defp determine_vendor(manufacturer) when is_binary(manufacturer) do
|
||||
manufacturer_lower = String.downcase(manufacturer)
|
||||
@doc """
|
||||
Determines a canonical vendor string from a manufacturer string.
|
||||
Recognizes MikroTik, Cisco, and Ubiquiti. Returns `nil` for others.
|
||||
"""
|
||||
@spec determine_vendor(String.t() | nil | term()) :: String.t() | nil
|
||||
def determine_vendor(manufacturer) when is_binary(manufacturer) do
|
||||
manufacturer |> String.downcase() |> match_vendor()
|
||||
end
|
||||
|
||||
def determine_vendor(_), do: nil
|
||||
|
||||
defp match_vendor(lower) do
|
||||
cond do
|
||||
String.contains?(manufacturer_lower, "mikrotik") -> "mikrotik"
|
||||
String.contains?(manufacturer_lower, "cisco") -> "cisco"
|
||||
String.contains?(manufacturer_lower, "ubiquiti") -> "ubiquiti"
|
||||
String.contains?(lower, "mikrotik") -> "mikrotik"
|
||||
String.contains?(lower, "cisco") -> "cisco"
|
||||
String.contains?(lower, "ubiquiti") -> "ubiquiti"
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_vendor(_), do: nil
|
||||
|
||||
defp determine_product_line(manufacturer) when is_binary(manufacturer) do
|
||||
manufacturer_lower = String.downcase(manufacturer)
|
||||
|
||||
if String.contains?(manufacturer_lower, "mikrotik") do
|
||||
"routeros"
|
||||
end
|
||||
@doc """
|
||||
Determines a product-line slug from a manufacturer string.
|
||||
Only MikroTik is recognized (→ `"routeros"`); other vendors return `nil`.
|
||||
"""
|
||||
@spec determine_product_line(String.t() | nil | term()) :: String.t() | nil
|
||||
def determine_product_line(manufacturer) when is_binary(manufacturer) do
|
||||
if manufacturer |> String.downcase() |> String.contains?("mikrotik"), do: "routeros"
|
||||
end
|
||||
|
||||
defp determine_product_line(_), do: nil
|
||||
def determine_product_line(_), do: nil
|
||||
|
||||
@doc """
|
||||
Loads neighbors (LLDP/CDP) with equipment associations.
|
||||
|
|
|
|||
|
|
@ -29,22 +29,13 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do
|
|||
- nil -> "-"
|
||||
"""
|
||||
@spec format_speed(integer() | nil) :: String.t()
|
||||
def format_speed(speed_bps) when is_integer(speed_bps) do
|
||||
cond do
|
||||
speed_bps >= 1_000_000_000 ->
|
||||
"#{Float.round(speed_bps / 1_000_000_000, 1)} Gbps"
|
||||
def format_speed(bps) when is_integer(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||
|
||||
speed_bps >= 1_000_000 ->
|
||||
"#{Float.round(speed_bps / 1_000_000, 1)} Mbps"
|
||||
def format_speed(bps) when is_integer(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
|
||||
|
||||
speed_bps >= 1_000 ->
|
||||
"#{Float.round(speed_bps / 1_000, 1)} Kbps"
|
||||
|
||||
true ->
|
||||
"#{speed_bps} bps"
|
||||
end
|
||||
end
|
||||
def format_speed(bps) when is_integer(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
|
||||
|
||||
def format_speed(bps) when is_integer(bps), do: "#{bps} bps"
|
||||
def format_speed(_), do: "-"
|
||||
|
||||
@doc """
|
||||
|
|
@ -60,25 +51,17 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do
|
|||
def format_bytes(nil), do: "-"
|
||||
def format_bytes(0), do: "0 B"
|
||||
|
||||
def 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"
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_099_511_627_776,
|
||||
do: "#{Float.round(bytes / 1_099_511_627_776, 1)} TB"
|
||||
|
||||
bytes >= 1_073_741_824 ->
|
||||
"#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824,
|
||||
do: "#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
|
||||
bytes >= 1_048_576 ->
|
||||
"#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
|
||||
bytes >= 1024 ->
|
||||
"#{Float.round(bytes / 1024, 1)} KB"
|
||||
|
||||
true ->
|
||||
"#{bytes} B"
|
||||
end
|
||||
end
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1024, do: "#{Float.round(bytes / 1024, 1)} KB"
|
||||
|
||||
def format_bytes(bytes) when is_integer(bytes), do: "#{bytes} B"
|
||||
def format_bytes(_), do: "-"
|
||||
|
||||
@doc """
|
||||
|
|
@ -147,16 +130,14 @@ defmodule ToweropsWeb.DeviceLive.Helpers.Formatters do
|
|||
def time_ago(nil), do: "Never"
|
||||
|
||||
def time_ago(datetime) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "#{diff}s ago"
|
||||
diff < 3600 -> "#{div(diff, 60)}m ago"
|
||||
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
||||
true -> "#{div(diff, 86_400)}d ago"
|
||||
end
|
||||
DateTime.utc_now() |> DateTime.diff(datetime, :second) |> format_ago()
|
||||
end
|
||||
|
||||
defp format_ago(diff) when diff < 60, do: "#{diff}s ago"
|
||||
defp format_ago(diff) when diff < 3600, do: "#{div(diff, 60)}m ago"
|
||||
defp format_ago(diff) when diff < 86_400, do: "#{div(diff, 3600)}h ago"
|
||||
defp format_ago(diff), do: "#{div(diff, 86_400)}d ago"
|
||||
|
||||
@doc """
|
||||
Formats SNMP uptime (in timeticks) to human-readable duration.
|
||||
|
||||
|
|
|
|||
|
|
@ -382,10 +382,11 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
|> filter_by_status(status_filter)
|
||||
end
|
||||
|
||||
defp filter_by_search(devices, ""), do: devices
|
||||
defp filter_by_search(devices, nil), do: devices
|
||||
@doc false
|
||||
def filter_by_search(devices, ""), do: devices
|
||||
def filter_by_search(devices, nil), do: devices
|
||||
|
||||
defp filter_by_search(devices, query) do
|
||||
def filter_by_search(devices, query) when is_binary(query) do
|
||||
q = String.downcase(query)
|
||||
|
||||
Enum.filter(devices, fn device ->
|
||||
|
|
@ -394,11 +395,12 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
end)
|
||||
end
|
||||
|
||||
defp filter_by_status(devices, "all"), do: devices
|
||||
defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up))
|
||||
defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down))
|
||||
defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown))
|
||||
defp filter_by_status(devices, _), do: devices
|
||||
@doc false
|
||||
def filter_by_status(devices, "all"), do: devices
|
||||
def filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up))
|
||||
def filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down))
|
||||
def filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown))
|
||||
def filter_by_status(devices, _), do: devices
|
||||
|
||||
defp enqueue_discovery(device_id) do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
|
|
@ -470,7 +472,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
)
|
||||
end
|
||||
|
||||
defp calculate_site_stats(devices) do
|
||||
@doc false
|
||||
def calculate_site_stats(devices) do
|
||||
status_counts = Enum.frequencies_by(devices, & &1.status)
|
||||
|
||||
%{
|
||||
|
|
@ -481,10 +484,11 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
}
|
||||
end
|
||||
|
||||
defp device_type_label(nil), do: "Unknown"
|
||||
defp device_type_label("access_point"), do: "Access Point"
|
||||
defp device_type_label(role) when is_binary(role), do: String.capitalize(role)
|
||||
defp device_type_label(_), do: "Unknown"
|
||||
@doc false
|
||||
def device_type_label(nil), do: "Unknown"
|
||||
def device_type_label("access_point"), do: "Access Point"
|
||||
def device_type_label(role) when is_binary(role), do: String.capitalize(role)
|
||||
def device_type_label(_), do: "Unknown"
|
||||
|
||||
defp load_site_subscribers(devices) do
|
||||
site_ids =
|
||||
|
|
|
|||
|
|
@ -662,24 +662,26 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
defp utilization_color(pct), do: Formatters.utilization_color(pct)
|
||||
|
||||
# Additional template-used formatting functions (not yet in Formatters module)
|
||||
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
@doc false
|
||||
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
|
||||
defp format_event_type(event_type) do
|
||||
@doc false
|
||||
def format_event_type(event_type) do
|
||||
event_type
|
||||
|> String.replace("_", " ")
|
||||
|> String.capitalize()
|
||||
end
|
||||
|
||||
defp firmware_update_available?(nil, _), do: false
|
||||
defp firmware_update_available?(_, nil), do: false
|
||||
@doc false
|
||||
def firmware_update_available?(nil, _), do: false
|
||||
def firmware_update_available?(_, nil), do: false
|
||||
|
||||
defp firmware_update_available?(snmp_device, available_firmware) do
|
||||
def firmware_update_available?(snmp_device, available_firmware) do
|
||||
current = snmp_device.firmware_version
|
||||
available = available_firmware.version
|
||||
|
||||
# Extract clean version numbers for comparison
|
||||
current_clean = extract_version_number(current)
|
||||
available_clean = extract_version_number(available)
|
||||
|
||||
|
|
@ -687,17 +689,19 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
VersionComparator.newer(current_clean, available_clean)
|
||||
end
|
||||
|
||||
defp extract_version_number(nil), do: nil
|
||||
defp extract_version_number(""), do: nil
|
||||
@doc false
|
||||
def extract_version_number(nil), do: nil
|
||||
def extract_version_number(""), do: nil
|
||||
|
||||
defp extract_version_number(version_string) when is_binary(version_string) do
|
||||
# Match semantic version pattern: X.Y or X.Y.Z
|
||||
def extract_version_number(version_string) when is_binary(version_string) do
|
||||
case Regex.run(~r/\d+\.\d+(?:\.\d+)?/, version_string) do
|
||||
[version] -> version
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
def extract_version_number(_), do: nil
|
||||
|
||||
# Group interfaces by type for organized display
|
||||
defp group_interfaces_by_type(interfaces) do
|
||||
interfaces
|
||||
|
|
@ -1062,16 +1066,17 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|
||||
# Checks tab helpers
|
||||
|
||||
defp group_title(:snmp_sensors), do: "SNMP Sensors"
|
||||
defp group_title(:snmp_interfaces), do: "SNMP Interfaces"
|
||||
defp group_title(:snmp_processors), do: "SNMP Processors"
|
||||
defp group_title(:snmp_storage), do: "SNMP Storage"
|
||||
defp group_title(:http_checks), do: "HTTP Checks"
|
||||
defp group_title(:tcp_checks), do: "TCP Checks"
|
||||
defp group_title(:dns_checks), do: "DNS Checks"
|
||||
defp group_title(:ssl_checks), do: "SSL Certificate Checks"
|
||||
defp group_title(:ping_checks), do: "Ping Checks"
|
||||
defp group_title(:other_checks), do: "Other Checks"
|
||||
@doc false
|
||||
def group_title(:snmp_sensors), do: "SNMP Sensors"
|
||||
def group_title(:snmp_interfaces), do: "SNMP Interfaces"
|
||||
def group_title(:snmp_processors), do: "SNMP Processors"
|
||||
def group_title(:snmp_storage), do: "SNMP Storage"
|
||||
def group_title(:http_checks), do: "HTTP Checks"
|
||||
def group_title(:tcp_checks), do: "TCP Checks"
|
||||
def group_title(:dns_checks), do: "DNS Checks"
|
||||
def group_title(:ssl_checks), do: "SSL Certificate Checks"
|
||||
def group_title(:ping_checks), do: "Ping Checks"
|
||||
def group_title(:other_checks), do: "Other Checks"
|
||||
|
||||
defp render_status_badge(0) do
|
||||
assigns = %{}
|
||||
|
|
@ -1131,19 +1136,20 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_check_value(%{value: nil}, _check), do: "-"
|
||||
@doc false
|
||||
def format_check_value(%{value: nil}, _check), do: "-"
|
||||
|
||||
defp format_check_value(%{value: value}, check) when is_number(value) do
|
||||
def format_check_value(%{value: value}, check) when is_number(value) do
|
||||
case check.check_type do
|
||||
"snmp_sensor" -> format_check_sensor_value(value, check.config)
|
||||
"snmp_processor" -> "#{Float.round(value, 1)}%"
|
||||
"snmp_storage" -> "#{Float.round(value, 1)}%"
|
||||
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value, 2)} ms"
|
||||
_ -> value |> Float.round(2) |> to_string()
|
||||
"snmp_processor" -> "#{Float.round(value / 1, 1)}%"
|
||||
"snmp_storage" -> "#{Float.round(value / 1, 1)}%"
|
||||
t when t in ["ping", "http", "tcp", "dns"] -> "#{Float.round(value / 1, 2)} ms"
|
||||
_ -> value |> Kernel./(1) |> Float.round(2) |> to_string()
|
||||
end
|
||||
end
|
||||
|
||||
defp format_check_value(_result, _check), do: "-"
|
||||
def format_check_value(_result, _check), do: "-"
|
||||
|
||||
@sensor_value_formats %{
|
||||
"temperature" => {1, "°C"},
|
||||
|
|
|
|||
|
|
@ -193,26 +193,27 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
{chart_data, unit, auto_scale, false}
|
||||
end
|
||||
|
||||
defp get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()}
|
||||
defp get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
|
||||
@doc false
|
||||
def get_time_range_for_graph("1h"), do: {DateTime.add(DateTime.utc_now(), -1, :hour), DateTime.utc_now()}
|
||||
def get_time_range_for_graph("6h"), do: {DateTime.add(DateTime.utc_now(), -6, :hour), DateTime.utc_now()}
|
||||
def get_time_range_for_graph("12h"), do: {DateTime.add(DateTime.utc_now(), -12, :hour), DateTime.utc_now()}
|
||||
def get_time_range_for_graph("24h"), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
|
||||
def get_time_range_for_graph("7d"), do: {DateTime.add(DateTime.utc_now(), -7, :day), DateTime.utc_now()}
|
||||
def get_time_range_for_graph("30d"), do: {DateTime.add(DateTime.utc_now(), -30, :day), DateTime.utc_now()}
|
||||
def get_time_range_for_graph(_), do: {DateTime.add(DateTime.utc_now(), -24, :hour), DateTime.utc_now()}
|
||||
|
||||
defp get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do
|
||||
# Get unit from sensor config
|
||||
@doc false
|
||||
def get_check_unit_and_scale(%{check_type: "snmp_sensor", config: config}) do
|
||||
unit = config["sensor_unit"] || ""
|
||||
{unit, true}
|
||||
end
|
||||
|
||||
defp get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false}
|
||||
defp get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false}
|
||||
defp get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true}
|
||||
defp get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true}
|
||||
defp get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true}
|
||||
defp get_check_unit_and_scale(_), do: {"", true}
|
||||
def get_check_unit_and_scale(%{check_type: "snmp_processor"}), do: {"%", false}
|
||||
def get_check_unit_and_scale(%{check_type: "snmp_storage"}), do: {"%", false}
|
||||
def get_check_unit_and_scale(%{check_type: "http"}), do: {"ms", true}
|
||||
def get_check_unit_and_scale(%{check_type: "tcp"}), do: {"ms", true}
|
||||
def get_check_unit_and_scale(%{check_type: "dns"}), do: {"ms", true}
|
||||
def get_check_unit_and_scale(_), do: {"", true}
|
||||
|
||||
defp initialize_graph_view(socket, device_id, sensor_type, params) do
|
||||
maybe_subscribe_to_device(socket, device_id)
|
||||
|
|
@ -933,8 +934,8 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
|> Enum.reject(&is_nil/1)
|
||||
end
|
||||
|
||||
# Format value with unit for display
|
||||
defp format_value(value, "bps") do
|
||||
@doc false
|
||||
def format_value(value, "bps") do
|
||||
abs_value = abs(value)
|
||||
|
||||
cond do
|
||||
|
|
@ -952,17 +953,15 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
# For counts (empty unit), format as integer
|
||||
defp format_value(value, "") do
|
||||
def format_value(value, "") do
|
||||
"#{round(value)}"
|
||||
end
|
||||
|
||||
# For other units, format with one decimal place
|
||||
defp format_value(value, unit) when is_float(value) do
|
||||
def format_value(value, unit) when is_float(value) do
|
||||
"#{Float.round(value, 1)} #{unit}"
|
||||
end
|
||||
|
||||
defp format_value(value, unit) when is_integer(value) do
|
||||
def format_value(value, unit) when is_integer(value) do
|
||||
"#{Float.round(value / 1, 1)} #{unit}"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -131,53 +131,30 @@ defmodule ToweropsWeb.InsightsLive.Index do
|
|||
assign(socket, :insights, insights)
|
||||
end
|
||||
|
||||
defp urgency_classes(urgency) do
|
||||
case urgency do
|
||||
"critical" ->
|
||||
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
@doc false
|
||||
def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
|
||||
"warning" ->
|
||||
"bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
@doc false
|
||||
def source_classes("preseem"), do: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
|
||||
def source_classes("gaiia"), do: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
def source_classes("snmp"), do: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
def source_classes("system"), do: "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
|
||||
"info" ->
|
||||
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
|
||||
_ ->
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
end
|
||||
end
|
||||
|
||||
defp source_classes(source) do
|
||||
case source do
|
||||
"preseem" ->
|
||||
"bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-400"
|
||||
|
||||
"gaiia" ->
|
||||
"bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
|
||||
|
||||
"snmp" ->
|
||||
"bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
|
||||
"system" ->
|
||||
"bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300"
|
||||
|
||||
_ ->
|
||||
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
end
|
||||
end
|
||||
|
||||
defp build_filter_params(base, overrides) do
|
||||
@doc false
|
||||
def build_filter_params(base, overrides) do
|
||||
base
|
||||
|> Map.merge(overrides)
|
||||
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp selected?(selected_ids, id) do
|
||||
MapSet.member?(selected_ids, id)
|
||||
end
|
||||
@doc false
|
||||
def selected?(selected_ids, id), do: MapSet.member?(selected_ids, id)
|
||||
|
||||
defp any_selected?(selected_ids) do
|
||||
MapSet.size(selected_ids) > 0
|
||||
end
|
||||
@doc false
|
||||
def any_selected?(selected_ids), do: MapSet.size(selected_ids) > 0
|
||||
end
|
||||
|
|
|
|||
|
|
@ -82,13 +82,15 @@ defmodule ToweropsWeb.MaintenanceLive.Form do
|
|||
end
|
||||
end
|
||||
|
||||
defp maybe_clear_scope(params, "org"), do: Map.merge(params, %{"site_id" => nil, "device_id" => nil})
|
||||
defp maybe_clear_scope(params, "site"), do: Map.put(params, "device_id", nil)
|
||||
defp maybe_clear_scope(params, _), do: params
|
||||
@doc false
|
||||
def maybe_clear_scope(params, "org"), do: Map.merge(params, %{"site_id" => nil, "device_id" => nil})
|
||||
def maybe_clear_scope(params, "site"), do: Map.put(params, "device_id", nil)
|
||||
def maybe_clear_scope(params, _), do: params
|
||||
|
||||
defp format_datetime_local(nil), do: ""
|
||||
defp format_datetime_local(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%dT%H:%M")
|
||||
defp format_datetime_local(_), do: ""
|
||||
@doc false
|
||||
def format_datetime_local(nil), do: ""
|
||||
def format_datetime_local(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%dT%H:%M")
|
||||
def format_datetime_local(_), do: ""
|
||||
|
||||
defp save_window(socket, :create, params) do
|
||||
case Maintenance.create_window(params) do
|
||||
|
|
|
|||
|
|
@ -115,15 +115,14 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
{:noreply, push_event(socket, "download", %{content: config_text, filename: filename, mime_type: "text/plain"})}
|
||||
end
|
||||
|
||||
# Helper function to format byte sizes
|
||||
defp format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
|
||||
cond do
|
||||
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 >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB"
|
||||
true -> "#{bytes} B"
|
||||
end
|
||||
end
|
||||
@doc false
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_073_741_824,
|
||||
do: "#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
|
||||
defp format_bytes(_), do: "Invalid byte count"
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_048_576, do: "#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 1_024, do: "#{Float.round(bytes / 1_024, 1)} KB"
|
||||
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0, do: "#{bytes} B"
|
||||
def format_bytes(_), do: "Invalid byte count"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -188,15 +188,17 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
|
||||
# Private helpers
|
||||
|
||||
defp next_step(:snmp), do: :site
|
||||
defp next_step(:site), do: :billing
|
||||
defp next_step(:billing), do: :agent
|
||||
defp next_step(:agent), do: :done
|
||||
@doc false
|
||||
def next_step(:snmp), do: :site
|
||||
def next_step(:site), do: :billing
|
||||
def next_step(:billing), do: :agent
|
||||
def next_step(:agent), do: :done
|
||||
|
||||
defp step_index(:snmp), do: 0
|
||||
defp step_index(:site), do: 1
|
||||
defp step_index(:billing), do: 2
|
||||
defp step_index(:agent), do: 3
|
||||
@doc false
|
||||
def step_index(:snmp), do: 0
|
||||
def step_index(:site), do: 1
|
||||
def step_index(:billing), do: 2
|
||||
def step_index(:agent), do: 3
|
||||
|
||||
defp step_label(:snmp), do: t("SNMP")
|
||||
defp step_label(:site), do: t("Site")
|
||||
|
|
@ -231,7 +233,8 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_integration_params(params, "sonar") do
|
||||
@doc false
|
||||
def normalize_integration_params(params, "sonar") do
|
||||
%{
|
||||
credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
|
|
@ -240,7 +243,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp normalize_integration_params(params, "splynx") do
|
||||
def normalize_integration_params(params, "splynx") do
|
||||
%{
|
||||
credentials: %{
|
||||
"instance_url" => String.trim(Map.get(params, "instance_url", "")),
|
||||
|
|
@ -250,7 +253,7 @@ defmodule ToweropsWeb.OnboardingLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp normalize_integration_params(params, _provider) do
|
||||
def normalize_integration_params(params, _provider) do
|
||||
%{credentials: %{"api_key" => Map.get(params, "api_key", "")}}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -273,9 +273,10 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
|> assign(:suggestions, suggestions)
|
||||
end
|
||||
|
||||
defp add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc
|
||||
@doc false
|
||||
def add_device_suggestion(acc, %{inventory_item: item}, _) when not is_nil(item), do: acc
|
||||
|
||||
defp add_device_suggestion(acc, row, unmapped_items_by_ip) do
|
||||
def add_device_suggestion(acc, row, unmapped_items_by_ip) do
|
||||
device_ip = to_string(row.device.ip_address)
|
||||
matches = Map.get(unmapped_items_by_ip, device_ip, [])
|
||||
|
||||
|
|
@ -286,10 +287,11 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc
|
||||
defp add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc
|
||||
@doc false
|
||||
def add_site_suggestion(acc, %{network_site: ns}, _) when not is_nil(ns), do: acc
|
||||
def add_site_suggestion(acc, %{site: %{name: name}}, _) when name in [nil, ""], do: acc
|
||||
|
||||
defp add_site_suggestion(acc, row, unmapped_ns) do
|
||||
def add_site_suggestion(acc, row, unmapped_ns) do
|
||||
site_name_lower = String.downcase(row.site.name)
|
||||
|
||||
matches =
|
||||
|
|
@ -306,11 +308,13 @@ defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item)))
|
||||
defp apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item))
|
||||
defp apply_device_filter(rows, _), do: rows
|
||||
@doc false
|
||||
def apply_device_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.inventory_item)))
|
||||
def apply_device_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.inventory_item))
|
||||
def apply_device_filter(rows, _), do: rows
|
||||
|
||||
defp apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site)))
|
||||
defp apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site))
|
||||
defp apply_site_filter(rows, _), do: rows
|
||||
@doc false
|
||||
def apply_site_filter(rows, "linked"), do: Enum.filter(rows, &(not is_nil(&1.network_site)))
|
||||
def apply_site_filter(rows, "unlinked"), do: Enum.filter(rows, &is_nil(&1.network_site))
|
||||
def apply_site_filter(rows, _), do: rows
|
||||
end
|
||||
|
|
|
|||
|
|
@ -546,9 +546,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp validate_present(nil, msg), do: {:error, msg}
|
||||
defp validate_present("", msg), do: {:error, msg}
|
||||
defp validate_present(_, _msg), do: :ok
|
||||
@doc false
|
||||
def validate_present(nil, msg), do: {:error, msg}
|
||||
def validate_present("", msg), do: {:error, msg}
|
||||
def validate_present(_, _msg), do: :ok
|
||||
|
||||
# === Private helpers (integrations) ===
|
||||
|
||||
|
|
@ -585,7 +586,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_params(params, existing_integration) do
|
||||
@doc false
|
||||
def normalize_params(params, existing_integration) do
|
||||
provider =
|
||||
case existing_integration do
|
||||
%Integration{provider: p} -> p
|
||||
|
|
@ -600,7 +602,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_standard_params(params, existing_integration) do
|
||||
@doc false
|
||||
def normalize_standard_params(params, existing_integration) do
|
||||
api_key = Map.get(params, "api_key", "")
|
||||
sync_interval = Map.get(params, "sync_interval_minutes")
|
||||
|
||||
|
|
@ -629,7 +632,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_netbox_params(params, _existing_integration) do
|
||||
@doc false
|
||||
def normalize_netbox_params(params, _existing_integration) do
|
||||
sync_interval = Map.get(params, "sync_interval_minutes")
|
||||
|
||||
credentials = %{
|
||||
|
|
@ -654,13 +658,14 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp get_credential(nil, _key), do: ""
|
||||
@doc false
|
||||
def get_credential(nil, _key), do: ""
|
||||
|
||||
defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do
|
||||
def get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do
|
||||
Map.get(credentials, key, "")
|
||||
end
|
||||
|
||||
defp get_credential(_integration, _key), do: ""
|
||||
def get_credential(_integration, _key), do: ""
|
||||
|
||||
defp current_integration(socket) do
|
||||
provider = socket.assigns.configuring
|
||||
|
|
@ -695,7 +700,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_sonar_params(params) do
|
||||
@doc false
|
||||
def normalize_sonar_params(params) do
|
||||
sync_interval = Map.get(params, "sync_interval_minutes")
|
||||
|
||||
attrs = %{
|
||||
|
|
@ -712,7 +718,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_splynx_params(params) do
|
||||
@doc false
|
||||
def normalize_splynx_params(params) do
|
||||
sync_interval = Map.get(params, "sync_interval_minutes")
|
||||
|
||||
attrs = %{
|
||||
|
|
@ -753,9 +760,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
{instance_url, api_key, api_secret}
|
||||
end
|
||||
|
||||
defp non_empty("", fallback), do: fallback
|
||||
defp non_empty(nil, fallback), do: fallback
|
||||
defp non_empty(value, _fallback), do: value
|
||||
@doc false
|
||||
def non_empty("", fallback), do: fallback
|
||||
def non_empty(nil, fallback), do: fallback
|
||||
def non_empty(value, _fallback), do: value
|
||||
|
||||
defp error_messages(changeset) do
|
||||
changeset
|
||||
|
|
@ -767,29 +775,31 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end)
|
||||
end
|
||||
|
||||
defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"}
|
||||
defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"}
|
||||
defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"}
|
||||
@doc false
|
||||
def format_connection_result({:ok, _body}), do: {:ok, "Connection successful"}
|
||||
def format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"}
|
||||
def format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"}
|
||||
|
||||
defp format_connection_result({:error, {:unexpected_status, status}}),
|
||||
def format_connection_result({:error, {:unexpected_status, status}}),
|
||||
do: {:error, "API returned unexpected status: #{status}"}
|
||||
|
||||
defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"}
|
||||
def format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"}
|
||||
|
||||
defp format_connection_result({:error, %{reason: :econnrefused}}),
|
||||
def format_connection_result({:error, %{reason: :econnrefused}}),
|
||||
do: {:error, "Connection refused - check API endpoint"}
|
||||
|
||||
defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"}
|
||||
def format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"}
|
||||
|
||||
defp format_connection_result({:error, {:graphql_errors, errors}}),
|
||||
def format_connection_result({:error, {:graphql_errors, errors}}),
|
||||
do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"}
|
||||
|
||||
defp format_connection_result({:error, reason}) do
|
||||
def format_connection_result({:error, reason}) do
|
||||
Logger.warning("Integration connection test failed: #{inspect(reason)}")
|
||||
{:error, "Connection failed - please check your API credentials and try again"}
|
||||
end
|
||||
|
||||
defp default_netbox_config do
|
||||
@doc false
|
||||
def default_netbox_config do
|
||||
%{
|
||||
"url" => "",
|
||||
"api_token" => "",
|
||||
|
|
@ -804,9 +814,10 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp load_netbox_config(nil), do: default_netbox_config()
|
||||
@doc false
|
||||
def load_netbox_config(nil), do: default_netbox_config()
|
||||
|
||||
defp load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do
|
||||
def load_netbox_config(%Integration{credentials: creds}) when is_map(creds) do
|
||||
%{
|
||||
"url" => Map.get(creds, "url", ""),
|
||||
"api_token" => Map.get(creds, "api_token", ""),
|
||||
|
|
@ -821,16 +832,17 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp load_netbox_config(_), do: default_netbox_config()
|
||||
def load_netbox_config(_), do: default_netbox_config()
|
||||
|
||||
defp load_integration_credentials(integration, "sonar") do
|
||||
@doc false
|
||||
def load_integration_credentials(integration, "sonar") do
|
||||
%{
|
||||
"instance_url" => get_credential(integration, "instance_url"),
|
||||
"api_token" => get_credential(integration, "api_token")
|
||||
}
|
||||
end
|
||||
|
||||
defp load_integration_credentials(integration, "splynx") do
|
||||
def load_integration_credentials(integration, "splynx") do
|
||||
%{
|
||||
"instance_url" => get_credential(integration, "instance_url"),
|
||||
"api_key" => get_credential(integration, "api_key"),
|
||||
|
|
@ -838,18 +850,19 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp load_integration_credentials(integration, _provider) do
|
||||
def load_integration_credentials(integration, _provider) do
|
||||
%{"api_key" => get_credential(integration, "api_key")}
|
||||
end
|
||||
|
||||
defp extract_credentials_from_params(params, "sonar") do
|
||||
@doc false
|
||||
def extract_credentials_from_params(params, "sonar") do
|
||||
%{
|
||||
"instance_url" => Map.get(params, "instance_url", ""),
|
||||
"api_token" => Map.get(params, "api_token", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp extract_credentials_from_params(params, "splynx") do
|
||||
def extract_credentials_from_params(params, "splynx") do
|
||||
%{
|
||||
"instance_url" => Map.get(params, "instance_url", ""),
|
||||
"api_key" => Map.get(params, "api_key", ""),
|
||||
|
|
@ -857,7 +870,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
}
|
||||
end
|
||||
|
||||
defp extract_credentials_from_params(params, _provider) do
|
||||
def extract_credentials_from_params(params, _provider) do
|
||||
%{"api_key" => Map.get(params, "api_key", "")}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -116,24 +116,27 @@ defmodule ToweropsWeb.ReportsLive do
|
|||
|
||||
# -- Helpers --
|
||||
|
||||
defp humanize_type("uptime_summary"), do: "Uptime Summary"
|
||||
defp humanize_type("alert_history"), do: "Alert History"
|
||||
defp humanize_type("capacity_trends"), do: "Capacity Trends"
|
||||
defp humanize_type("rf_link_health"), do: "RF Link Health"
|
||||
defp humanize_type(type), do: type
|
||||
@doc false
|
||||
def humanize_type("uptime_summary"), do: "Uptime Summary"
|
||||
def humanize_type("alert_history"), do: "Alert History"
|
||||
def humanize_type("capacity_trends"), do: "Capacity Trends"
|
||||
def humanize_type("rf_link_health"), do: "RF Link Health"
|
||||
def humanize_type(type), do: type
|
||||
|
||||
defp humanize_schedule(%{"type" => type}), do: String.capitalize(type)
|
||||
defp humanize_schedule(_), do: "-"
|
||||
@doc false
|
||||
def humanize_schedule(%{"type" => type}), do: String.capitalize(type)
|
||||
def humanize_schedule(_), do: "-"
|
||||
|
||||
defp format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ")
|
||||
defp format_recipients(_), do: "-"
|
||||
@doc false
|
||||
def format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ")
|
||||
def format_recipients(_), do: "-"
|
||||
|
||||
defp status_badge("success"), do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
|
||||
@doc false
|
||||
def status_badge("success"), do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
|
||||
def status_badge("failed"), do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
|
||||
|
||||
defp status_badge("failed"), do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
|
||||
|
||||
defp status_badge("partial_failure"),
|
||||
def status_badge("partial_failure"),
|
||||
do: {"Partial", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"}
|
||||
|
||||
defp status_badge(_), do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"}
|
||||
def status_badge(_), do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -80,101 +80,105 @@ defmodule ToweropsWeb.RfLinkHealthLive do
|
|||
|
||||
# -- Helpers --
|
||||
|
||||
defp health_color(:healthy), do: "text-green-600 dark:text-green-400"
|
||||
defp health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp health_color(:critical), do: "text-red-600 dark:text-red-400"
|
||||
defp health_color(_), do: "text-gray-400 dark:text-gray-500"
|
||||
@doc false
|
||||
def health_color(:healthy), do: "text-green-600 dark:text-green-400"
|
||||
def health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400"
|
||||
def health_color(:critical), do: "text-red-600 dark:text-red-400"
|
||||
def health_color(_), do: "text-gray-400 dark:text-gray-500"
|
||||
|
||||
defp health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
|
||||
defp health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
|
||||
defp health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
|
||||
defp health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
@doc false
|
||||
def health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
|
||||
def health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
|
||||
def health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
|
||||
def health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
|
||||
defp health_label(:healthy), do: "Healthy"
|
||||
defp health_label(:degraded), do: "Degraded"
|
||||
defp health_label(:critical), do: "Critical"
|
||||
defp health_label(_), do: "Unknown"
|
||||
@doc false
|
||||
def health_label(:healthy), do: "Healthy"
|
||||
def health_label(:degraded), do: "Degraded"
|
||||
def health_label(:critical), do: "Critical"
|
||||
def health_label(_), do: "Unknown"
|
||||
|
||||
defp signal_bar_width(nil), do: 0
|
||||
@doc false
|
||||
def signal_bar_width(nil), do: 0
|
||||
|
||||
defp signal_bar_width(signal) do
|
||||
def signal_bar_width(signal) do
|
||||
# Map signal from -100..-30 dBm to 0..100%
|
||||
clamped = max(-100, min(-30, signal))
|
||||
round((clamped + 100) / 70 * 100)
|
||||
end
|
||||
|
||||
defp signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
|
||||
@doc false
|
||||
def signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
|
||||
def signal_bar_color(signal) when signal > -65, do: "bg-green-500"
|
||||
def signal_bar_color(signal) when signal > -75, do: "bg-yellow-500"
|
||||
def signal_bar_color(_), do: "bg-red-500"
|
||||
|
||||
defp signal_bar_color(signal) do
|
||||
cond do
|
||||
signal > -65 -> "bg-green-500"
|
||||
signal > -75 -> "bg-yellow-500"
|
||||
true -> "bg-red-500"
|
||||
end
|
||||
end
|
||||
@doc false
|
||||
def snr_bar_width(nil), do: 0
|
||||
|
||||
defp snr_bar_width(nil), do: 0
|
||||
|
||||
defp snr_bar_width(snr) do
|
||||
def snr_bar_width(snr) do
|
||||
# Map SNR from 0..40 dB to 0..100%
|
||||
clamped = max(0, min(40, snr))
|
||||
round(clamped / 40 * 100)
|
||||
end
|
||||
|
||||
defp snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
|
||||
@doc false
|
||||
def snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
|
||||
def snr_bar_color(snr) when snr > 25, do: "bg-green-500"
|
||||
def snr_bar_color(snr) when snr > 15, do: "bg-yellow-500"
|
||||
def snr_bar_color(_), do: "bg-red-500"
|
||||
|
||||
defp snr_bar_color(snr) do
|
||||
cond do
|
||||
snr > 25 -> "bg-green-500"
|
||||
snr > 15 -> "bg-yellow-500"
|
||||
true -> "bg-red-500"
|
||||
end
|
||||
end
|
||||
@doc false
|
||||
def format_signal(nil), do: "-"
|
||||
def format_signal(val), do: "#{val} dBm"
|
||||
|
||||
defp format_signal(nil), do: "-"
|
||||
defp format_signal(val), do: "#{val} dBm"
|
||||
@doc false
|
||||
def format_snr(nil), do: "-"
|
||||
def format_snr(val), do: "#{val} dB"
|
||||
|
||||
defp format_snr(nil), do: "-"
|
||||
defp format_snr(val), do: "#{val} dB"
|
||||
@doc false
|
||||
def format_rate(nil), do: "-"
|
||||
def format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps"
|
||||
def format_rate(rate), do: "#{rate} Mbps"
|
||||
|
||||
defp format_rate(nil), do: "-"
|
||||
@doc false
|
||||
def format_distance(nil), do: "-"
|
||||
def format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km"
|
||||
def format_distance(m), do: "#{m} m"
|
||||
|
||||
defp format_rate(rate) when rate >= 1000, do: "#{Float.round(rate / 1000, 1)} Gbps"
|
||||
@doc false
|
||||
def format_uptime(nil), do: "-"
|
||||
|
||||
defp format_rate(rate), do: "#{rate} Mbps"
|
||||
|
||||
defp format_distance(nil), do: "-"
|
||||
defp format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km"
|
||||
defp format_distance(m), do: "#{m} m"
|
||||
|
||||
defp format_uptime(nil), do: "-"
|
||||
|
||||
defp format_uptime(seconds) do
|
||||
def format_uptime(seconds) do
|
||||
days = div(seconds, 86_400)
|
||||
hours = div(rem(seconds, 86_400), 3600)
|
||||
|
||||
cond do
|
||||
days > 0 -> "#{days}d #{hours}h"
|
||||
hours > 0 -> "#{hours}h"
|
||||
true -> "< 1h"
|
||||
end
|
||||
format_uptime_parts(days, hours)
|
||||
end
|
||||
|
||||
defp device_name(%{device: %{name: name}}) when not is_nil(name), do: name
|
||||
defp device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip
|
||||
defp device_name(_), do: "-"
|
||||
defp format_uptime_parts(days, hours) when days > 0, do: "#{days}d #{hours}h"
|
||||
defp format_uptime_parts(_days, hours) when hours > 0, do: "#{hours}h"
|
||||
defp format_uptime_parts(_, _), do: "< 1h"
|
||||
|
||||
defp link_name(link) do
|
||||
@doc false
|
||||
def device_name(%{device: %{name: name}}) when not is_nil(name), do: name
|
||||
def device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip
|
||||
def device_name(_), do: "-"
|
||||
|
||||
@doc false
|
||||
def link_name(link) do
|
||||
link.hostname || link.ip_address || link.mac_address || "-"
|
||||
end
|
||||
|
||||
defp stat_card_class(:all, _), do: "ring-2 ring-blue-500"
|
||||
defp stat_card_class(filter, filter), do: "ring-2 ring-blue-500"
|
||||
defp stat_card_class(_, _), do: ""
|
||||
@doc false
|
||||
def stat_card_class(:all, _), do: "ring-2 ring-blue-500"
|
||||
def stat_card_class(filter, filter), do: "ring-2 ring-blue-500"
|
||||
def stat_card_class(_, _), do: ""
|
||||
|
||||
defp build_sparkline_points(readings) when length(readings) < 2, do: nil
|
||||
@doc false
|
||||
def build_sparkline_points(readings) when length(readings) < 2, do: nil
|
||||
|
||||
defp build_sparkline_points(readings) do
|
||||
def build_sparkline_points(readings) do
|
||||
signals = readings |> Enum.map(& &1.signal_strength) |> Enum.reject(&is_nil/1)
|
||||
|
||||
if signals == [] do
|
||||
|
|
|
|||
|
|
@ -51,32 +51,37 @@ defmodule ToweropsWeb.SiteLive.Index do
|
|||
assign(socket, :page_title, t("Sites"))
|
||||
end
|
||||
|
||||
defp format_qoe(nil), do: "—"
|
||||
defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1)
|
||||
@doc false
|
||||
def format_qoe(nil), do: "—"
|
||||
def format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1)
|
||||
|
||||
defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
|
||||
defp qoe_color(score) when score < 2.0, do: "text-red-600 dark:text-red-400"
|
||||
defp qoe_color(score) when score < 4.0, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp qoe_color(_), do: "text-green-600 dark:text-green-400"
|
||||
@doc false
|
||||
def qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
|
||||
def qoe_color(score) when score < 2.0, do: "text-red-600 dark:text-red-400"
|
||||
def qoe_color(score) when score < 4.0, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def qoe_color(_), do: "text-green-600 dark:text-green-400"
|
||||
|
||||
defp health_dot(nil), do: "bg-gray-400"
|
||||
defp health_dot(:red), do: "bg-red-500"
|
||||
defp health_dot(:yellow), do: "bg-yellow-500"
|
||||
defp health_dot(_), do: "bg-green-500"
|
||||
@doc false
|
||||
def health_dot(nil), do: "bg-gray-400"
|
||||
def health_dot(:red), do: "bg-red-500"
|
||||
def health_dot(:yellow), do: "bg-yellow-500"
|
||||
def health_dot(_), do: "bg-green-500"
|
||||
|
||||
defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
@doc false
|
||||
def urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
def urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
|
||||
defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
def urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
|
||||
defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
def urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
|
||||
defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
@doc false
|
||||
def source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
|
||||
defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
def source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
||||
|
||||
defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
def source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
|
||||
defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
def source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -163,86 +163,96 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
|
||||
defp format_number(number), do: to_string(number)
|
||||
|
||||
defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
|
||||
defp qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
|
||||
defp qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp qoe_color(_), do: "text-red-600 dark:text-red-400"
|
||||
@doc false
|
||||
def qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
|
||||
def qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
|
||||
def qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def qoe_color(_), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
|
||||
defp qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
|
||||
@doc false
|
||||
def qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
|
||||
def qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
|
||||
|
||||
defp qoe_bg(score) when score >= 6.0,
|
||||
def qoe_bg(score) when score >= 6.0,
|
||||
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
|
||||
|
||||
defp qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
|
||||
def qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
|
||||
|
||||
defp capacity_bar_color(nil), do: "bg-gray-300"
|
||||
defp capacity_bar_color(score) when score >= 70, do: "bg-green-500"
|
||||
defp capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
|
||||
defp capacity_bar_color(_), do: "bg-red-500"
|
||||
@doc false
|
||||
def capacity_bar_color(nil), do: "bg-gray-300"
|
||||
def capacity_bar_color(score) when score >= 70, do: "bg-green-500"
|
||||
def capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
|
||||
def capacity_bar_color(_), do: "bg-red-500"
|
||||
|
||||
defp status_dot_class(:up), do: "bg-green-500"
|
||||
defp status_dot_class(:down), do: "bg-red-500"
|
||||
defp status_dot_class(_), do: "bg-gray-400"
|
||||
@doc false
|
||||
def status_dot_class(:up), do: "bg-green-500"
|
||||
def status_dot_class(:down), do: "bg-red-500"
|
||||
def status_dot_class(_), do: "bg-gray-400"
|
||||
|
||||
defp time_ago(nil), do: "—"
|
||||
@doc false
|
||||
def time_ago(nil), do: "—"
|
||||
|
||||
defp time_ago(datetime) do
|
||||
def time_ago(datetime) do
|
||||
seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
||||
|
||||
cond do
|
||||
seconds < 60 -> "#{seconds}s ago"
|
||||
seconds < 3600 -> "#{div(seconds, 60)}m ago"
|
||||
seconds < 86_400 -> "#{div(seconds, 3600)}h ago"
|
||||
true -> "#{div(seconds, 86_400)}d ago"
|
||||
end
|
||||
format_seconds_ago(seconds)
|
||||
end
|
||||
|
||||
defp format_response_time(nil), do: "—"
|
||||
defp format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms"
|
||||
defp format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms"
|
||||
defp format_response_time(_), do: "—"
|
||||
defp format_seconds_ago(seconds) when seconds < 60, do: "#{seconds}s ago"
|
||||
defp format_seconds_ago(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m ago"
|
||||
defp format_seconds_ago(seconds) when seconds < 86_400, do: "#{div(seconds, 3600)}h ago"
|
||||
defp format_seconds_ago(seconds), do: "#{div(seconds, 86_400)}d ago"
|
||||
|
||||
defp change_size_label(size) when size < 100, do: "Small"
|
||||
defp change_size_label(size) when size < 500, do: "Medium"
|
||||
defp change_size_label(_), do: "Large"
|
||||
@doc false
|
||||
def format_response_time(nil), do: "—"
|
||||
def format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms"
|
||||
def format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms"
|
||||
def format_response_time(_), do: "—"
|
||||
|
||||
defp change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
@doc false
|
||||
def change_size_label(size) when size < 100, do: "Small"
|
||||
def change_size_label(size) when size < 500, do: "Medium"
|
||||
def change_size_label(_), do: "Large"
|
||||
|
||||
defp change_size_color(size) when size < 500,
|
||||
@doc false
|
||||
def change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
|
||||
def change_size_color(size) when size < 500,
|
||||
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
|
||||
defp change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
def change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||
|
||||
defp insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
|
||||
defp insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
|
||||
@doc false
|
||||
def insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
|
||||
def insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
|
||||
def insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
|
||||
|
||||
defp insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
|
||||
defp insight_urgency_icon("warning"), do: "hero-exclamation-circle"
|
||||
defp insight_urgency_icon(_), do: "hero-information-circle"
|
||||
@doc false
|
||||
def insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
|
||||
def insight_urgency_icon("warning"), do: "hero-exclamation-circle"
|
||||
def insight_urgency_icon(_), do: "hero-information-circle"
|
||||
|
||||
defp alert_severity(:device_down), do: "critical"
|
||||
defp alert_severity(:device_up), do: "info"
|
||||
defp alert_severity(_), do: "warning"
|
||||
@doc false
|
||||
def alert_severity(:device_down), do: "critical"
|
||||
def alert_severity(:device_up), do: "info"
|
||||
def alert_severity(_), do: "warning"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000,
|
||||
do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||
@doc false
|
||||
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
|
||||
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
|
||||
def format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
|
||||
def format_capacity(bps) when is_number(bps), do: "#{bps} bps"
|
||||
def format_capacity(_), do: "-"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
|
||||
@doc false
|
||||
def utilization_bar_color(pct) when pct >= 90, do: "bg-red-500"
|
||||
def utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500"
|
||||
def utilization_bar_color(_pct), do: "bg-green-500"
|
||||
|
||||
defp format_capacity(bps) when is_number(bps), do: "#{bps} bps"
|
||||
defp format_capacity(_), do: "-"
|
||||
|
||||
defp utilization_bar_color(pct) when pct >= 90, do: "bg-red-500"
|
||||
defp utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500"
|
||||
defp utilization_bar_color(_pct), do: "bg-green-500"
|
||||
|
||||
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
@doc false
|
||||
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
||||
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
||||
|
||||
# Enqueue discovery job - safe to call in test environment
|
||||
defp enqueue_discovery(device_id) do
|
||||
|
|
|
|||
|
|
@ -61,41 +61,47 @@ defmodule ToweropsWeb.StatusPageLive do
|
|||
|
||||
# -- Helpers --
|
||||
|
||||
defp overall_color(:operational), do: "bg-green-500"
|
||||
defp overall_color(:degraded), do: "bg-yellow-500"
|
||||
defp overall_color(:partial_outage), do: "bg-orange-500"
|
||||
defp overall_color(:major_outage), do: "bg-red-500"
|
||||
defp overall_color(:maintenance), do: "bg-blue-500"
|
||||
defp overall_color(_), do: "bg-gray-500"
|
||||
@doc false
|
||||
def overall_color(:operational), do: "bg-green-500"
|
||||
def overall_color(:degraded), do: "bg-yellow-500"
|
||||
def overall_color(:partial_outage), do: "bg-orange-500"
|
||||
def overall_color(:major_outage), do: "bg-red-500"
|
||||
def overall_color(:maintenance), do: "bg-blue-500"
|
||||
def overall_color(_), do: "bg-gray-500"
|
||||
|
||||
defp overall_text(:operational), do: "All Systems Operational"
|
||||
defp overall_text(:degraded), do: "Degraded Performance"
|
||||
defp overall_text(:partial_outage), do: "Partial System Outage"
|
||||
defp overall_text(:major_outage), do: "Major System Outage"
|
||||
defp overall_text(:maintenance), do: "Scheduled Maintenance"
|
||||
defp overall_text(_), do: "Unknown"
|
||||
@doc false
|
||||
def overall_text(:operational), do: "All Systems Operational"
|
||||
def overall_text(:degraded), do: "Degraded Performance"
|
||||
def overall_text(:partial_outage), do: "Partial System Outage"
|
||||
def overall_text(:major_outage), do: "Major System Outage"
|
||||
def overall_text(:maintenance), do: "Scheduled Maintenance"
|
||||
def overall_text(_), do: "Unknown"
|
||||
|
||||
defp component_color("operational"), do: "bg-green-500"
|
||||
defp component_color("degraded"), do: "bg-yellow-500"
|
||||
defp component_color("partial_outage"), do: "bg-orange-500"
|
||||
defp component_color("major_outage"), do: "bg-red-500"
|
||||
defp component_color("maintenance"), do: "bg-blue-500"
|
||||
defp component_color(_), do: "bg-gray-400"
|
||||
@doc false
|
||||
def component_color("operational"), do: "bg-green-500"
|
||||
def component_color("degraded"), do: "bg-yellow-500"
|
||||
def component_color("partial_outage"), do: "bg-orange-500"
|
||||
def component_color("major_outage"), do: "bg-red-500"
|
||||
def component_color("maintenance"), do: "bg-blue-500"
|
||||
def component_color(_), do: "bg-gray-400"
|
||||
|
||||
defp component_label("operational"), do: "Operational"
|
||||
defp component_label("degraded"), do: "Degraded"
|
||||
defp component_label("partial_outage"), do: "Partial Outage"
|
||||
defp component_label("major_outage"), do: "Major Outage"
|
||||
defp component_label("maintenance"), do: "Maintenance"
|
||||
defp component_label(s), do: s
|
||||
@doc false
|
||||
def component_label("operational"), do: "Operational"
|
||||
def component_label("degraded"), do: "Degraded"
|
||||
def component_label("partial_outage"), do: "Partial Outage"
|
||||
def component_label("major_outage"), do: "Major Outage"
|
||||
def component_label("maintenance"), do: "Maintenance"
|
||||
def component_label(s), do: s
|
||||
|
||||
defp severity_color("critical"), do: "text-red-600"
|
||||
defp severity_color("major"), do: "text-orange-600"
|
||||
defp severity_color(_), do: "text-yellow-600"
|
||||
@doc false
|
||||
def severity_color("critical"), do: "text-red-600"
|
||||
def severity_color("major"), do: "text-orange-600"
|
||||
def severity_color(_), do: "text-yellow-600"
|
||||
|
||||
defp incident_status_label("investigating"), do: "Investigating"
|
||||
defp incident_status_label("identified"), do: "Identified"
|
||||
defp incident_status_label("monitoring"), do: "Monitoring"
|
||||
defp incident_status_label("resolved"), do: "Resolved"
|
||||
defp incident_status_label(s), do: s
|
||||
@doc false
|
||||
def incident_status_label("investigating"), do: "Investigating"
|
||||
def incident_status_label("identified"), do: "Identified"
|
||||
def incident_status_label("monitoring"), do: "Monitoring"
|
||||
def incident_status_label("resolved"), do: "Resolved"
|
||||
def incident_status_label(s), do: s
|
||||
end
|
||||
|
|
|
|||
|
|
@ -138,116 +138,134 @@ defmodule ToweropsWeb.TraceLive.Index do
|
|||
|> push_patch(to: ~p"/trace")}
|
||||
end
|
||||
|
||||
defp type_badge_class(:account), do: "badge-primary"
|
||||
defp type_badge_class(:inventory_item), do: "badge-secondary"
|
||||
defp type_badge_class(:site), do: "badge-warning"
|
||||
defp type_badge_class(:device), do: "badge-accent"
|
||||
defp type_badge_class(:access_point), do: "badge-info"
|
||||
defp type_badge_class(_), do: "badge-ghost"
|
||||
def type_badge_class(:account), do: "badge-primary"
|
||||
def type_badge_class(:inventory_item), do: "badge-secondary"
|
||||
def type_badge_class(:site), do: "badge-warning"
|
||||
def type_badge_class(:device), do: "badge-accent"
|
||||
def type_badge_class(:access_point), do: "badge-info"
|
||||
def type_badge_class(_), do: "badge-ghost"
|
||||
|
||||
defp type_badge_label(:account), do: "Account"
|
||||
defp type_badge_label(:inventory_item), do: "Inventory"
|
||||
defp type_badge_label(:site), do: "Site"
|
||||
defp type_badge_label(:device), do: "Device"
|
||||
defp type_badge_label(:access_point), do: "AP"
|
||||
defp type_badge_label(_), do: "Unknown"
|
||||
def type_badge_label(:account), do: "Account"
|
||||
def type_badge_label(:inventory_item), do: "Inventory"
|
||||
def type_badge_label(:site), do: "Site"
|
||||
def type_badge_label(:device), do: "Device"
|
||||
def type_badge_label(:access_point), do: "AP"
|
||||
def type_badge_label(_), do: "Unknown"
|
||||
|
||||
defp status_badge_class("active"), do: "badge-success"
|
||||
defp status_badge_class("suspended"), do: "badge-warning"
|
||||
defp status_badge_class("cancelled"), do: "badge-error"
|
||||
defp status_badge_class(_), do: "badge-ghost"
|
||||
@doc false
|
||||
def status_badge_class("active"), do: "badge-success"
|
||||
def status_badge_class("suspended"), do: "badge-warning"
|
||||
def status_badge_class("cancelled"), do: "badge-error"
|
||||
def status_badge_class(_), do: "badge-ghost"
|
||||
|
||||
defp device_status_color(:up), do: "text-green-600 dark:text-green-400"
|
||||
defp device_status_color(:down), do: "text-red-600 dark:text-red-400"
|
||||
defp device_status_color(_), do: "text-gray-500 dark:text-gray-400"
|
||||
@doc false
|
||||
def device_status_color(:up), do: "text-green-600 dark:text-green-400"
|
||||
def device_status_color(:down), do: "text-red-600 dark:text-red-400"
|
||||
def device_status_color(_), do: "text-gray-500 dark:text-gray-400"
|
||||
|
||||
defp format_speed(nil), do: "—"
|
||||
defp format_speed(speed) when is_number(speed) and speed >= 1000, do: "#{Float.round(speed / 1000, 1)} Gbps"
|
||||
defp format_speed(speed) when is_number(speed), do: "#{speed} Mbps"
|
||||
defp format_speed(speed), do: "#{speed}"
|
||||
@doc false
|
||||
def format_speed(nil), do: "—"
|
||||
def format_speed(speed) when is_number(speed) and speed >= 1000, do: "#{Float.round(speed / 1000, 1)} Gbps"
|
||||
def format_speed(speed) when is_number(speed), do: "#{speed} Mbps"
|
||||
def format_speed(speed), do: "#{speed}"
|
||||
|
||||
defp format_relative_time(nil), do: "—"
|
||||
@doc false
|
||||
def format_relative_time(nil), do: "—"
|
||||
|
||||
defp format_relative_time(dt) do
|
||||
def format_relative_time(dt) do
|
||||
diff = DateTime.diff(DateTime.utc_now(), dt, :second)
|
||||
|
||||
cond do
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> "#{div(diff, 60)}m ago"
|
||||
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
||||
true -> "#{div(diff, 86_400)}d ago"
|
||||
end
|
||||
format_relative_diff(diff)
|
||||
end
|
||||
|
||||
defp format_score(nil), do: "—"
|
||||
defp format_score(score) when is_number(score), do: "#{Float.round(score * 1.0, 1)}"
|
||||
defp format_score(score), do: "#{score}"
|
||||
defp format_relative_diff(diff) when diff < 60, do: "just now"
|
||||
defp format_relative_diff(diff) when diff < 3600, do: "#{div(diff, 60)}m ago"
|
||||
defp format_relative_diff(diff) when diff < 86_400, do: "#{div(diff, 3600)}h ago"
|
||||
defp format_relative_diff(diff), do: "#{div(diff, 86_400)}d ago"
|
||||
|
||||
defp format_pct(nil), do: "—"
|
||||
defp format_pct(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)}%"
|
||||
defp format_pct(val), do: "#{val}"
|
||||
@doc false
|
||||
def format_score(nil), do: "—"
|
||||
def format_score(score) when is_number(score), do: "#{Float.round(score * 1.0, 1)}"
|
||||
def format_score(score), do: "#{score}"
|
||||
|
||||
defp format_ms(nil), do: "—"
|
||||
defp format_ms(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} ms"
|
||||
defp format_ms(val), do: "#{val}"
|
||||
@doc false
|
||||
def format_pct(nil), do: "—"
|
||||
def format_pct(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)}%"
|
||||
def format_pct(val), do: "#{val}"
|
||||
|
||||
defp format_throughput(nil), do: "—"
|
||||
defp format_throughput(val) when is_number(val) and val >= 1000, do: "#{Float.round(val / 1000, 1)} Gbps"
|
||||
defp format_throughput(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} Mbps"
|
||||
defp format_throughput(val), do: "#{val}"
|
||||
@doc false
|
||||
def format_ms(nil), do: "—"
|
||||
def format_ms(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} ms"
|
||||
def format_ms(val), do: "#{val}"
|
||||
|
||||
defp format_alert_type(nil), do: "—"
|
||||
defp format_alert_type(type) when is_binary(type), do: type |> String.replace("_", " ") |> String.capitalize()
|
||||
defp format_alert_type(type), do: "#{type}"
|
||||
@doc false
|
||||
def format_throughput(nil), do: "—"
|
||||
def format_throughput(val) when is_number(val) and val >= 1000, do: "#{Float.round(val / 1000, 1)} Gbps"
|
||||
def format_throughput(val) when is_number(val), do: "#{Float.round(val * 1.0, 1)} Mbps"
|
||||
def format_throughput(val), do: "#{val}"
|
||||
|
||||
defp score_quality(nil), do: :unknown
|
||||
defp score_quality(s) when s >= 80, do: :good
|
||||
defp score_quality(s) when s >= 50, do: :warning
|
||||
defp score_quality(_), do: :bad
|
||||
@doc false
|
||||
def format_alert_type(nil), do: "—"
|
||||
def format_alert_type(type) when is_binary(type), do: type |> String.replace("_", " ") |> String.capitalize()
|
||||
def format_alert_type(type), do: "#{type}"
|
||||
|
||||
defp score_color(nil), do: "text-gray-400"
|
||||
defp score_color(s) when s >= 80, do: "text-green-600 dark:text-green-400"
|
||||
defp score_color(s) when s >= 50, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp score_color(_), do: "text-red-600 dark:text-red-400"
|
||||
@doc false
|
||||
def score_quality(nil), do: :unknown
|
||||
def score_quality(s) when s >= 80, do: :good
|
||||
def score_quality(s) when s >= 50, do: :warning
|
||||
def score_quality(_), do: :bad
|
||||
|
||||
defp latency_color(nil), do: "text-gray-400"
|
||||
defp latency_color(v) when v <= 30, do: "text-green-600 dark:text-green-400"
|
||||
defp latency_color(v) when v <= 80, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp latency_color(_), do: "text-red-600 dark:text-red-400"
|
||||
@doc false
|
||||
def score_color(nil), do: "text-gray-400"
|
||||
def score_color(s) when s >= 80, do: "text-green-600 dark:text-green-400"
|
||||
def score_color(s) when s >= 50, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def score_color(_), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp jitter_color(nil), do: "text-gray-400"
|
||||
defp jitter_color(v) when v <= 10, do: "text-green-600 dark:text-green-400"
|
||||
defp jitter_color(v) when v <= 30, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp jitter_color(_), do: "text-red-600 dark:text-red-400"
|
||||
@doc false
|
||||
def latency_color(nil), do: "text-gray-400"
|
||||
def latency_color(v) when v <= 30, do: "text-green-600 dark:text-green-400"
|
||||
def latency_color(v) when v <= 80, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def latency_color(_), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp loss_color(nil), do: "text-gray-400"
|
||||
defp loss_color(v) when v <= 1, do: "text-green-600 dark:text-green-400"
|
||||
defp loss_color(v) when v <= 5, do: "text-yellow-600 dark:text-yellow-400"
|
||||
defp loss_color(_), do: "text-red-600 dark:text-red-400"
|
||||
@doc false
|
||||
def jitter_color(nil), do: "text-gray-400"
|
||||
def jitter_color(v) when v <= 10, do: "text-green-600 dark:text-green-400"
|
||||
def jitter_color(v) when v <= 30, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def jitter_color(_), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp device_status_dot(:up), do: "bg-green-500"
|
||||
defp device_status_dot(:down), do: "bg-red-500"
|
||||
defp device_status_dot(_), do: "bg-gray-400"
|
||||
@doc false
|
||||
def loss_color(nil), do: "text-gray-400"
|
||||
def loss_color(v) when v <= 1, do: "text-green-600 dark:text-green-400"
|
||||
def loss_color(v) when v <= 5, do: "text-yellow-600 dark:text-yellow-400"
|
||||
def loss_color(_), do: "text-red-600 dark:text-red-400"
|
||||
|
||||
defp alert_dot(%{severity: "critical"}), do: "bg-red-500"
|
||||
defp alert_dot(%{severity: "warning"}), do: "bg-yellow-500"
|
||||
defp alert_dot(%{alert_type: "down"}), do: "bg-red-500"
|
||||
defp alert_dot(_), do: "bg-orange-400"
|
||||
@doc false
|
||||
def device_status_dot(:up), do: "bg-green-500"
|
||||
def device_status_dot(:down), do: "bg-red-500"
|
||||
def device_status_dot(_), do: "bg-gray-400"
|
||||
|
||||
defp urgency_dot("critical"), do: "bg-red-500"
|
||||
defp urgency_dot("high"), do: "bg-orange-500"
|
||||
defp urgency_dot("medium"), do: "bg-yellow-500"
|
||||
defp urgency_dot(_), do: "bg-blue-400"
|
||||
@doc false
|
||||
def alert_dot(%{severity: "critical"}), do: "bg-red-500"
|
||||
def alert_dot(%{severity: "warning"}), do: "bg-yellow-500"
|
||||
def alert_dot(%{alert_type: "down"}), do: "bg-red-500"
|
||||
def alert_dot(_), do: "bg-orange-400"
|
||||
|
||||
defp airtime_quality(nil), do: :unknown
|
||||
defp airtime_quality(v) when v <= 50, do: :good
|
||||
defp airtime_quality(v) when v <= 80, do: :warning
|
||||
defp airtime_quality(_), do: :bad
|
||||
@doc false
|
||||
def urgency_dot("critical"), do: "bg-red-500"
|
||||
def urgency_dot("high"), do: "bg-orange-500"
|
||||
def urgency_dot("medium"), do: "bg-yellow-500"
|
||||
def urgency_dot(_), do: "bg-blue-400"
|
||||
|
||||
defp safe_type("account"), do: :account
|
||||
defp safe_type("inventory_item"), do: :inventory_item
|
||||
defp safe_type("site"), do: :site
|
||||
defp safe_type("device"), do: :device
|
||||
defp safe_type("access_point"), do: :access_point
|
||||
defp safe_type(_), do: :account
|
||||
@doc false
|
||||
def airtime_quality(nil), do: :unknown
|
||||
def airtime_quality(v) when v <= 50, do: :good
|
||||
def airtime_quality(v) when v <= 80, do: :warning
|
||||
def airtime_quality(_), do: :bad
|
||||
|
||||
@doc false
|
||||
def safe_type("account"), do: :account
|
||||
def safe_type("inventory_item"), do: :inventory_item
|
||||
def safe_type("site"), do: :site
|
||||
def safe_type("device"), do: :device
|
||||
def safe_type("access_point"), do: :access_point
|
||||
def safe_type(_), do: :account
|
||||
end
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@ defmodule ToweropsWeb.UserRegistrationLive do
|
|||
end
|
||||
end
|
||||
|
||||
defp normalize_consent_params(params) do
|
||||
@doc false
|
||||
def normalize_consent_params(params) do
|
||||
params
|
||||
|> Map.update("privacy_policy_consent", nil, fn
|
||||
"on" -> true
|
||||
|
|
|
|||
16
mix.exs
16
mix.exs
|
|
@ -13,7 +13,21 @@ defmodule Towerops.MixProject do
|
|||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
prune_code_paths: false,
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
dialyzer: dialyzer()
|
||||
dialyzer: dialyzer(),
|
||||
test_coverage: [ignore_modules: ignore_modules_for_coverage()]
|
||||
]
|
||||
end
|
||||
|
||||
# Vendored SnmpKit library, generated protobuf modules, and purely
|
||||
# declarative Phoenix/Absinthe type modules are excluded from coverage
|
||||
# calculations since they are third-party, generated, or data-only code.
|
||||
defp ignore_modules_for_coverage do
|
||||
[
|
||||
~r/^SnmpKit/,
|
||||
~r/^Towerops\.Agent\./,
|
||||
~r/^Inspect\./,
|
||||
~r/^ToweropsWeb\.GraphQL\.Types\./,
|
||||
~r/HTML$/
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
136
test/mix/tasks/geoip_import_test.exs
Normal file
136
test/mix/tasks/geoip_import_test.exs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
defmodule Mix.Tasks.Geoip.ImportUnitTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mix.Tasks.Geoip.Import
|
||||
|
||||
describe "parse_geoname_id/1" do
|
||||
test "empty string returns nil" do
|
||||
assert nil == Import.parse_geoname_id("")
|
||||
end
|
||||
|
||||
test "valid integer returns the integer" do
|
||||
assert 12_345 == Import.parse_geoname_id("12345")
|
||||
end
|
||||
|
||||
test "invalid format returns nil" do
|
||||
assert nil == Import.parse_geoname_id("abc")
|
||||
end
|
||||
|
||||
test "partial integer parses leading number" do
|
||||
assert 123 == Import.parse_geoname_id("123abc")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_cidr/1" do
|
||||
test "valid IPv4 /24 CIDR" do
|
||||
assert {:ok, start_int, end_int} = Import.parse_cidr("192.168.1.0/24")
|
||||
# 192.168.1.0 = 3232235776
|
||||
assert start_int == 3_232_235_776
|
||||
# 192.168.1.255 = 3232236031
|
||||
assert end_int == 3_232_236_031
|
||||
assert end_int - start_int == 255
|
||||
end
|
||||
|
||||
test "valid /32 host address" do
|
||||
assert {:ok, same, same} = Import.parse_cidr("10.0.0.1/32")
|
||||
assert same == 10 * 16_777_216 + 1
|
||||
end
|
||||
|
||||
test "valid /0 full range" do
|
||||
assert {:ok, 0, end_int} = Import.parse_cidr("0.0.0.0/0")
|
||||
assert end_int == 4_294_967_295
|
||||
end
|
||||
|
||||
test "invalid CIDR returns :error" do
|
||||
assert :error == Import.parse_cidr("not a cidr")
|
||||
assert :error == Import.parse_cidr("192.168.1.0")
|
||||
assert :error == Import.parse_cidr("256.256.256.256/24")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_block_line/1" do
|
||||
test "well-formed line with geoname_id" do
|
||||
line = "192.168.1.0/24,12345,67890,,,,0,1,,\n"
|
||||
result = Import.parse_block_line(line)
|
||||
|
||||
assert result.network == "192.168.1.0/24"
|
||||
assert result.geoname_id == 12_345
|
||||
assert result.registered_country_geoname_id == 67_890
|
||||
end
|
||||
|
||||
test "empty geoname_id falls back to registered" do
|
||||
line = "192.168.1.0/24,,67890,,,,,,,\n"
|
||||
result = Import.parse_block_line(line)
|
||||
|
||||
assert result.geoname_id == 67_890
|
||||
assert result.registered_country_geoname_id == 67_890
|
||||
end
|
||||
|
||||
test "both empty returns nil" do
|
||||
line = "192.168.1.0/24,,,,,,,,,\n"
|
||||
assert nil == Import.parse_block_line(line)
|
||||
end
|
||||
|
||||
test "malformed line returns nil" do
|
||||
assert nil == Import.parse_block_line("no commas\n")
|
||||
end
|
||||
|
||||
test "invalid CIDR returns nil" do
|
||||
line = "not-a-cidr,12345,67890,,\n"
|
||||
assert nil == Import.parse_block_line(line)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_location_line/1" do
|
||||
test "parses a standard location row" do
|
||||
line = "5332921,en,NA,North America,US,United States,CA,California,,,San Jose,,America/Los_Angeles,false"
|
||||
result = Import.parse_location_line(line)
|
||||
|
||||
assert result.geoname_id == 5_332_921
|
||||
assert result.country_code == "US"
|
||||
assert result.country_name == "United States"
|
||||
assert result.city_name == "San Jose"
|
||||
assert result.subdivision_1_name == "California"
|
||||
end
|
||||
|
||||
test "empty city becomes nil" do
|
||||
line = "5332921,en,NA,North America,US,United States,CA,California,,,,,,false"
|
||||
result = Import.parse_location_line(line)
|
||||
|
||||
assert result.city_name == nil
|
||||
end
|
||||
|
||||
test "missing country_code skips the row" do
|
||||
line = "5332921,en,NA,,,United States,CA,California,,,San Jose,,tz,false"
|
||||
assert nil == Import.parse_location_line(line)
|
||||
end
|
||||
|
||||
test "missing geoname_id skips the row" do
|
||||
line = ",en,NA,North America,US,United States,CA,California,,,San Jose,,tz,false"
|
||||
assert nil == Import.parse_location_line(line)
|
||||
end
|
||||
|
||||
test "malformed short line returns nil" do
|
||||
assert nil == Import.parse_location_line("too,few,fields")
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_block_entry/3" do
|
||||
test "nil geoname_id returns nil" do
|
||||
assert nil == Import.build_block_entry("192.168.1.0/24", nil, 67_890)
|
||||
end
|
||||
|
||||
test "invalid CIDR returns nil" do
|
||||
assert nil == Import.build_block_entry("garbage", 12_345, 67_890)
|
||||
end
|
||||
|
||||
test "valid inputs produce a complete entry" do
|
||||
entry = Import.build_block_entry("10.0.0.0/8", 12_345, 67_890)
|
||||
|
||||
assert entry.network == "10.0.0.0/8"
|
||||
assert entry.geoname_id == 12_345
|
||||
assert entry.registered_country_geoname_id == 67_890
|
||||
assert entry.start_ip_int < entry.end_ip_int
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
defmodule Towerops.Monitoring.PingStub do
|
||||
@moduledoc """
|
||||
Stub implementation of PingBehaviour for testing.
|
||||
Always returns successful ping responses.
|
||||
"""
|
||||
|
||||
@behaviour Towerops.Monitoring.PingBehaviour
|
||||
|
||||
@impl true
|
||||
def ping(_ip_address) do
|
||||
{:ok, 10}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ping(_ip_address, _timeout) do
|
||||
{:ok, 10}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
defmodule Towerops.Mocks.SnmpKitMock do
|
||||
@moduledoc """
|
||||
Mock for SnmpKit to be used in tests.
|
||||
"""
|
||||
|
||||
def get(_target, _oid, _opts) do
|
||||
{:ok, {:octet_string, "Mock Value"}}
|
||||
end
|
||||
|
||||
def walk(_target, _oid, _opts) do
|
||||
# Return empty list to simulate no results
|
||||
{:ok, []}
|
||||
end
|
||||
|
||||
def get_bulk(_target, _oid, _opts) do
|
||||
{:ok, []}
|
||||
end
|
||||
|
||||
def get_multiple(_target, oids, _opts) do
|
||||
# Return map of OID to mock typed values
|
||||
result_map =
|
||||
Map.new(oids, fn oid ->
|
||||
{oid, {:octet_string, "Mock Value"}}
|
||||
end)
|
||||
|
||||
{:ok, result_map}
|
||||
end
|
||||
end
|
||||
|
|
@ -39,4 +39,40 @@ defmodule Towerops.Accounts.HIBPTest do
|
|||
assert {:ok, 0} = HIBP.check_password(unique)
|
||||
end
|
||||
end
|
||||
|
||||
describe "hash_password/1" do
|
||||
test "SHA-1 uppercase hex for 'password'" do
|
||||
assert "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" == HIBP.hash_password("password")
|
||||
end
|
||||
|
||||
test "different passwords produce different hashes" do
|
||||
assert HIBP.hash_password("a") != HIBP.hash_password("b")
|
||||
end
|
||||
|
||||
test "empty string has known SHA-1 hash" do
|
||||
assert "DA39A3EE5E6B4B0D3255BFEF95601890AFD80709" == HIBP.hash_password("")
|
||||
end
|
||||
end
|
||||
|
||||
describe "check_suffix/2" do
|
||||
test "finds matching suffix and parses count" do
|
||||
body = "ABCD:100\r\nEFGH:5\r\n"
|
||||
assert {:ok, 100} == HIBP.check_suffix(body, "ABCD")
|
||||
assert {:ok, 5} == HIBP.check_suffix(body, "EFGH")
|
||||
end
|
||||
|
||||
test "returns {:ok, 0} when no match" do
|
||||
body = "FOUND:10\r\n"
|
||||
assert {:ok, 0} == HIBP.check_suffix(body, "MISS")
|
||||
end
|
||||
|
||||
test "handles empty response" do
|
||||
assert {:ok, 0} == HIBP.check_suffix("", "any")
|
||||
end
|
||||
|
||||
test "skips malformed lines" do
|
||||
body = "GOODLINE:5\r\nbad_no_colon\r\nALSO:GOOD:7\r\n"
|
||||
assert {:ok, 5} == HIBP.check_suffix(body, "GOODLINE")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
76
test/towerops/agent/enum_types_test.exs
Normal file
76
test/towerops/agent/enum_types_test.exs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
defmodule Towerops.Agent.EnumTypesTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Agent.JobType
|
||||
alias Towerops.Agent.QueryType
|
||||
|
||||
describe "JobType.value/1" do
|
||||
test "maps each known atom to expected integer" do
|
||||
assert JobType.value(:DISCOVER) == 0
|
||||
assert JobType.value(:POLL) == 1
|
||||
assert JobType.value(:MIKROTIK) == 2
|
||||
assert JobType.value(:TEST_CREDENTIALS) == 3
|
||||
assert JobType.value(:PING) == 4
|
||||
assert JobType.value(:LLDP_TOPOLOGY) == 5
|
||||
end
|
||||
|
||||
test "raises for unknown atom" do
|
||||
assert_raise KeyError, fn -> JobType.value(:UNKNOWN) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "JobType.key/1" do
|
||||
test "maps each known integer back to atom" do
|
||||
assert JobType.key(0) == :DISCOVER
|
||||
assert JobType.key(1) == :POLL
|
||||
assert JobType.key(2) == :MIKROTIK
|
||||
assert JobType.key(3) == :TEST_CREDENTIALS
|
||||
assert JobType.key(4) == :PING
|
||||
assert JobType.key(5) == :LLDP_TOPOLOGY
|
||||
end
|
||||
end
|
||||
|
||||
describe "JobType roundtrip" do
|
||||
property "value/1 and key/1 are inverses for all known atoms" do
|
||||
check all(
|
||||
atom <-
|
||||
member_of([:DISCOVER, :POLL, :MIKROTIK, :TEST_CREDENTIALS, :PING, :LLDP_TOPOLOGY])
|
||||
) do
|
||||
assert atom |> JobType.value() |> JobType.key() == atom
|
||||
end
|
||||
end
|
||||
|
||||
property "key/1 and value/1 are inverses for all known integers" do
|
||||
check all(int <- 0..5 |> Enum.to_list() |> member_of()) do
|
||||
assert int |> JobType.key() |> JobType.value() == int
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "QueryType.value/1" do
|
||||
test "maps GET and WALK" do
|
||||
assert QueryType.value(:GET) == 0
|
||||
assert QueryType.value(:WALK) == 1
|
||||
end
|
||||
|
||||
test "raises for unknown atom" do
|
||||
assert_raise KeyError, fn -> QueryType.value(:SET) end
|
||||
end
|
||||
end
|
||||
|
||||
describe "QueryType.key/1" do
|
||||
test "maps 0 and 1" do
|
||||
assert QueryType.key(0) == :GET
|
||||
assert QueryType.key(1) == :WALK
|
||||
end
|
||||
end
|
||||
|
||||
describe "QueryType roundtrip" do
|
||||
property "value and key are inverses" do
|
||||
check all(atom <- member_of([:GET, :WALK])) do
|
||||
assert atom |> QueryType.value() |> QueryType.key() == atom
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
43
test/towerops/agent/interface_test.exs
Normal file
43
test/towerops/agent/interface_test.exs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
defmodule Towerops.Agent.InterfaceTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Agent.Interface
|
||||
|
||||
describe "struct defaults" do
|
||||
test "defaults to empty id and name, zero if_index" do
|
||||
assert %Interface{id: "", if_index: 0, if_name: ""} = %Interface{}
|
||||
end
|
||||
|
||||
test "accepts explicit field values" do
|
||||
iface = %Interface{id: "abc-uuid", if_index: 42, if_name: "ether1"}
|
||||
assert iface.id == "abc-uuid"
|
||||
assert iface.if_index == 42
|
||||
assert iface.if_name == "ether1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "encode/1" do
|
||||
test "produces a binary" do
|
||||
iface = %Interface{id: "abc", if_index: 1, if_name: "eth0"}
|
||||
assert is_binary(Interface.encode(iface))
|
||||
end
|
||||
|
||||
test "default struct encodes without error" do
|
||||
assert is_binary(Interface.encode(%Interface{}))
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: encode/1 always returns binary" do
|
||||
property "random interface structs encode cleanly" do
|
||||
check all(
|
||||
id <- string(:alphanumeric, max_length: 36),
|
||||
if_index <- integer(0..2_147_483_647),
|
||||
if_name <- string(:printable, max_length: 64)
|
||||
) do
|
||||
iface = %Interface{id: id, if_index: if_index, if_name: if_name}
|
||||
assert is_binary(Interface.encode(iface))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
93
test/towerops/alerts/alert_storm_test.exs
Normal file
93
test/towerops/alerts/alert_storm_test.exs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule Towerops.Alerts.AlertStormTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.Alerts.AlertStorm
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
started_at: ~U[2026-01-15 10:00:00Z]
|
||||
}
|
||||
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert changeset.changes.organization_id == attrs.organization_id
|
||||
assert changeset.changes.started_at == attrs.started_at
|
||||
end
|
||||
|
||||
test "valid with all fields" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
started_at: ~U[2026-01-15 10:00:00Z],
|
||||
ended_at: ~U[2026-01-15 10:30:00Z],
|
||||
alert_count: 42,
|
||||
suppressed_count: 40,
|
||||
summary_notification_sent: true
|
||||
}
|
||||
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
assert changeset.changes.alert_count == 42
|
||||
assert changeset.changes.suppressed_count == 40
|
||||
assert changeset.changes.summary_notification_sent == true
|
||||
assert changeset.changes.ended_at == ~U[2026-01-15 10:30:00Z]
|
||||
end
|
||||
|
||||
test "invalid without organization_id" do
|
||||
attrs = %{started_at: ~U[2026-01-15 10:00:00Z]}
|
||||
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{organization_id: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "invalid without started_at" do
|
||||
attrs = %{organization_id: Ecto.UUID.generate()}
|
||||
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, attrs)
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{started_at: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "invalid with both organization_id and started_at missing" do
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, %{})
|
||||
|
||||
refute changeset.valid?
|
||||
errors = errors_on(changeset)
|
||||
assert errors[:organization_id] == ["can't be blank"]
|
||||
assert errors[:started_at] == ["can't be blank"]
|
||||
end
|
||||
|
||||
test "ignores unknown fields" do
|
||||
attrs = %{
|
||||
organization_id: Ecto.UUID.generate(),
|
||||
started_at: ~U[2026-01-15 10:00:00Z],
|
||||
unknown_field: "ignored"
|
||||
}
|
||||
|
||||
changeset = AlertStorm.changeset(%AlertStorm{}, attrs)
|
||||
|
||||
assert changeset.valid?
|
||||
refute Map.has_key?(changeset.changes, :unknown_field)
|
||||
end
|
||||
end
|
||||
|
||||
describe "schema defaults" do
|
||||
test "alert_count defaults to 0" do
|
||||
assert %AlertStorm{alert_count: 0} = %AlertStorm{}
|
||||
end
|
||||
|
||||
test "suppressed_count defaults to 0" do
|
||||
assert %AlertStorm{suppressed_count: 0} = %AlertStorm{}
|
||||
end
|
||||
|
||||
test "summary_notification_sent defaults to false" do
|
||||
assert %AlertStorm{summary_notification_sent: false} = %AlertStorm{}
|
||||
end
|
||||
end
|
||||
end
|
||||
68
test/towerops/alerts/storm_detector_test.exs
Normal file
68
test/towerops/alerts/storm_detector_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
defmodule Towerops.Alerts.StormDetectorTest do
|
||||
@moduledoc """
|
||||
Tests for pure helpers. The buffered alert-creation paths require a running
|
||||
GenServer and real alert/device fixtures — covered at the integration level.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Alerts.StormDetector
|
||||
|
||||
defp queue_of(list), do: Enum.reduce(list, :queue.new(), fn x, q -> :queue.in(x, q) end)
|
||||
|
||||
describe "prune_old_timestamps/2" do
|
||||
test "removes timestamps older than cutoff" do
|
||||
pruned = StormDetector.prune_old_timestamps(queue_of([100, 200, 300]), 250)
|
||||
assert :queue.to_list(pruned) == [300]
|
||||
end
|
||||
|
||||
test "returns queue unchanged when all timestamps are recent" do
|
||||
pruned = StormDetector.prune_old_timestamps(queue_of([500, 600]), 100)
|
||||
assert :queue.to_list(pruned) == [500, 600]
|
||||
end
|
||||
|
||||
test "returns empty queue when all expire" do
|
||||
pruned = StormDetector.prune_old_timestamps(queue_of([10, 20]), 1_000)
|
||||
assert :queue.is_empty(pruned)
|
||||
end
|
||||
|
||||
test "empty queue stays empty" do
|
||||
assert :queue.is_empty(StormDetector.prune_old_timestamps(:queue.new(), 100))
|
||||
end
|
||||
|
||||
property "pruned queue never grows larger than input" do
|
||||
check all(
|
||||
items <- list_of(integer(0..1000), max_length: 30),
|
||||
cutoff <- integer(0..1000)
|
||||
) do
|
||||
q = queue_of(items)
|
||||
pruned = StormDetector.prune_old_timestamps(q, cutoff)
|
||||
assert :queue.len(pruned) <= :queue.len(q)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "limit_queue_size/2" do
|
||||
test "trims oldest items to fit max_size" do
|
||||
trimmed = StormDetector.limit_queue_size(queue_of(Enum.to_list(1..10)), 3)
|
||||
assert :queue.to_list(trimmed) == [8, 9, 10]
|
||||
end
|
||||
|
||||
test "returns queue unchanged when under limit" do
|
||||
assert :queue.to_list(StormDetector.limit_queue_size(queue_of([1, 2]), 5)) == [1, 2]
|
||||
end
|
||||
|
||||
test "exact size is unchanged" do
|
||||
assert :queue.to_list(StormDetector.limit_queue_size(queue_of([1, 2]), 2)) == [1, 2]
|
||||
end
|
||||
|
||||
property "result never exceeds max_size" do
|
||||
check all(
|
||||
items <- list_of(integer(), max_length: 50),
|
||||
max <- integer(0..30)
|
||||
) do
|
||||
assert :queue.len(StormDetector.limit_queue_size(queue_of(items), max)) <= max
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -93,4 +93,167 @@ defmodule Towerops.CnMaestro.ClientTest do
|
|||
assert length(perf) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_token/3 — error paths" do
|
||||
test "unauthorized token response" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "invalid_client"})
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Client.get_token("http://test", "bad", "bad")
|
||||
end
|
||||
|
||||
test "other status returns {:token_failed, status}" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, {:token_failed, 500}} = Client.get_token("http://test", "id", "secret")
|
||||
end
|
||||
|
||||
test "200 but missing access_token in JSON body" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{"something" => "else"})
|
||||
end)
|
||||
|
||||
assert {:error, {:token_failed, 200}} = Client.get_token("http://test", "id", "secret")
|
||||
end
|
||||
|
||||
test "accepts body as raw JSON string with access_token" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/plain")
|
||||
|> Plug.Conn.send_resp(200, ~s({"access_token":"raw-token"}))
|
||||
end)
|
||||
|
||||
assert {:ok, "raw-token"} = Client.get_token("http://test", "id", "secret")
|
||||
end
|
||||
|
||||
test "raw string body without access_token returns :invalid_token_response" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/plain")
|
||||
|> Plug.Conn.send_resp(200, ~s({"other":"thing"}))
|
||||
end)
|
||||
|
||||
assert {:error, :invalid_token_response} = Client.get_token("http://test", "id", "secret")
|
||||
end
|
||||
end
|
||||
|
||||
describe "test_connection/3 — error paths" do
|
||||
test "returns error when token fetch fails" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
case conn.request_path do
|
||||
"/api/v1/access/token" ->
|
||||
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{})
|
||||
|
||||
_ ->
|
||||
Req.Test.json(conn, %{})
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Client.test_connection("http://test", "x", "y")
|
||||
end
|
||||
|
||||
test "returns {:unexpected_status, code} when device fetch fails" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
case {conn.method, conn.request_path} do
|
||||
{"POST", "/api/v1/access/token"} ->
|
||||
Req.Test.json(conn, %{"access_token" => "tok"})
|
||||
|
||||
{"GET", "/api/v1/devices"} ->
|
||||
conn |> Plug.Conn.put_status(503) |> Req.Test.json(%{})
|
||||
|
||||
_ ->
|
||||
Plug.Conn.send_resp(conn, 404, "")
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:error, {:unexpected_status, 503}} = Client.test_connection("http://test", "x", "y")
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_devices/2 — error paths" do
|
||||
test "returns unexpected_status on non-2xx" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, {:unexpected_status, 500}} = Client.list_devices("http://test", "tok")
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_networks/2 — error paths" do
|
||||
test "returns unexpected_status on non-2xx" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(502) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, {:unexpected_status, 502}} = Client.list_networks("http://test", "tok")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_device_statistics/3 — error paths" do
|
||||
test "404 returns :not_found" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, :not_found} = Client.get_device_statistics("http://test", "tok", "AA:BB")
|
||||
end
|
||||
|
||||
test "other status returns unexpected_status" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, {:unexpected_status, 500}} =
|
||||
Client.get_device_statistics("http://test", "tok", "AA:BB")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_device_performance/3 — error paths" do
|
||||
test "404 returns :not_found" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, :not_found} = Client.get_device_performance("http://test", "tok", "AA:BB")
|
||||
end
|
||||
|
||||
test "other status returns unexpected_status" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, {:unexpected_status, 500}} =
|
||||
Client.get_device_performance("http://test", "tok", "AA:BB")
|
||||
end
|
||||
end
|
||||
|
||||
describe "response body normalization (extract_data)" do
|
||||
test "handles body with no data wrapper (direct list)" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, [%{"id" => 1}, %{"id" => 2}])
|
||||
end)
|
||||
|
||||
assert {:ok, [%{"id" => 1}, %{"id" => 2}]} = Client.list_devices("http://test", "tok")
|
||||
end
|
||||
|
||||
test "handles non-list data wrapped in {data: map}" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{"data" => %{"id" => 1}})
|
||||
end)
|
||||
|
||||
assert {:ok, [%{"id" => 1}]} = Client.list_devices("http://test", "tok")
|
||||
end
|
||||
|
||||
test "returns empty list for unknown body shape" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{"unexpected" => "shape"})
|
||||
end)
|
||||
|
||||
assert {:ok, []} = Client.list_devices("http://test", "tok")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
56
test/towerops/cn_maestro/sync_test.exs
Normal file
56
test/towerops/cn_maestro/sync_test.exs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
defmodule Towerops.CnMaestro.SyncTest do
|
||||
@moduledoc """
|
||||
Tests for process_network/3 — covers both the pure skip-dispatch and the
|
||||
DB-backed upsert_site path.
|
||||
"""
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.CnMaestro.Sync
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
describe "process_network/3 — skip paths (no DB)" do
|
||||
test "skips network with nil name" do
|
||||
state = {5, %{}}
|
||||
assert state == Sync.process_network(%{"name" => nil}, Ecto.UUID.generate(), state)
|
||||
end
|
||||
|
||||
test "skips network with empty name" do
|
||||
state = {0, %{}}
|
||||
assert state == Sync.process_network(%{"name" => ""}, Ecto.UUID.generate(), state)
|
||||
end
|
||||
end
|
||||
|
||||
describe "process_network/3 — DB upsert" do
|
||||
setup do
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
{:ok, org} = Towerops.Organizations.create_organization(%{name: "CN Org"}, user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
test "creates site and indexes by network id", %{org: org} do
|
||||
{count, map} =
|
||||
Sync.process_network(
|
||||
%{"name" => "Region-A", "id" => "net-a"},
|
||||
org.id,
|
||||
{0, %{}}
|
||||
)
|
||||
|
||||
assert count == 1
|
||||
assert %Site{name: "Region-A"} = map["net-a"]
|
||||
end
|
||||
|
||||
test "uses name as key when id missing", %{org: org} do
|
||||
{count, map} =
|
||||
Sync.process_network(%{"name" => "Region-B"}, org.id, {0, %{}})
|
||||
|
||||
assert count == 1
|
||||
assert %Site{name: "Region-B"} = map["Region-B"]
|
||||
end
|
||||
|
||||
test "upsert returns the same site for duplicate name", %{org: org} do
|
||||
{1, map1} = Sync.process_network(%{"name" => "Dup", "id" => "k1"}, org.id, {0, %{}})
|
||||
{_, map2} = Sync.process_network(%{"name" => "Dup", "id" => "k1"}, org.id, {0, %{}})
|
||||
assert map1["k1"].id == map2["k1"].id
|
||||
end
|
||||
end
|
||||
end
|
||||
192
test/towerops/config_changes/correlator_test.exs
Normal file
192
test/towerops/config_changes/correlator_test.exs
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
defmodule Towerops.ConfigChanges.CorrelatorTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.ConfigChanges.Correlator
|
||||
|
||||
describe "safe_pct_change/2" do
|
||||
test "normal percentage increase" do
|
||||
assert 0.5 == Correlator.safe_pct_change(10.0, 15.0)
|
||||
assert 1.0 == Correlator.safe_pct_change(5.0, 10.0)
|
||||
end
|
||||
|
||||
test "percentage decrease is negative" do
|
||||
assert -0.5 == Correlator.safe_pct_change(10.0, 5.0)
|
||||
end
|
||||
|
||||
test "no change is 0.0" do
|
||||
assert 0.0 == Correlator.safe_pct_change(10.0, 10.0)
|
||||
end
|
||||
|
||||
test "zero old value returns 0.0 (avoids division by zero)" do
|
||||
assert 0.0 == Correlator.safe_pct_change(0.0, 100.0)
|
||||
end
|
||||
|
||||
test "negative old value returns 0.0" do
|
||||
assert 0.0 == Correlator.safe_pct_change(-1.0, 100.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "determine_severity_from_deltas/3" do
|
||||
test "critical when latency > 50%" do
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.51, 0.0, 0.0)
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(1.0, 0.0, 0.0)
|
||||
end
|
||||
|
||||
test "critical when loss > 100%" do
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 1.01, 0.0)
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 5.0, 0.0)
|
||||
end
|
||||
|
||||
test "critical when throughput drops > 30%" do
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.31)
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.8)
|
||||
end
|
||||
|
||||
test "warning when latency in (20%, 50%]" do
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.21, 0.0, 0.0)
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.50, 0.0, 0.0)
|
||||
end
|
||||
|
||||
test "warning when loss in (50%, 100%]" do
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.51, 0.0)
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.0, 1.0, 0.0)
|
||||
end
|
||||
|
||||
test "warning when throughput drops in (15%, 30%]" do
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.16)
|
||||
assert "warning" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.30)
|
||||
end
|
||||
|
||||
test "info at and below warning thresholds" do
|
||||
assert "info" == Correlator.determine_severity_from_deltas(0.20, 0.50, -0.15)
|
||||
assert "info" == Correlator.determine_severity_from_deltas(0.0, 0.0, 0.0)
|
||||
assert "info" == Correlator.determine_severity_from_deltas(-0.5, -0.5, 0.5)
|
||||
end
|
||||
|
||||
test "any one critical trigger bumps severity to critical" do
|
||||
# Even if other metrics are fine
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(1.0, 0.0, 0.0)
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 2.0, 0.0)
|
||||
assert "critical" == Correlator.determine_severity_from_deltas(0.0, 0.0, -0.5)
|
||||
end
|
||||
end
|
||||
|
||||
describe "maybe_add/3" do
|
||||
test "appends on true" do
|
||||
assert ["a", "b"] == Correlator.maybe_add(["a"], "b", true)
|
||||
end
|
||||
|
||||
test "does not append on false" do
|
||||
assert ["a"] == Correlator.maybe_add(["a"], "b", false)
|
||||
end
|
||||
|
||||
test "starting from empty list" do
|
||||
assert ["x"] == Correlator.maybe_add([], "x", true)
|
||||
assert [] == Correlator.maybe_add([], "x", false)
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_float/1" do
|
||||
test "nil becomes 0.0" do
|
||||
assert 0.0 == Correlator.to_float(nil)
|
||||
end
|
||||
|
||||
test "float passes through" do
|
||||
assert 3.14 == Correlator.to_float(3.14)
|
||||
assert 0.0 == Correlator.to_float(0.0)
|
||||
end
|
||||
|
||||
test "integer converts to float" do
|
||||
assert 42.0 == Correlator.to_float(42)
|
||||
assert 0.0 == Correlator.to_float(0)
|
||||
assert -7.0 == Correlator.to_float(-7)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_description/2" do
|
||||
test "includes sections when present" do
|
||||
deltas = %{latency_delta: 0.15, loss_delta: 0.15, throughput_delta: -0.10}
|
||||
event = %{sections_changed: ["firewall", "nat"]}
|
||||
|
||||
desc = Correlator.build_description(deltas, event)
|
||||
assert desc =~ "After config change (sections: firewall, nat)"
|
||||
assert desc =~ "latency +15%"
|
||||
assert desc =~ "loss +15%"
|
||||
assert desc =~ "throughput -10%"
|
||||
end
|
||||
|
||||
test "skips sections when empty list" do
|
||||
deltas = %{latency_delta: 0.15, loss_delta: 0.0, throughput_delta: 0.0}
|
||||
event = %{sections_changed: []}
|
||||
|
||||
desc = Correlator.build_description(deltas, event)
|
||||
assert desc =~ "After config change: latency +15%"
|
||||
refute desc =~ "(sections:"
|
||||
end
|
||||
|
||||
test "skips sections when nil" do
|
||||
deltas = %{latency_delta: 0.15, loss_delta: 0.0, throughput_delta: 0.0}
|
||||
event = %{sections_changed: nil}
|
||||
|
||||
desc = Correlator.build_description(deltas, event)
|
||||
refute desc =~ "(sections:"
|
||||
end
|
||||
|
||||
test "omits sub-threshold deltas" do
|
||||
deltas = %{latency_delta: 0.05, loss_delta: 0.05, throughput_delta: -0.02}
|
||||
event = %{sections_changed: []}
|
||||
|
||||
desc = Correlator.build_description(deltas, event)
|
||||
refute desc =~ "latency"
|
||||
refute desc =~ "loss"
|
||||
refute desc =~ "throughput"
|
||||
assert desc =~ "After config change:"
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: safe_pct_change" do
|
||||
property "positive old always produces finite ratio" do
|
||||
check all(
|
||||
old <- float(min: 0.01, max: 1_000.0),
|
||||
new_val <- float(min: 0.0, max: 10_000.0)
|
||||
) do
|
||||
result = Correlator.safe_pct_change(old, new_val)
|
||||
assert is_float(result)
|
||||
assert_in_delta result, (new_val - old) / old, 1.0e-9
|
||||
end
|
||||
end
|
||||
|
||||
property "zero or negative old always produces 0.0" do
|
||||
check all(
|
||||
old <- one_of([constant(0.0), float(min: -1000.0, max: 0.0)]),
|
||||
new_val <- float()
|
||||
) do
|
||||
assert 0.0 == Correlator.safe_pct_change(old, new_val)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: determine_severity_from_deltas monotonicity" do
|
||||
property "critical thresholds produce critical regardless of other metrics" do
|
||||
check all(
|
||||
latency <- float(min: 0.51, max: 10.0),
|
||||
loss <- float(min: -1.0, max: 1.0),
|
||||
throughput <- float(min: -1.0, max: 1.0)
|
||||
) do
|
||||
assert "critical" ==
|
||||
Correlator.determine_severity_from_deltas(latency, loss, throughput)
|
||||
end
|
||||
end
|
||||
|
||||
property "info when all metrics within safe range" do
|
||||
check all(
|
||||
latency <- float(min: -1.0, max: 0.20),
|
||||
loss <- float(min: -1.0, max: 0.50),
|
||||
throughput <- float(min: -0.15, max: 1.0)
|
||||
) do
|
||||
assert "info" == Correlator.determine_severity_from_deltas(latency, loss, throughput)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
152
test/towerops/config_changes_test.exs
Normal file
152
test/towerops/config_changes_test.exs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
defmodule Towerops.ConfigChangesTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.ConfigChanges
|
||||
|
||||
describe "detect_changed_sections/1" do
|
||||
test "empty diff returns no sections" do
|
||||
assert [] == ConfigChanges.detect_changed_sections("")
|
||||
end
|
||||
|
||||
test "firewall changes detected" do
|
||||
diff = """
|
||||
/ip firewall filter add chain=input action=drop
|
||||
"""
|
||||
|
||||
assert "firewall" in ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "interface changes detected" do
|
||||
diff = """
|
||||
+/interface ethernet set [ find default-name=ether1 ] mtu=1500
|
||||
"""
|
||||
|
||||
assert "interface" in ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "nat changes detected (matches firewall too since it's a subsection)" do
|
||||
diff = """
|
||||
/ip firewall nat add chain=srcnat action=masquerade
|
||||
"""
|
||||
|
||||
sections = ConfigChanges.detect_changed_sections(diff)
|
||||
assert "nat" in sections
|
||||
assert "firewall" in sections
|
||||
end
|
||||
|
||||
test "multiple independent sections" do
|
||||
diff = """
|
||||
/ip firewall filter add chain=input
|
||||
/ip dns set servers=1.1.1.1
|
||||
+/interface bridge add name=bridge1
|
||||
"""
|
||||
|
||||
sections = ConfigChanges.detect_changed_sections(diff)
|
||||
assert "firewall" in sections
|
||||
assert "dns" in sections
|
||||
assert "bridge" in sections
|
||||
assert "interface" in sections
|
||||
end
|
||||
|
||||
test "wireless interface changes" do
|
||||
diff = """
|
||||
+/interface wireless set wlan1 ssid=new-ssid
|
||||
"""
|
||||
|
||||
sections = ConfigChanges.detect_changed_sections(diff)
|
||||
assert "wireless" in sections
|
||||
assert "interface" in sections
|
||||
end
|
||||
|
||||
test "vlan changes" do
|
||||
diff = """
|
||||
+/interface vlan add name=vlan10 vlan-id=10
|
||||
"""
|
||||
|
||||
sections = ConfigChanges.detect_changed_sections(diff)
|
||||
assert "vlan" in sections
|
||||
end
|
||||
|
||||
test "routing changes (ospf, bgp, route)" do
|
||||
for line <- ["/ip route add dst-address=", "/routing ospf", "/routing bgp", "/ip route rip"] do
|
||||
assert "routing" in ConfigChanges.detect_changed_sections("#{line}\n")
|
||||
end
|
||||
end
|
||||
|
||||
test "user, snmp, system, ppp, queue, dhcp, address sections" do
|
||||
mappings = [
|
||||
{"/user add name=admin", "user"},
|
||||
{"/snmp set enabled=yes", "snmp"},
|
||||
{"/system identity set name=router1", "system"},
|
||||
{"/ppp profile add", "ppp"},
|
||||
{"/queue simple add", "queue"},
|
||||
{"/ip dhcp-server add", "dhcp"},
|
||||
{"/ip address add", "address"}
|
||||
]
|
||||
|
||||
for {line, section} <- mappings do
|
||||
sections = ConfigChanges.detect_changed_sections("#{line}\n")
|
||||
assert section in sections, "expected #{section} in sections for line #{line}"
|
||||
end
|
||||
end
|
||||
|
||||
test "returns sorted list" do
|
||||
diff = """
|
||||
/ip dns set
|
||||
/ip firewall filter
|
||||
+/interface bridge
|
||||
"""
|
||||
|
||||
sections = ConfigChanges.detect_changed_sections(diff)
|
||||
assert sections == Enum.sort(sections)
|
||||
end
|
||||
|
||||
test "unrelated diff yields empty list" do
|
||||
diff = """
|
||||
# Just a comment
|
||||
some unrelated text
|
||||
"""
|
||||
|
||||
assert [] == ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "non-binary input returns []" do
|
||||
assert [] == ConfigChanges.detect_changed_sections(nil)
|
||||
assert [] == ConfigChanges.detect_changed_sections(42)
|
||||
assert [] == ConfigChanges.detect_changed_sections(%{})
|
||||
end
|
||||
|
||||
test "patterns are anchored at line start (^/.../ m)" do
|
||||
# "/ip firewall" in the middle of a line should NOT match (^ anchors)
|
||||
diff = "foo /ip firewall filter bar"
|
||||
assert [] == ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "unified-diff lines with + prefix are detected" do
|
||||
diff = """
|
||||
--- before
|
||||
+++ after
|
||||
@@ -1,3 +1,4 @@
|
||||
+/ip firewall filter add chain=input
|
||||
"""
|
||||
|
||||
assert "firewall" in ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "unified-diff lines with - prefix are detected" do
|
||||
diff = """
|
||||
-/ip dns set servers=8.8.8.8
|
||||
"""
|
||||
|
||||
assert "dns" in ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
|
||||
test "unified-diff context lines with space prefix are detected" do
|
||||
diff = """
|
||||
/interface ethernet set ether1 mtu=1500
|
||||
"""
|
||||
|
||||
assert "interface" in ConfigChanges.detect_changed_sections(diff)
|
||||
end
|
||||
end
|
||||
end
|
||||
84
test/towerops/ecto_types/ip_address_properties_test.exs
Normal file
84
test/towerops/ecto_types/ip_address_properties_test.exs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
defmodule Towerops.EctoTypes.IpAddressPropertiesTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.EctoTypes.IpAddress
|
||||
|
||||
defp ipv4_string_gen do
|
||||
gen all(
|
||||
a <- integer(0..255),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: IPv4 casting" do
|
||||
property "any valid IPv4 string casts to a struct with matching tuple" do
|
||||
check all(ip_string <- ipv4_string_gen()) do
|
||||
assert {:ok, %IpAddress{version: :ipv4, tuple: tuple, address: addr}} =
|
||||
IpAddress.cast(ip_string)
|
||||
|
||||
assert tuple_size(tuple) == 4
|
||||
assert addr == ip_string
|
||||
end
|
||||
end
|
||||
|
||||
property "tuple octets match the string representation" do
|
||||
check all(
|
||||
a <- integer(0..255),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
{:ok, %IpAddress{tuple: {ta, tb, tc, td}}} =
|
||||
IpAddress.cast("#{a}.#{b}.#{c}.#{d}")
|
||||
|
||||
assert {ta, tb, tc, td} == {a, b, c, d}
|
||||
end
|
||||
end
|
||||
|
||||
property "round-trip through dump/load preserves struct" do
|
||||
check all(ip_string <- ipv4_string_gen()) do
|
||||
{:ok, ip} = IpAddress.cast(ip_string)
|
||||
{:ok, dumped} = IpAddress.dump(ip)
|
||||
{:ok, loaded} = IpAddress.load(dumped)
|
||||
|
||||
assert loaded.address == ip.address
|
||||
assert loaded.version == ip.version
|
||||
assert loaded.tuple == ip.tuple
|
||||
end
|
||||
end
|
||||
|
||||
property "idempotent: casting an already-cast struct returns it unchanged" do
|
||||
check all(ip_string <- ipv4_string_gen()) do
|
||||
{:ok, ip} = IpAddress.cast(ip_string)
|
||||
assert {:ok, ^ip} = IpAddress.cast(ip)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: invalid input rejection" do
|
||||
property "octets > 255 are rejected" do
|
||||
check all(
|
||||
a <- integer(256..1000),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert :error == IpAddress.cast("#{a}.#{b}.#{c}.#{d}")
|
||||
end
|
||||
end
|
||||
|
||||
property "non-IP strings are rejected" do
|
||||
check all(
|
||||
garbage <- string(:alphanumeric, min_length: 1, max_length: 20),
|
||||
not String.match?(garbage, ~r/^\d/)
|
||||
) do
|
||||
assert :error == IpAddress.cast(garbage)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
113
test/towerops/ecto_types/mac_address_properties_test.exs
Normal file
113
test/towerops/ecto_types/mac_address_properties_test.exs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
defmodule Towerops.EctoTypes.MacAddressPropertiesTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.EctoTypes.MacAddress
|
||||
|
||||
defp mac_bytes_gen do
|
||||
gen all(bytes <- list_of(integer(0..255), length: 6)) do
|
||||
bytes
|
||||
end
|
||||
end
|
||||
|
||||
defp to_colon_format(bytes) do
|
||||
bytes |> Enum.map_join(":", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase()
|
||||
end
|
||||
|
||||
defp to_hyphen_format(bytes) do
|
||||
bytes |> Enum.map_join("-", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase()
|
||||
end
|
||||
|
||||
defp to_cisco_format(bytes) do
|
||||
bytes
|
||||
|> Enum.map(fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end)
|
||||
|> Enum.chunk_every(2)
|
||||
|> Enum.map_join(".", &Enum.join/1)
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
defp to_compact_format(bytes) do
|
||||
bytes |> Enum.map_join("", fn b -> String.pad_leading(Integer.to_string(b, 16), 2, "0") end) |> String.downcase()
|
||||
end
|
||||
|
||||
describe "property: all input formats normalize to the same canonical form" do
|
||||
property "colon, hyphen, cisco, and compact formats produce identical structs" do
|
||||
check all(bytes <- mac_bytes_gen()) do
|
||||
colon = to_colon_format(bytes)
|
||||
hyphen = to_hyphen_format(bytes)
|
||||
cisco = to_cisco_format(bytes)
|
||||
compact = to_compact_format(bytes)
|
||||
|
||||
assert {:ok, m_colon} = MacAddress.cast(colon)
|
||||
assert {:ok, m_hyphen} = MacAddress.cast(hyphen)
|
||||
assert {:ok, m_cisco} = MacAddress.cast(cisco)
|
||||
assert {:ok, m_compact} = MacAddress.cast(compact)
|
||||
|
||||
assert m_colon.address == m_hyphen.address
|
||||
assert m_hyphen.address == m_cisco.address
|
||||
assert m_cisco.address == m_compact.address
|
||||
end
|
||||
end
|
||||
|
||||
property "uppercase input is normalized to lowercase" do
|
||||
check all(bytes <- mac_bytes_gen()) do
|
||||
upper = bytes |> to_colon_format() |> String.upcase()
|
||||
lower = to_colon_format(bytes)
|
||||
|
||||
assert {:ok, m1} = MacAddress.cast(upper)
|
||||
assert {:ok, m2} = MacAddress.cast(lower)
|
||||
assert m1.address == m2.address
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: binary representation consistency" do
|
||||
property "binary field is always 6 bytes when cast succeeds from string" do
|
||||
check all(bytes <- mac_bytes_gen()) do
|
||||
{:ok, mac} = MacAddress.cast(to_colon_format(bytes))
|
||||
assert byte_size(mac.binary) == 6
|
||||
end
|
||||
end
|
||||
|
||||
test "explicit non-printable binaries round-trip to colon format" do
|
||||
cases = [
|
||||
{<<0x00, 0x11, 0x22, 0x33, 0x44, 0x55>>, "00:11:22:33:44:55"},
|
||||
{<<0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF>>, "aa:bb:cc:dd:ee:ff"},
|
||||
{<<0x01, 0x02, 0x03, 0x04, 0x05, 0x06>>, "01:02:03:04:05:06"}
|
||||
]
|
||||
|
||||
for {binary, expected} <- cases do
|
||||
assert {:ok, %MacAddress{address: ^expected, binary: ^binary}} =
|
||||
MacAddress.cast(binary)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: idempotence and round-trips" do
|
||||
property "idempotent on already-cast struct" do
|
||||
check all(bytes <- mac_bytes_gen()) do
|
||||
{:ok, mac} = MacAddress.cast(to_colon_format(bytes))
|
||||
assert {:ok, ^mac} = MacAddress.cast(mac)
|
||||
end
|
||||
end
|
||||
|
||||
property "dump/load roundtrip preserves the struct" do
|
||||
check all(bytes <- mac_bytes_gen()) do
|
||||
{:ok, mac} = MacAddress.cast(to_colon_format(bytes))
|
||||
{:ok, dumped} = MacAddress.dump(mac)
|
||||
{:ok, loaded} = MacAddress.load(dumped)
|
||||
|
||||
assert loaded.address == mac.address
|
||||
assert loaded.binary == mac.binary
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: invalid inputs" do
|
||||
property "random short strings are rejected" do
|
||||
check all(garbage <- string(:alphanumeric, min_length: 1, max_length: 5)) do
|
||||
assert :error == MacAddress.cast(garbage)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
140
test/towerops/geocoding_test.exs
Normal file
140
test/towerops/geocoding_test.exs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
defmodule Towerops.GeocodingTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Towerops.Geocoding
|
||||
|
||||
setup do
|
||||
original = Application.get_env(:towerops, :google_maps_api_key)
|
||||
Application.put_env(:towerops, :google_maps_api_key, "test-key")
|
||||
|
||||
on_exit(fn ->
|
||||
if is_nil(original) do
|
||||
Application.delete_env(:towerops, :google_maps_api_key)
|
||||
else
|
||||
Application.put_env(:towerops, :google_maps_api_key, original)
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "geocode/1" do
|
||||
test "returns lat/lng/formatted_address on OK response" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
assert conn.query_string =~ "address="
|
||||
assert conn.query_string =~ "key=test-key"
|
||||
|
||||
Req.Test.json(conn, %{
|
||||
"status" => "OK",
|
||||
"results" => [
|
||||
%{
|
||||
"geometry" => %{"location" => %{"lat" => 40.7128, "lng" => -74.006}},
|
||||
"formatted_address" => "New York, NY, USA"
|
||||
}
|
||||
]
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, result} = Geocoding.geocode("New York")
|
||||
assert result.latitude == 40.7128
|
||||
assert result.longitude == -74.006
|
||||
assert result.formatted_address == "New York, NY, USA"
|
||||
end
|
||||
|
||||
test "returns error on ZERO_RESULTS" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{"status" => "ZERO_RESULTS", "results" => []})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("nonexistent address")
|
||||
assert message =~ "No results"
|
||||
end
|
||||
|
||||
test "returns error on OVER_QUERY_LIMIT" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{"status" => "OVER_QUERY_LIMIT"})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "quota"
|
||||
end
|
||||
|
||||
test "returns error on REQUEST_DENIED with message" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"status" => "REQUEST_DENIED",
|
||||
"error_message" => "API key invalid"
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "denied"
|
||||
assert message =~ "API key invalid"
|
||||
end
|
||||
|
||||
test "returns error on INVALID_REQUEST" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{"status" => "INVALID_REQUEST"})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("")
|
||||
assert message =~ "Invalid request"
|
||||
end
|
||||
|
||||
test "returns error on unknown status" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{"status" => "UNKNOWN_ERROR"})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "UNKNOWN_ERROR"
|
||||
end
|
||||
|
||||
test "returns error when OK but missing geometry" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"status" => "OK",
|
||||
"results" => [%{"formatted_address" => "Somewhere"}]
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "Invalid response format"
|
||||
end
|
||||
|
||||
test "returns error for unexpected response body" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
Req.Test.json(conn, %{"unexpected" => "shape"})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "Unexpected response format"
|
||||
end
|
||||
|
||||
test "returns error on non-200 status" do
|
||||
Req.Test.stub(Geocoding, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(500)
|
||||
|> Req.Test.json(%{"error" => "server error"})
|
||||
end)
|
||||
|
||||
assert {:error, message} = Geocoding.geocode("anywhere")
|
||||
assert message =~ "Geocoding API error"
|
||||
assert message =~ "500"
|
||||
end
|
||||
|
||||
test "returns :no_api_key error when key is missing" do
|
||||
Application.delete_env(:towerops, :google_maps_api_key)
|
||||
System.delete_env("GOOGLE_MAPS_API_KEY")
|
||||
|
||||
assert {:error, :no_api_key} = Geocoding.geocode("anywhere")
|
||||
end
|
||||
|
||||
test "returns :no_api_key error when key is whitespace-only" do
|
||||
Application.put_env(:towerops, :google_maps_api_key, " ")
|
||||
System.delete_env("GOOGLE_MAPS_API_KEY")
|
||||
|
||||
assert {:error, :no_api_key} = Geocoding.geocode("anywhere")
|
||||
end
|
||||
end
|
||||
end
|
||||
275
test/towerops/maintenance_context_test.exs
Normal file
275
test/towerops/maintenance_context_test.exs
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
defmodule Towerops.MaintenanceContextTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Maintenance
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = Towerops.OrganizationsFixtures.organization_fixture(user.id)
|
||||
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Maint Site #{System.unique_integer([:positive])}",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
device =
|
||||
Towerops.DevicesFixtures.device_fixture(%{
|
||||
organization_id: org.id,
|
||||
site_id: site.id,
|
||||
name: "Maint Device",
|
||||
ip_address: "10.99.0.1"
|
||||
})
|
||||
|
||||
%{org: org, site: site, device: device, user: user}
|
||||
end
|
||||
|
||||
defp create_window!(attrs) do
|
||||
user_id = Map.get(attrs, :created_by_id) || (attrs[:user] && attrs.user.id)
|
||||
attrs = Map.delete(attrs, :user)
|
||||
|
||||
attrs =
|
||||
if user_id do
|
||||
Map.put(attrs, :created_by_id, user_id)
|
||||
else
|
||||
Map.put_new(attrs, :created_by_id, user_fixture().id)
|
||||
end
|
||||
|
||||
{:ok, w} =
|
||||
Maintenance.create_window(
|
||||
Map.merge(
|
||||
%{
|
||||
name: "Routine",
|
||||
starts_at: DateTime.add(DateTime.utc_now(), -3600, :second),
|
||||
ends_at: DateTime.add(DateTime.utc_now(), 3600, :second),
|
||||
suppress_alerts: true
|
||||
},
|
||||
attrs
|
||||
)
|
||||
)
|
||||
|
||||
w
|
||||
end
|
||||
|
||||
describe "CRUD" do
|
||||
test "create/update/delete/get cycle", %{org: org} do
|
||||
w =
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
name: "Planned"
|
||||
})
|
||||
|
||||
got = Maintenance.get_window!(w.id)
|
||||
assert got.name == "Planned"
|
||||
|
||||
{:ok, updated} = Maintenance.update_window(got, %{name: "Renamed"})
|
||||
assert updated.name == "Renamed"
|
||||
|
||||
assert {:ok, _} = Maintenance.delete_window(updated)
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_windows/2 filtering" do
|
||||
setup %{org: org} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
active =
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
name: "Active",
|
||||
starts_at: DateTime.add(now, -3600, :second),
|
||||
ends_at: DateTime.add(now, 3600, :second)
|
||||
})
|
||||
|
||||
upcoming =
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
name: "Upcoming",
|
||||
starts_at: DateTime.add(now, 3600, :second),
|
||||
ends_at: DateTime.add(now, 7200, :second)
|
||||
})
|
||||
|
||||
past =
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
name: "Past",
|
||||
starts_at: DateTime.add(now, -7200, :second),
|
||||
ends_at: DateTime.add(now, -3600, :second)
|
||||
})
|
||||
|
||||
%{active: active, upcoming: upcoming, past: past}
|
||||
end
|
||||
|
||||
test "no filter returns all", %{org: org} do
|
||||
results = Maintenance.list_windows(org.id)
|
||||
assert length(results) == 3
|
||||
end
|
||||
|
||||
test "filter: :active", %{org: org, active: active} do
|
||||
[got] = Maintenance.list_windows(org.id, filter: :active)
|
||||
assert got.id == active.id
|
||||
end
|
||||
|
||||
test "filter: :upcoming", %{org: org, upcoming: upcoming} do
|
||||
[got] = Maintenance.list_windows(org.id, filter: :upcoming)
|
||||
assert got.id == upcoming.id
|
||||
end
|
||||
|
||||
test "filter: :past", %{org: org, past: past} do
|
||||
[got] = Maintenance.list_windows(org.id, filter: :past)
|
||||
assert got.id == past.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "device_in_maintenance?/1" do
|
||||
test "direct device window", %{org: org, device: device} do
|
||||
create_window!(%{organization_id: org.id, device_id: device.id})
|
||||
assert Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
|
||||
test "site window applies", %{org: org, site: site, device: device} do
|
||||
create_window!(%{organization_id: org.id, site_id: site.id})
|
||||
assert Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
|
||||
test "org-wide window applies", %{org: org, device: device} do
|
||||
create_window!(%{organization_id: org.id})
|
||||
assert Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
|
||||
test "false with no active windows", %{device: device} do
|
||||
refute Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
|
||||
test "past windows don't count", %{org: org, device: device} do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
device_id: device.id,
|
||||
starts_at: DateTime.add(now, -7200, :second),
|
||||
ends_at: DateTime.add(now, -3600, :second)
|
||||
})
|
||||
|
||||
refute Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
|
||||
test "suppress_alerts: false doesn't count", %{org: org, device: device} do
|
||||
create_window!(%{organization_id: org.id, device_id: device.id, suppress_alerts: false})
|
||||
refute Maintenance.device_in_maintenance?(device.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "site_in_maintenance?/1" do
|
||||
test "site-scoped window only", %{org: org, site: site} do
|
||||
create_window!(%{organization_id: org.id, site_id: site.id})
|
||||
assert Maintenance.site_in_maintenance?(site.id)
|
||||
end
|
||||
|
||||
test "device-scoped window doesn't make site in maintenance", %{org: org, site: site, device: device} do
|
||||
create_window!(%{organization_id: org.id, site_id: site.id, device_id: device.id})
|
||||
refute Maintenance.site_in_maintenance?(site.id)
|
||||
end
|
||||
|
||||
test "returns false with no matching windows", %{site: site} do
|
||||
refute Maintenance.site_in_maintenance?(site.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "active_windows_for_device/1" do
|
||||
test "de-dupes across direct/site/org matches", %{org: org, site: site, device: device} do
|
||||
create_window!(%{organization_id: org.id, device_id: device.id, name: "d"})
|
||||
create_window!(%{organization_id: org.id, site_id: site.id, name: "s"})
|
||||
create_window!(%{organization_id: org.id, name: "o"})
|
||||
|
||||
results = Maintenance.active_windows_for_device(device.id)
|
||||
assert length(results) == 3
|
||||
assert length(Enum.uniq_by(results, & &1.id)) == length(results)
|
||||
end
|
||||
|
||||
test "returns empty list for device with no windows", %{device: device} do
|
||||
assert [] == Maintenance.active_windows_for_device(device.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "active_windows_for_org/1" do
|
||||
test "returns active windows with preloads", %{org: org} do
|
||||
create_window!(%{organization_id: org.id, name: "Active"})
|
||||
|
||||
past = DateTime.add(DateTime.utc_now(), -7200, :second)
|
||||
|
||||
create_window!(%{
|
||||
organization_id: org.id,
|
||||
name: "Past",
|
||||
starts_at: past,
|
||||
ends_at: DateTime.add(past, 3600, :second)
|
||||
})
|
||||
|
||||
results = Maintenance.active_windows_for_org(org.id)
|
||||
assert length(results) == 1
|
||||
assert Enum.all?(results, fn w -> w.name == "Active" end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "maintenance_badge/1" do
|
||||
test "returns name for device in maintenance", %{org: org, device: device} do
|
||||
create_window!(%{organization_id: org.id, device_id: device.id, name: "Badge"})
|
||||
assert "Badge" == Maintenance.maintenance_badge(device)
|
||||
end
|
||||
|
||||
test "returns nil for device not in maintenance", %{device: device} do
|
||||
assert nil == Maintenance.maintenance_badge(device)
|
||||
end
|
||||
|
||||
test "returns name for site in maintenance", %{org: org, site: site} do
|
||||
create_window!(%{organization_id: org.id, site_id: site.id, name: "SiteBadge"})
|
||||
assert "SiteBadge" == Maintenance.maintenance_badge(site)
|
||||
end
|
||||
|
||||
test "returns nil for site without maintenance", %{site: site} do
|
||||
assert nil == Maintenance.maintenance_badge(site)
|
||||
end
|
||||
|
||||
test "returns nil for other types" do
|
||||
assert nil == Maintenance.maintenance_badge(nil)
|
||||
assert nil == Maintenance.maintenance_badge(%{})
|
||||
assert nil == Maintenance.maintenance_badge("foo")
|
||||
end
|
||||
end
|
||||
|
||||
describe "devices_in_maintenance/1 batch" do
|
||||
test "empty list returns empty MapSet" do
|
||||
assert MapSet.new() == Maintenance.devices_in_maintenance([])
|
||||
end
|
||||
|
||||
test "identifies direct + site + org scopes in single batch", %{org: org, site: site, device: device} do
|
||||
# Create another device that's only org-covered
|
||||
other_device =
|
||||
Towerops.DevicesFixtures.device_fixture(%{
|
||||
organization_id: org.id,
|
||||
site_id: site.id,
|
||||
name: "OtherDev",
|
||||
ip_address: "10.99.0.2"
|
||||
})
|
||||
|
||||
create_window!(%{organization_id: org.id, name: "org wide"})
|
||||
|
||||
result = Maintenance.devices_in_maintenance([device.id, other_device.id])
|
||||
assert MapSet.member?(result, device.id)
|
||||
assert MapSet.member?(result, other_device.id)
|
||||
end
|
||||
|
||||
test "omits devices not in maintenance", %{org: org, site: site} do
|
||||
device =
|
||||
Towerops.DevicesFixtures.device_fixture(%{
|
||||
organization_id: org.id,
|
||||
site_id: site.id,
|
||||
ip_address: "10.88.0.1"
|
||||
})
|
||||
|
||||
refute MapSet.member?(Maintenance.devices_in_maintenance([device.id]), device.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Towerops.Monitoring.Executors.DnsExecutorTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Monitoring.Executors.DnsExecutor
|
||||
|
||||
|
|
@ -108,4 +109,94 @@ defmodule Towerops.Monitoring.Executors.DnsExecutorTest do
|
|||
assert output =~ "Resolved to:"
|
||||
end
|
||||
end
|
||||
|
||||
describe "record_type_to_atom/1" do
|
||||
test "maps all known types" do
|
||||
assert :a == DnsExecutor.record_type_to_atom("A")
|
||||
assert :aaaa == DnsExecutor.record_type_to_atom("AAAA")
|
||||
assert :cname == DnsExecutor.record_type_to_atom("CNAME")
|
||||
assert :mx == DnsExecutor.record_type_to_atom("MX")
|
||||
assert :txt == DnsExecutor.record_type_to_atom("TXT")
|
||||
assert :ns == DnsExecutor.record_type_to_atom("NS")
|
||||
assert :ptr == DnsExecutor.record_type_to_atom("PTR")
|
||||
end
|
||||
|
||||
test "defaults to :a for unknown types" do
|
||||
assert :a == DnsExecutor.record_type_to_atom("SOA")
|
||||
assert :a == DnsExecutor.record_type_to_atom("srv")
|
||||
assert :a == DnsExecutor.record_type_to_atom("")
|
||||
assert :a == DnsExecutor.record_type_to_atom("anything")
|
||||
end
|
||||
|
||||
test "is case-sensitive (lowercase rejected)" do
|
||||
# Whitelist only matches uppercase; caller should normalize.
|
||||
assert :a == DnsExecutor.record_type_to_atom("aaaa")
|
||||
assert :a == DnsExecutor.record_type_to_atom("cname")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_server/1" do
|
||||
test "parses valid IPv4 into 4-tuple on port 53" do
|
||||
assert {{1, 1, 1, 1}, 53} == DnsExecutor.parse_server("1.1.1.1")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("8.8.8.8")
|
||||
assert {{192, 168, 1, 1}, 53} == DnsExecutor.parse_server("192.168.1.1")
|
||||
end
|
||||
|
||||
test "boundary values 0 and 255 are valid" do
|
||||
assert {{0, 0, 0, 0}, 53} == DnsExecutor.parse_server("0.0.0.0")
|
||||
assert {{255, 255, 255, 255}, 53} == DnsExecutor.parse_server("255.255.255.255")
|
||||
end
|
||||
|
||||
test "falls back to 8.8.8.8 for out-of-range octets" do
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("256.1.1.1")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.1.1.999")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("-1.2.3.4")
|
||||
end
|
||||
|
||||
test "falls back for wrong number of octets" do
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.4.5")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("")
|
||||
end
|
||||
|
||||
test "falls back for non-numeric octets" do
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("a.b.c.d")
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.abc")
|
||||
end
|
||||
|
||||
test "falls back for octets with trailing garbage" do
|
||||
# Integer.parse returns a remainder of "x" which kills the match
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server("1.2.3.4x")
|
||||
end
|
||||
|
||||
test "falls back for non-binary input" do
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(nil)
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(42)
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: parse_server/1" do
|
||||
property "valid IPv4 strings always yield matching 4-tuples" do
|
||||
check all(
|
||||
a <- integer(0..255),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert {{^a, ^b, ^c, ^d}, 53} = DnsExecutor.parse_server("#{a}.#{b}.#{c}.#{d}")
|
||||
end
|
||||
end
|
||||
|
||||
property "IPv4 strings with any out-of-range octet fall back to 8.8.8.8" do
|
||||
check all(
|
||||
pos <- integer(0..3),
|
||||
bad <- integer(256..999)
|
||||
) do
|
||||
octets = List.replace_at([0, 0, 0, 0], pos, bad)
|
||||
str = Enum.map_join(octets, ".", &Integer.to_string/1)
|
||||
assert {{8, 8, 8, 8}, 53} == DnsExecutor.parse_server(str)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Towerops.Monitoring.Executors.HttpExecutorTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Monitoring.Executors.HttpExecutor
|
||||
|
||||
|
|
@ -145,4 +146,141 @@ defmodule Towerops.Monitoring.Executors.HttpExecutorTest do
|
|||
assert {:ok, _, _} = HttpExecutor.execute(config)
|
||||
end
|
||||
end
|
||||
|
||||
describe "normalize_http_method/1" do
|
||||
test "maps all known lowercase methods" do
|
||||
assert :get == HttpExecutor.normalize_http_method("get")
|
||||
assert :post == HttpExecutor.normalize_http_method("post")
|
||||
assert :put == HttpExecutor.normalize_http_method("put")
|
||||
assert :patch == HttpExecutor.normalize_http_method("patch")
|
||||
assert :delete == HttpExecutor.normalize_http_method("delete")
|
||||
assert :head == HttpExecutor.normalize_http_method("head")
|
||||
assert :options == HttpExecutor.normalize_http_method("options")
|
||||
end
|
||||
|
||||
test "defaults to :get for unknown methods" do
|
||||
assert :get == HttpExecutor.normalize_http_method("trace")
|
||||
assert :get == HttpExecutor.normalize_http_method("unknown")
|
||||
end
|
||||
|
||||
test "defaults to :get for uppercase (caller should downcase first)" do
|
||||
# Whitelist only matches lowercase — intentional to force caller normalization.
|
||||
assert :get == HttpExecutor.normalize_http_method("GET")
|
||||
assert :get == HttpExecutor.normalize_http_method("POST")
|
||||
end
|
||||
|
||||
test "defaults to :get for empty/junk input" do
|
||||
assert :get == HttpExecutor.normalize_http_method("")
|
||||
assert :get == HttpExecutor.normalize_http_method("injected; rm -rf /")
|
||||
end
|
||||
end
|
||||
|
||||
describe "private_ip?/1" do
|
||||
test "loopback 127/8" do
|
||||
assert HttpExecutor.private_ip?({127, 0, 0, 1})
|
||||
assert HttpExecutor.private_ip?({127, 255, 255, 254})
|
||||
end
|
||||
|
||||
test "private class A (10/8)" do
|
||||
assert HttpExecutor.private_ip?({10, 0, 0, 1})
|
||||
assert HttpExecutor.private_ip?({10, 255, 255, 254})
|
||||
end
|
||||
|
||||
test "private class B (172.16/12)" do
|
||||
assert HttpExecutor.private_ip?({172, 16, 0, 1})
|
||||
assert HttpExecutor.private_ip?({172, 31, 255, 254})
|
||||
|
||||
refute HttpExecutor.private_ip?({172, 15, 0, 1})
|
||||
refute HttpExecutor.private_ip?({172, 32, 0, 1})
|
||||
end
|
||||
|
||||
test "private class C (192.168/16)" do
|
||||
assert HttpExecutor.private_ip?({192, 168, 0, 1})
|
||||
assert HttpExecutor.private_ip?({192, 168, 255, 254})
|
||||
end
|
||||
|
||||
test "link-local / AWS metadata (169.254/16)" do
|
||||
assert HttpExecutor.private_ip?({169, 254, 169, 254})
|
||||
end
|
||||
|
||||
test "unspecified 0.0.0.0" do
|
||||
assert HttpExecutor.private_ip?({0, 0, 0, 0})
|
||||
end
|
||||
|
||||
test "public IPs are not private" do
|
||||
refute HttpExecutor.private_ip?({1, 1, 1, 1})
|
||||
refute HttpExecutor.private_ip?({8, 8, 8, 8})
|
||||
refute HttpExecutor.private_ip?({93, 184, 216, 34})
|
||||
end
|
||||
|
||||
test "non-tuples are not private" do
|
||||
refute HttpExecutor.private_ip?(nil)
|
||||
refute HttpExecutor.private_ip?("127.0.0.1")
|
||||
refute HttpExecutor.private_ip?({10, 0, 0})
|
||||
refute HttpExecutor.private_ip?(%{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: private_ip? coverage" do
|
||||
property "every 10.x.x.x is private" do
|
||||
check all(
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert HttpExecutor.private_ip?({10, b, c, d})
|
||||
end
|
||||
end
|
||||
|
||||
property "every 127.x.x.x is private" do
|
||||
check all(
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert HttpExecutor.private_ip?({127, b, c, d})
|
||||
end
|
||||
end
|
||||
|
||||
property "192.168.x.x is private, neighbors are not" do
|
||||
check all(
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert HttpExecutor.private_ip?({192, 168, c, d})
|
||||
end
|
||||
end
|
||||
|
||||
property "172.16-31.x.x is private, 172.0-15/172.32-255 are not" do
|
||||
check all(
|
||||
b <- integer(16..31),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
assert HttpExecutor.private_ip?({172, b, c, d})
|
||||
end
|
||||
end
|
||||
|
||||
property "172.0..15.x.x is not private" do
|
||||
check all(
|
||||
b <- integer(0..15),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
refute HttpExecutor.private_ip?({172, b, c, d})
|
||||
end
|
||||
end
|
||||
|
||||
property "random non-reserved public addresses are not private" do
|
||||
check all(
|
||||
a <- integer(11..126),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255)
|
||||
) do
|
||||
# 11-126 excludes 10/8 (private) and 127/8 (loopback).
|
||||
refute HttpExecutor.private_ip?({a, b, c, d})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Towerops.Monitoring.Executors.PingExecutorTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Monitoring.Executors.PingExecutor
|
||||
|
||||
|
|
@ -154,4 +155,165 @@ defmodule Towerops.Monitoring.Executors.PingExecutorTest do
|
|||
assert {:error, _reason} = PingExecutor.parse_output("")
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_host/1" do
|
||||
test "accepts plain IPv4" do
|
||||
assert :ok == PingExecutor.validate_host("192.168.1.1")
|
||||
assert :ok == PingExecutor.validate_host("10.0.0.1")
|
||||
end
|
||||
|
||||
test "accepts IPv6" do
|
||||
assert :ok == PingExecutor.validate_host("2001:db8::1")
|
||||
assert :ok == PingExecutor.validate_host("fe80::1")
|
||||
end
|
||||
|
||||
test "accepts plain hostnames" do
|
||||
assert :ok == PingExecutor.validate_host("example.com")
|
||||
assert :ok == PingExecutor.validate_host("my-host")
|
||||
assert :ok == PingExecutor.validate_host("host123.subdomain.example.org")
|
||||
end
|
||||
|
||||
test "rejects shell metacharacters" do
|
||||
assert {:error, reason} = PingExecutor.validate_host("127.0.0.1; rm -rf /")
|
||||
assert reason =~ "Invalid host"
|
||||
end
|
||||
|
||||
test "rejects pipe and redirect" do
|
||||
assert {:error, _} = PingExecutor.validate_host("host | cat")
|
||||
assert {:error, _} = PingExecutor.validate_host("host > /tmp/x")
|
||||
end
|
||||
|
||||
test "rejects backticks" do
|
||||
assert {:error, _} = PingExecutor.validate_host("`whoami`")
|
||||
end
|
||||
|
||||
test "rejects dollar sign" do
|
||||
assert {:error, _} = PingExecutor.validate_host("$(whoami)")
|
||||
end
|
||||
|
||||
test "rejects whitespace" do
|
||||
assert {:error, _} = PingExecutor.validate_host("host with space")
|
||||
end
|
||||
|
||||
test "rejects embedded newline (shell-injection vector)" do
|
||||
# Using ^/$ anchors instead of \A/\z would let this slip through because
|
||||
# $ matches before a trailing \n in PCRE by default.
|
||||
assert {:error, _} = PingExecutor.validate_host("host\ninjected")
|
||||
assert {:error, _} = PingExecutor.validate_host("host\n")
|
||||
assert {:error, _} = PingExecutor.validate_host("\nhost")
|
||||
end
|
||||
|
||||
test "rejects embedded carriage return and tab" do
|
||||
assert {:error, _} = PingExecutor.validate_host("host\rinjected")
|
||||
assert {:error, _} = PingExecutor.validate_host("host\t")
|
||||
end
|
||||
|
||||
test "rejects empty string" do
|
||||
assert {:error, _} = PingExecutor.validate_host("")
|
||||
end
|
||||
|
||||
test "rejects non-string types" do
|
||||
assert {:error, reason} = PingExecutor.validate_host(nil)
|
||||
assert reason =~ "must be a string"
|
||||
|
||||
assert {:error, _} = PingExecutor.validate_host(123)
|
||||
assert {:error, _} = PingExecutor.validate_host(:atom)
|
||||
assert {:error, _} = PingExecutor.validate_host([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: validate_host/1" do
|
||||
property "pure alphanumeric-dot-hyphen-colon strings are always accepted" do
|
||||
check all(
|
||||
host <-
|
||||
string([?a..?z, ?A..?Z, ?0..?9, ?., ?-, ?:], min_length: 1, max_length: 30)
|
||||
) do
|
||||
assert :ok == PingExecutor.validate_host(host)
|
||||
end
|
||||
end
|
||||
|
||||
property "strings containing shell metacharacters are always rejected" do
|
||||
check all(
|
||||
prefix <- string(:alphanumeric, min_length: 1, max_length: 5),
|
||||
bad <- member_of([";", "|", "&", "`", "$", "(", ")", " ", "\n"]),
|
||||
suffix <- string(:alphanumeric, min_length: 0, max_length: 5)
|
||||
) do
|
||||
assert {:error, _} = PingExecutor.validate_host(prefix <> bad <> suffix)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_packet_loss/1" do
|
||||
test "extracts loss percentage from Linux ping output" do
|
||||
output = "3 packets transmitted, 3 received, 0% packet loss"
|
||||
assert {:ok, 0.0, "3 packets"} == PingExecutor.parse_packet_loss(output)
|
||||
end
|
||||
|
||||
test "extracts loss percentage from macOS ping output" do
|
||||
output = "3 packets transmitted, 3 packets received, 0.0% packet loss"
|
||||
assert {:ok, 0.0, "3 packets"} == PingExecutor.parse_packet_loss(output)
|
||||
end
|
||||
|
||||
test "handles partial loss" do
|
||||
output = "10 packets transmitted, 7 received, 30% packet loss"
|
||||
assert {:ok, 30.0, "10 packets"} == PingExecutor.parse_packet_loss(output)
|
||||
end
|
||||
|
||||
test "handles 100% loss with errors" do
|
||||
output = "1 packets transmitted, 0 received, +1 errors, 100% packet loss"
|
||||
assert {:ok, 100.0, "1 packets"} == PingExecutor.parse_packet_loss(output)
|
||||
end
|
||||
|
||||
test "error on unparseable output" do
|
||||
assert {:error, _} = PingExecutor.parse_packet_loss("some garbage output")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_rtt/1" do
|
||||
test "parses Linux rtt output" do
|
||||
output = "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms"
|
||||
assert {:ok, avg} = PingExecutor.parse_rtt(output)
|
||||
assert_in_delta avg, 1.266, 0.001
|
||||
end
|
||||
|
||||
test "parses macOS round-trip output" do
|
||||
output = "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms"
|
||||
assert {:ok, avg} = PingExecutor.parse_rtt(output)
|
||||
assert_in_delta avg, 0.059, 0.001
|
||||
end
|
||||
|
||||
test "error when RTT line absent (100% loss case)" do
|
||||
output = "1 packets transmitted, 0 received, 100% packet loss"
|
||||
assert {:error, _} = PingExecutor.parse_rtt(output)
|
||||
end
|
||||
end
|
||||
|
||||
describe "format_loss/1" do
|
||||
test "zero loss is '0%'" do
|
||||
assert "0%" == PingExecutor.format_loss(0.0)
|
||||
end
|
||||
|
||||
test "partial loss shows 1 decimal place" do
|
||||
assert "33.3%" == PingExecutor.format_loss(33.33)
|
||||
assert "50.0%" == PingExecutor.format_loss(50.0)
|
||||
end
|
||||
|
||||
test "100% loss" do
|
||||
assert "100.0%" == PingExecutor.format_loss(100.0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_args/3" do
|
||||
test "contains -c count and host at tail on all platforms" do
|
||||
args = PingExecutor.build_args("8.8.8.8", 3, 5000)
|
||||
assert "-c" in args
|
||||
assert "3" in args
|
||||
assert List.last(args) == "8.8.8.8"
|
||||
end
|
||||
|
||||
test "timeout flag varies by OS (-W on darwin, -w elsewhere)" do
|
||||
args = PingExecutor.build_args("host", 2, 4000)
|
||||
assert "-W" in args or "-w" in args
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
249
test/towerops/monitoring/executors/snmp_executors_test.exs
Normal file
249
test/towerops/monitoring/executors/snmp_executors_test.exs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
defmodule Towerops.Monitoring.Executors.SnmpExecutorsTest do
|
||||
@moduledoc """
|
||||
Unit tests for pure helper functions in the SNMP monitoring executors.
|
||||
Tests cover OID construction, status mapping, credential selection, and
|
||||
output formatting — none of which requires SNMP network access.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.Monitoring.Executors.SnmpInterfaceExecutor
|
||||
alias Towerops.Monitoring.Executors.SnmpProcessorExecutor
|
||||
alias Towerops.Monitoring.Executors.SnmpStorageExecutor
|
||||
|
||||
describe "SnmpProcessorExecutor.build_processor_oid/1" do
|
||||
test "hr_processor prepends the host-resources OID" do
|
||||
processor = %{processor_index: "1", processor_type: "hr_processor"}
|
||||
assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.1"
|
||||
end
|
||||
|
||||
test "cisco_cpu uses the Cisco OID" do
|
||||
processor = %{processor_index: "7", processor_type: "cisco_cpu"}
|
||||
assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.4.1.9.9.109.1.1.1.1.5.7"
|
||||
end
|
||||
|
||||
test "unknown type falls back to host-resources" do
|
||||
processor = %{processor_index: "3", processor_type: "unknown"}
|
||||
assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.3"
|
||||
end
|
||||
|
||||
test "strips non-numeric characters from index" do
|
||||
processor = %{processor_index: "cisco_42", processor_type: "hr_processor"}
|
||||
assert SnmpProcessorExecutor.build_processor_oid(processor) == "1.3.6.1.2.1.25.3.3.1.2.42"
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpProcessorExecutor.determine_status/1" do
|
||||
test "< 80 is OK (0)" do
|
||||
assert SnmpProcessorExecutor.determine_status(0.0) == 0
|
||||
assert SnmpProcessorExecutor.determine_status(50.0) == 0
|
||||
assert SnmpProcessorExecutor.determine_status(79.9) == 0
|
||||
end
|
||||
|
||||
test "80-90 is WARNING (1)" do
|
||||
assert SnmpProcessorExecutor.determine_status(80.0) == 1
|
||||
assert SnmpProcessorExecutor.determine_status(85.0) == 1
|
||||
assert SnmpProcessorExecutor.determine_status(89.9) == 1
|
||||
end
|
||||
|
||||
test ">= 90 is CRITICAL (2)" do
|
||||
assert SnmpProcessorExecutor.determine_status(90.0) == 2
|
||||
assert SnmpProcessorExecutor.determine_status(100.0) == 2
|
||||
end
|
||||
|
||||
property "always returns 0, 1, or 2" do
|
||||
check all(load <- float(min: 0.0, max: 200.0)) do
|
||||
assert SnmpProcessorExecutor.determine_status(load) in [0, 1, 2]
|
||||
end
|
||||
end
|
||||
|
||||
property "status is monotonically non-decreasing" do
|
||||
check all(
|
||||
base <- float(min: 0.0, max: 100.0),
|
||||
delta <- float(min: 0.0, max: 50.0)
|
||||
) do
|
||||
a = base
|
||||
b = base + delta
|
||||
|
||||
assert SnmpProcessorExecutor.determine_status(a) <=
|
||||
SnmpProcessorExecutor.determine_status(b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpProcessorExecutor.format_output/2" do
|
||||
test "uses description when present" do
|
||||
output = SnmpProcessorExecutor.format_output(%{description: "CPU 0", processor_index: "0"}, 42.56)
|
||||
assert output == "CPU 0: 42.6% load"
|
||||
end
|
||||
|
||||
test "falls back to Processor #{:index}" do
|
||||
output = SnmpProcessorExecutor.format_output(%{description: nil, processor_index: "3"}, 10.0)
|
||||
assert output == "Processor 3: 10.0% load"
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpProcessorExecutor.add_snmp_credentials/2" do
|
||||
test "v2c sets community" do
|
||||
result = SnmpProcessorExecutor.add_snmp_credentials([], %{snmp_version: "2c", snmp_community: "secret"})
|
||||
assert result[:community] == "secret"
|
||||
end
|
||||
|
||||
test "v2c defaults to public when community nil" do
|
||||
result = SnmpProcessorExecutor.add_snmp_credentials([], %{snmp_version: "2c", snmp_community: nil})
|
||||
assert result[:community] == "public"
|
||||
end
|
||||
|
||||
test "v3 sets security options" do
|
||||
device = %{
|
||||
snmp_version: "3",
|
||||
snmpv3_username: "admin",
|
||||
snmpv3_security_level: "authPriv",
|
||||
snmpv3_auth_protocol: "SHA",
|
||||
snmpv3_auth_password: "authpw",
|
||||
snmpv3_priv_protocol: "AES",
|
||||
snmpv3_priv_password: "privpw"
|
||||
}
|
||||
|
||||
result = SnmpProcessorExecutor.add_snmp_credentials([], device)
|
||||
assert result[:security_name] == "admin"
|
||||
assert result[:security_level] == "authPriv"
|
||||
assert result[:auth_protocol] == "SHA"
|
||||
assert result[:priv_protocol] == "AES"
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpStorageExecutor.ensure_integer/1" do
|
||||
test "integers pass through" do
|
||||
assert SnmpStorageExecutor.ensure_integer(42) == 42
|
||||
end
|
||||
|
||||
test "floats round" do
|
||||
assert SnmpStorageExecutor.ensure_integer(42.6) == 43
|
||||
assert SnmpStorageExecutor.ensure_integer(42.4) == 42
|
||||
end
|
||||
|
||||
test "non-numbers become 0" do
|
||||
assert SnmpStorageExecutor.ensure_integer(nil) == 0
|
||||
assert SnmpStorageExecutor.ensure_integer("abc") == 0
|
||||
assert SnmpStorageExecutor.ensure_integer(:atom) == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpStorageExecutor.calculate_usage_percent/1" do
|
||||
test "size 0 returns 0.0 (no divide by zero)" do
|
||||
assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 0, used_units: 10}) == 0.0
|
||||
end
|
||||
|
||||
test "standard usage %" do
|
||||
assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 100, used_units: 25}) == 25.0
|
||||
assert SnmpStorageExecutor.calculate_usage_percent(%{size_units: 200, used_units: 150}) == 75.0
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpStorageExecutor.determine_status/1" do
|
||||
test "< 85 is OK" do
|
||||
assert SnmpStorageExecutor.determine_status(0) == 0
|
||||
assert SnmpStorageExecutor.determine_status(84.9) == 0
|
||||
end
|
||||
|
||||
test "85-95 is WARNING" do
|
||||
assert SnmpStorageExecutor.determine_status(85.0) == 1
|
||||
assert SnmpStorageExecutor.determine_status(94.9) == 1
|
||||
end
|
||||
|
||||
test ">= 95 is CRITICAL" do
|
||||
assert SnmpStorageExecutor.determine_status(95.0) == 2
|
||||
assert SnmpStorageExecutor.determine_status(100.0) == 2
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpStorageExecutor.format_bytes/1" do
|
||||
test "B/KB/MB/GB/TB transitions" do
|
||||
assert SnmpStorageExecutor.format_bytes(512) == "512 B"
|
||||
assert SnmpStorageExecutor.format_bytes(2048) == "2.0 KB"
|
||||
assert SnmpStorageExecutor.format_bytes(5_000_000) == "4.77 MB"
|
||||
assert SnmpStorageExecutor.format_bytes(2_000_000_000) == "1.86 GB"
|
||||
assert SnmpStorageExecutor.format_bytes(1_500_000_000_000) == "1.36 TB"
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpStorageExecutor.format_output/3" do
|
||||
test "uses description when present" do
|
||||
storage = %{description: "/home", device_name: "box", storage_index: "1"}
|
||||
data = %{allocation_units: 1024, size_units: 1000, used_units: 500}
|
||||
|
||||
output = SnmpStorageExecutor.format_output(storage, 50.0, data)
|
||||
assert String.contains?(output, "/home")
|
||||
assert String.contains?(output, "50.0%")
|
||||
end
|
||||
|
||||
test "falls back to device_name then Storage #{:index}" do
|
||||
storage = %{description: nil, device_name: "box", storage_index: "3"}
|
||||
data = %{allocation_units: 1, size_units: 10, used_units: 5}
|
||||
|
||||
output = SnmpStorageExecutor.format_output(storage, 50.0, data)
|
||||
assert String.contains?(output, "box")
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpInterfaceExecutor.determine_status/2" do
|
||||
test "up interface is OK" do
|
||||
assert SnmpInterfaceExecutor.determine_status(1, %{if_admin_status: "up"}) == 0
|
||||
end
|
||||
|
||||
test "admin-down interface that is down is OK (expected)" do
|
||||
assert SnmpInterfaceExecutor.determine_status(2, %{if_admin_status: "down"}) == 0
|
||||
end
|
||||
|
||||
test "down interface that should be up is CRITICAL" do
|
||||
assert SnmpInterfaceExecutor.determine_status(2, %{if_admin_status: "up"}) == 2
|
||||
end
|
||||
|
||||
test "testing / dormant is WARNING" do
|
||||
assert SnmpInterfaceExecutor.determine_status(3, %{if_admin_status: "up"}) == 1
|
||||
assert SnmpInterfaceExecutor.determine_status(5, %{if_admin_status: "up"}) == 1
|
||||
end
|
||||
|
||||
test "unknown states default to CRITICAL" do
|
||||
for code <- [4, 6, 7, 99] do
|
||||
assert SnmpInterfaceExecutor.determine_status(code, %{if_admin_status: "up"}) == 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpInterfaceExecutor.oper_status_to_string/1" do
|
||||
test "maps all known values" do
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(1) == "Up"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(2) == "Down"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(3) == "Testing"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(4) == "Unknown"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(5) == "Dormant"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(6) == "Not Present"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(7) == "Lower Layer Down"
|
||||
end
|
||||
|
||||
test "unknown code is 'Unknown Status'" do
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(99) == "Unknown Status"
|
||||
assert SnmpInterfaceExecutor.oper_status_to_string(-1) == "Unknown Status"
|
||||
end
|
||||
end
|
||||
|
||||
describe "SnmpInterfaceExecutor.format_output/2" do
|
||||
test "prefers if_alias when present" do
|
||||
interface = %{if_alias: "WAN", if_name: "eth0", if_descr: "Ethernet 0", if_index: 1}
|
||||
assert "WAN: Up" == SnmpInterfaceExecutor.format_output(interface, 1)
|
||||
end
|
||||
|
||||
test "falls back through if_name, if_descr, then Interface #:index" do
|
||||
assert "eth0: Down" ==
|
||||
SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: "eth0", if_descr: nil, if_index: 0}, 2)
|
||||
|
||||
assert "Ethernet 0: Down" ==
|
||||
SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: nil, if_descr: "Ethernet 0", if_index: 0}, 2)
|
||||
|
||||
assert "Interface 7: Down" ==
|
||||
SnmpInterfaceExecutor.format_output(%{if_alias: nil, if_name: nil, if_descr: nil, if_index: 7}, 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -164,4 +164,61 @@ defmodule Towerops.Monitoring.PingTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "validate_ip_address/1" do
|
||||
test "empty string returns invalid_ip" do
|
||||
assert {:error, :invalid_ip} == Ping.validate_ip_address("")
|
||||
end
|
||||
|
||||
test "valid IPv4 is :ok" do
|
||||
assert :ok == Ping.validate_ip_address("192.168.1.1")
|
||||
assert :ok == Ping.validate_ip_address("0.0.0.0")
|
||||
assert :ok == Ping.validate_ip_address("255.255.255.255")
|
||||
end
|
||||
|
||||
test "valid IPv6 is :ok" do
|
||||
assert :ok == Ping.validate_ip_address("::1")
|
||||
assert :ok == Ping.validate_ip_address("2001:db8::1")
|
||||
end
|
||||
|
||||
test "invalid string returns invalid_ip" do
|
||||
assert {:error, :invalid_ip} == Ping.validate_ip_address("not.an.ip")
|
||||
assert {:error, :invalid_ip} == Ping.validate_ip_address("256.256.256.256")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_ping_output/1" do
|
||||
test "parses macOS format" do
|
||||
output = """
|
||||
PING 127.0.0.1 (127.0.0.1): 56 data bytes
|
||||
round-trip min/avg/max/stddev = 0.050/0.123/0.200/0.075 ms
|
||||
"""
|
||||
|
||||
assert {:ok, 0.123} == Ping.parse_ping_output(output)
|
||||
end
|
||||
|
||||
test "parses Linux format" do
|
||||
output = """
|
||||
--- 127.0.0.1 ping statistics ---
|
||||
rtt min/avg/max/mdev = 0.010/0.025/0.050/0.020 ms
|
||||
"""
|
||||
|
||||
assert {:ok, 0.025} == Ping.parse_ping_output(output)
|
||||
end
|
||||
|
||||
test "parses simple time= format" do
|
||||
output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.123 ms"
|
||||
assert {:ok, 0.123} == Ping.parse_ping_output(output)
|
||||
end
|
||||
|
||||
test "parses time< fallback" do
|
||||
output = "64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time<0.5 ms"
|
||||
assert {:ok, 0.5} == Ping.parse_ping_output(output)
|
||||
end
|
||||
|
||||
test "returns parse_error when no known pattern present" do
|
||||
assert {:error, :parse_error} == Ping.parse_ping_output("total garbage")
|
||||
assert {:error, :parse_error} == Ping.parse_ping_output("")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,4 +62,187 @@ defmodule Towerops.NetBox.ClientTest do
|
|||
assert message =~ "missing or invalid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "normalize_url/1" do
|
||||
test "accepts https URL" do
|
||||
assert {:ok, "https://nb.example.com"} == Client.normalize_url("https://nb.example.com")
|
||||
end
|
||||
|
||||
test "accepts http URL" do
|
||||
assert {:ok, "http://nb.local"} == Client.normalize_url("http://nb.local")
|
||||
end
|
||||
|
||||
test "trims whitespace and trailing slash" do
|
||||
assert {:ok, "https://nb.example.com"} ==
|
||||
Client.normalize_url(" https://nb.example.com/ ")
|
||||
end
|
||||
|
||||
test "rejects non-http schemes" do
|
||||
assert {:error, _} = Client.normalize_url("ftp://nb.example.com")
|
||||
assert {:error, _} = Client.normalize_url("file:///etc/passwd")
|
||||
end
|
||||
|
||||
test "rejects non-binary input" do
|
||||
assert {:error, _} = Client.normalize_url(42)
|
||||
assert {:error, _} = Client.normalize_url(:atom)
|
||||
end
|
||||
end
|
||||
|
||||
@url "https://nb.example.com"
|
||||
@token "abc-token"
|
||||
|
||||
describe "test_connection/2 via HTTP" do
|
||||
test "returns netbox version on success" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.request_path == "/api/status/"
|
||||
assert Plug.Conn.get_req_header(conn, "authorization") == ["Token #{@token}"]
|
||||
|
||||
Req.Test.json(conn, %{"netbox-version" => "3.7.0"})
|
||||
end)
|
||||
|
||||
assert {:ok, message} = Client.test_connection(@url, @token)
|
||||
assert message =~ "3.7.0"
|
||||
end
|
||||
|
||||
test "returns generic success for django-only response" do
|
||||
Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"django-version" => "4.2"}) end)
|
||||
assert {:ok, "Connected to NetBox"} = Client.test_connection(@url, @token)
|
||||
end
|
||||
|
||||
test "returns generic success for unknown body shape" do
|
||||
Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"something" => "else"}) end)
|
||||
assert {:ok, "Connected to NetBox"} = Client.test_connection(@url, @token)
|
||||
end
|
||||
|
||||
test "401 -> authentication failed" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, msg} = Client.test_connection(@url, @token)
|
||||
assert msg =~ "Authentication failed"
|
||||
end
|
||||
|
||||
test "403 -> authentication failed" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, msg} = Client.test_connection(@url, @token)
|
||||
assert msg =~ "Authentication failed"
|
||||
end
|
||||
|
||||
test "404 -> API not found at URL" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, msg} = Client.test_connection(@url, @token)
|
||||
assert msg =~ "not found at this URL"
|
||||
end
|
||||
|
||||
test "5xx -> generic connection failure" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, msg} = Client.test_connection(@url, @token)
|
||||
assert msg =~ "Connection failed"
|
||||
assert msg =~ "500"
|
||||
end
|
||||
end
|
||||
|
||||
describe "list endpoints via HTTP" do
|
||||
test "list_devices/3 hits dcim/devices and passes params" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.request_path == "/api/dcim/devices/"
|
||||
assert conn.query_string =~ "site=foo"
|
||||
Req.Test.json(conn, %{"results" => [%{"id" => 1}]})
|
||||
end)
|
||||
|
||||
assert {:ok, %{"results" => [%{"id" => 1}]}} =
|
||||
Client.list_devices(@url, @token, %{"site" => "foo"})
|
||||
end
|
||||
|
||||
test "list_sites/3 hits dcim/sites" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.request_path == "/api/dcim/sites/"
|
||||
Req.Test.json(conn, %{"results" => []})
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Client.list_sites(@url, @token)
|
||||
end
|
||||
|
||||
test "list_ip_addresses/3 hits ipam endpoint" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.request_path == "/api/ipam/ip-addresses/"
|
||||
Req.Test.json(conn, %{"results" => []})
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Client.list_ip_addresses(@url, @token)
|
||||
end
|
||||
|
||||
test "list_interfaces/3 hits interfaces endpoint" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.request_path == "/api/dcim/interfaces/"
|
||||
Req.Test.json(conn, %{"results" => []})
|
||||
end)
|
||||
|
||||
assert {:ok, _} = Client.list_interfaces(@url, @token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_device/3 via HTTP" do
|
||||
test "POSTs attrs and returns 201 body" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.method == "POST"
|
||||
assert conn.request_path == "/api/dcim/devices/"
|
||||
|
||||
{:ok, body, conn} = Plug.Conn.read_body(conn)
|
||||
payload = Jason.decode!(body)
|
||||
assert payload["name"] == "dev-1"
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_status(201)
|
||||
|> Req.Test.json(%{"id" => 42, "name" => "dev-1"})
|
||||
end)
|
||||
|
||||
assert {:ok, %{"id" => 42}} = Client.upsert_device(@url, @token, %{"name" => "dev-1"})
|
||||
end
|
||||
|
||||
test "accepts 200 status" do
|
||||
Req.Test.stub(Client, fn conn -> Req.Test.json(conn, %{"id" => 42}) end)
|
||||
assert {:ok, %{"id" => 42}} = Client.upsert_device(@url, @token, %{})
|
||||
end
|
||||
|
||||
test "401 -> unauthorized" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{})
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Client.upsert_device(@url, @token, %{})
|
||||
end
|
||||
|
||||
test "other status -> formatted HTTP error" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn |> Plug.Conn.put_status(422) |> Req.Test.json(%{"errors" => ["bad"]})
|
||||
end)
|
||||
|
||||
assert {:error, msg} = Client.upsert_device(@url, @token, %{})
|
||||
assert msg =~ "HTTP 422"
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_site/3 via HTTP" do
|
||||
test "POSTs site attrs" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
assert conn.method == "POST"
|
||||
assert conn.request_path == "/api/dcim/sites/"
|
||||
|
||||
conn |> Plug.Conn.put_status(201) |> Req.Test.json(%{"id" => 99})
|
||||
end)
|
||||
|
||||
assert {:ok, %{"id" => 99}} = Client.upsert_site(@url, @token, %{"name" => "site-1"})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
322
test/towerops/netbox/sync_test.exs
Normal file
322
test/towerops/netbox/sync_test.exs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
defmodule Towerops.NetBox.SyncTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Towerops.NetBox.Sync
|
||||
|
||||
describe "extract_ip/1" do
|
||||
test "returns nil for nil" do
|
||||
assert nil == Sync.extract_ip(nil)
|
||||
end
|
||||
|
||||
test "strips CIDR suffix" do
|
||||
assert "10.0.0.1" == Sync.extract_ip(%{"address" => "10.0.0.1/24"})
|
||||
assert "192.168.1.1" == Sync.extract_ip(%{"address" => "192.168.1.1/32"})
|
||||
end
|
||||
|
||||
test "handles IP without CIDR" do
|
||||
assert "10.0.0.1" == Sync.extract_ip(%{"address" => "10.0.0.1"})
|
||||
end
|
||||
|
||||
test "handles IPv6 with CIDR" do
|
||||
assert "2001:db8::1" == Sync.extract_ip(%{"address" => "2001:db8::1/64"})
|
||||
end
|
||||
|
||||
test "returns nil for map without address key" do
|
||||
assert nil == Sync.extract_ip(%{"other" => "value"})
|
||||
end
|
||||
|
||||
test "returns nil for non-binary address" do
|
||||
assert nil == Sync.extract_ip(%{"address" => 42})
|
||||
end
|
||||
|
||||
test "returns nil for non-map input" do
|
||||
assert nil == Sync.extract_ip("string")
|
||||
assert nil == Sync.extract_ip(42)
|
||||
assert nil == Sync.extract_ip([])
|
||||
end
|
||||
end
|
||||
|
||||
describe "map_status/1" do
|
||||
test "maps known NetBox statuses to towerops statuses" do
|
||||
assert "up" == Sync.map_status("active")
|
||||
assert "down" == Sync.map_status("offline")
|
||||
assert "pending" == Sync.map_status("planned")
|
||||
assert "pending" == Sync.map_status("staged")
|
||||
assert "down" == Sync.map_status("decommissioning")
|
||||
end
|
||||
|
||||
test "passes through unknown statuses unchanged" do
|
||||
assert "custom" == Sync.map_status("custom")
|
||||
assert "inventory" == Sync.map_status("inventory")
|
||||
end
|
||||
|
||||
test "preserves arbitrary strings" do
|
||||
assert "anything" == Sync.map_status("anything")
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_decimal/1" do
|
||||
test "nil passes through" do
|
||||
assert nil == Sync.parse_decimal(nil)
|
||||
end
|
||||
|
||||
test "float converts" do
|
||||
assert Decimal.eq?(Decimal.from_float(1.5), Sync.parse_decimal(1.5))
|
||||
end
|
||||
|
||||
test "integer converts" do
|
||||
assert Decimal.eq?(Decimal.new(42), Sync.parse_decimal(42))
|
||||
end
|
||||
|
||||
test "parseable binary converts" do
|
||||
assert Decimal.eq?(Decimal.new("3.14"), Sync.parse_decimal("3.14"))
|
||||
end
|
||||
|
||||
test "unparseable binary returns nil" do
|
||||
assert nil == Sync.parse_decimal("not a number")
|
||||
assert nil == Sync.parse_decimal("")
|
||||
end
|
||||
|
||||
test "unknown types return nil" do
|
||||
assert nil == Sync.parse_decimal(:atom)
|
||||
assert nil == Sync.parse_decimal([])
|
||||
assert nil == Sync.parse_decimal(%{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_device_params/1" do
|
||||
test "returns empty map when no filters set" do
|
||||
assert %{} == Sync.build_device_params(%{})
|
||||
end
|
||||
|
||||
test "includes role when set" do
|
||||
assert %{"role" => "core-router"} == Sync.build_device_params(%{"device_role_filter" => "core-router"})
|
||||
end
|
||||
|
||||
test "includes site when set" do
|
||||
assert %{"site" => "hq"} == Sync.build_device_params(%{"site_filter" => "hq"})
|
||||
end
|
||||
|
||||
test "includes tag when set" do
|
||||
assert %{"tag" => "production"} == Sync.build_device_params(%{"tag_filter" => "production"})
|
||||
end
|
||||
|
||||
test "includes all when all set" do
|
||||
params =
|
||||
Sync.build_device_params(%{
|
||||
"device_role_filter" => "r",
|
||||
"site_filter" => "s",
|
||||
"tag_filter" => "t"
|
||||
})
|
||||
|
||||
assert params == %{"role" => "r", "site" => "s", "tag" => "t"}
|
||||
end
|
||||
|
||||
test "skips empty strings" do
|
||||
assert %{} ==
|
||||
Sync.build_device_params(%{
|
||||
"device_role_filter" => "",
|
||||
"site_filter" => "",
|
||||
"tag_filter" => ""
|
||||
})
|
||||
end
|
||||
|
||||
test "skips nil values" do
|
||||
assert %{} ==
|
||||
Sync.build_device_params(%{
|
||||
"device_role_filter" => nil,
|
||||
"site_filter" => nil
|
||||
})
|
||||
end
|
||||
|
||||
test "ignores unrelated credential keys" do
|
||||
assert %{"role" => "r"} ==
|
||||
Sync.build_device_params(%{
|
||||
"device_role_filter" => "r",
|
||||
"api_token" => "secret",
|
||||
"url" => "https://..."
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
describe "resolve_site_id/2" do
|
||||
test "returns nil for nil name" do
|
||||
assert nil == Sync.resolve_site_id(nil, %{})
|
||||
end
|
||||
|
||||
test "resolves via lowercased lookup" do
|
||||
site_map = %{"hq" => %{id: "site-uuid-1"}}
|
||||
assert "site-uuid-1" == Sync.resolve_site_id("HQ", site_map)
|
||||
assert "site-uuid-1" == Sync.resolve_site_id("Hq", site_map)
|
||||
assert "site-uuid-1" == Sync.resolve_site_id("hq", site_map)
|
||||
end
|
||||
|
||||
test "returns nil for unknown site name" do
|
||||
assert nil == Sync.resolve_site_id("unknown", %{"hq" => %{id: "x"}})
|
||||
end
|
||||
|
||||
test "returns nil when site map is empty" do
|
||||
assert nil == Sync.resolve_site_id("any", %{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "find_existing_device/4" do
|
||||
test "finds by lowercased name" do
|
||||
dev = %{id: "dev-1"}
|
||||
assert dev == Sync.find_existing_device("Router-01", nil, %{"router-01" => dev}, %{})
|
||||
end
|
||||
|
||||
test "falls back to IP when name misses" do
|
||||
dev = %{id: "dev-2"}
|
||||
|
||||
assert dev ==
|
||||
Sync.find_existing_device("unknown", "10.0.0.1", %{}, %{"10.0.0.1" => dev})
|
||||
end
|
||||
|
||||
test "prefers name match over IP match" do
|
||||
name_dev = %{id: "name-match"}
|
||||
ip_dev = %{id: "ip-match"}
|
||||
|
||||
assert name_dev ==
|
||||
Sync.find_existing_device(
|
||||
"Router",
|
||||
"10.0.0.1",
|
||||
%{"router" => name_dev},
|
||||
%{"10.0.0.1" => ip_dev}
|
||||
)
|
||||
end
|
||||
|
||||
test "returns nil when both miss" do
|
||||
assert nil == Sync.find_existing_device("x", "1.2.3.4", %{}, %{})
|
||||
end
|
||||
|
||||
test "returns nil when name misses and IP is nil" do
|
||||
assert nil == Sync.find_existing_device("x", nil, %{}, %{"1.2.3.4" => %{}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: extract_ip round-trip with random CIDR suffixes" do
|
||||
property "always returns the portion before the first /" do
|
||||
check all(
|
||||
a <- integer(0..255),
|
||||
b <- integer(0..255),
|
||||
c <- integer(0..255),
|
||||
d <- integer(0..255),
|
||||
cidr <- integer(0..32)
|
||||
) do
|
||||
ip = "#{a}.#{b}.#{c}.#{d}"
|
||||
assert ip == Sync.extract_ip(%{"address" => "#{ip}/#{cidr}"})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: parse_decimal/1 idempotence" do
|
||||
property "non-negative floats round-trip via Decimal.to_string" do
|
||||
check all(n <- float(min: 0.0, max: 1.0e6)) do
|
||||
decimal = Sync.parse_decimal(n)
|
||||
assert %Decimal{} = decimal
|
||||
# Round-trip through string preserves value
|
||||
round_tripped = decimal |> Decimal.to_string() |> Sync.parse_decimal()
|
||||
assert Decimal.eq?(decimal, round_tripped)
|
||||
end
|
||||
end
|
||||
|
||||
property "integers produce Decimal with matching to_integer value" do
|
||||
check all(n <- integer(-1_000_000..1_000_000)) do
|
||||
decimal = Sync.parse_decimal(n)
|
||||
assert Decimal.to_integer(decimal) == n
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: map_status/1 is idempotent on towerops statuses" do
|
||||
property "already-towerops statuses pass through (since they don't match)" do
|
||||
check all(status <- member_of(["up", "down", "pending", "unknown", "custom"])) do
|
||||
# map_status is a one-way mapping; applied twice to its output yields same value
|
||||
once = Sync.map_status(status)
|
||||
twice = Sync.map_status(once)
|
||||
assert once == twice
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "site_id_from/1" do
|
||||
test "nil returns nil" do
|
||||
assert nil == Sync.site_id_from(nil)
|
||||
end
|
||||
|
||||
test "extracts :id field from a map" do
|
||||
assert "uuid" == Sync.site_id_from(%{id: "uuid"})
|
||||
end
|
||||
end
|
||||
|
||||
describe "lookup_by_ip/2" do
|
||||
test "nil ip returns nil regardless of map" do
|
||||
assert nil == Sync.lookup_by_ip(nil, %{"1.2.3.4" => %{id: "x"}})
|
||||
end
|
||||
|
||||
test "hits the map by ip" do
|
||||
dev = %{id: "dev-1"}
|
||||
assert dev == Sync.lookup_by_ip("10.0.0.1", %{"10.0.0.1" => dev})
|
||||
end
|
||||
|
||||
test "miss returns nil" do
|
||||
assert nil == Sync.lookup_by_ip("99.99.99.99", %{"10.0.0.1" => %{}})
|
||||
end
|
||||
end
|
||||
|
||||
describe "maybe_put/3" do
|
||||
test "ignores nil values" do
|
||||
assert %{} == Sync.maybe_put(%{}, "k", nil)
|
||||
end
|
||||
|
||||
test "ignores empty strings" do
|
||||
assert %{} == Sync.maybe_put(%{}, "k", "")
|
||||
end
|
||||
|
||||
test "adds non-empty value" do
|
||||
assert %{"k" => "v"} == Sync.maybe_put(%{}, "k", "v")
|
||||
end
|
||||
|
||||
test "preserves existing keys" do
|
||||
assert %{"a" => 1, "b" => 2} == Sync.maybe_put(%{"a" => 1}, "b", 2)
|
||||
end
|
||||
end
|
||||
|
||||
describe "build_site_params/1" do
|
||||
test "empty map without tag_filter" do
|
||||
assert %{} == Sync.build_site_params(%{})
|
||||
assert %{} == Sync.build_site_params(%{"tag_filter" => nil})
|
||||
assert %{} == Sync.build_site_params(%{"tag_filter" => ""})
|
||||
end
|
||||
|
||||
test "returns tag param when present" do
|
||||
assert %{"tag" => "production"} == Sync.build_site_params(%{"tag_filter" => "production"})
|
||||
end
|
||||
|
||||
test "ignores unrelated credential keys" do
|
||||
assert %{"tag" => "t"} ==
|
||||
Sync.build_site_params(%{"tag_filter" => "t", "url" => "u", "api_token" => "x"})
|
||||
end
|
||||
end
|
||||
|
||||
describe "humanize_sync_error/1" do
|
||||
test "unauthorized message" do
|
||||
assert Sync.humanize_sync_error(:unauthorized) =~ "Authentication failed"
|
||||
end
|
||||
|
||||
test "not_found message" do
|
||||
assert Sync.humanize_sync_error(:not_found) =~ "API endpoint not found"
|
||||
end
|
||||
|
||||
test "SSL cert error mentions certificate" do
|
||||
msg = Sync.humanize_sync_error("SSL: CA trust store missing")
|
||||
assert msg =~ "SSL certificate"
|
||||
end
|
||||
|
||||
test "generic binary returns generic unexpected-error message" do
|
||||
assert Sync.humanize_sync_error("some random failure") =~ "unexpected error"
|
||||
end
|
||||
end
|
||||
end
|
||||
54
test/towerops/profiles/mib_cache_test.exs
Normal file
54
test/towerops/profiles/mib_cache_test.exs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
defmodule Towerops.Profiles.MibCacheTest do
|
||||
@moduledoc """
|
||||
Tests for the MibCache GenServer. In the test env, resolution is skipped via
|
||||
compile-time check, so lookups either return cached hits or strip MODULE:: prefix.
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Towerops.Profiles.MibCache
|
||||
|
||||
setup do
|
||||
# Ensure ETS table exists (GenServer is started via the application)
|
||||
case Process.whereis(MibCache) do
|
||||
nil ->
|
||||
{:ok, _pid} = MibCache.start_link([])
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "lookup/1" do
|
||||
test "returns cached oid when hit" do
|
||||
:ets.insert(:mib_cache, {"ifDescr", "1.3.6.1.2.1.2.2.1.2"})
|
||||
assert "1.3.6.1.2.1.2.2.1.2" == MibCache.lookup("ifDescr")
|
||||
end
|
||||
|
||||
test "strips MODULE:: prefix on cache miss in test env" do
|
||||
# Ensure key not cached
|
||||
:ets.delete(:mib_cache, "IF-MIB::ifName")
|
||||
assert "ifName" == MibCache.lookup("IF-MIB::ifName")
|
||||
end
|
||||
|
||||
test "returns original name when no module prefix and cache miss" do
|
||||
:ets.delete(:mib_cache, "plainMib")
|
||||
assert "plainMib" == MibCache.lookup("plainMib")
|
||||
end
|
||||
end
|
||||
|
||||
describe "size/0" do
|
||||
test "returns current ETS table size" do
|
||||
:ets.insert(:mib_cache, {"size_test_#{System.unique_integer([:positive])}", "1.1.1"})
|
||||
assert is_integer(MibCache.size())
|
||||
assert MibCache.size() >= 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "pre_resolve/1" do
|
||||
test "returns :ok (fire-and-forget)" do
|
||||
assert :ok == MibCache.pre_resolve(["ifName", "sysDescr"])
|
||||
end
|
||||
end
|
||||
end
|
||||
53
test/towerops/profiles/profile_watcher_test.exs
Normal file
53
test/towerops/profiles/profile_watcher_test.exs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
defmodule Towerops.Profiles.ProfileWatcherTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Profiles.ProfileWatcher
|
||||
|
||||
describe "yaml_file?/1" do
|
||||
test "matches .yaml extension" do
|
||||
assert ProfileWatcher.yaml_file?("/tmp/x/foo.yaml")
|
||||
end
|
||||
|
||||
test "matches .yml extension" do
|
||||
assert ProfileWatcher.yaml_file?("/tmp/x/foo.yml")
|
||||
end
|
||||
|
||||
test "rejects unrelated extensions" do
|
||||
refute ProfileWatcher.yaml_file?("/tmp/x/foo.json")
|
||||
refute ProfileWatcher.yaml_file?("/tmp/x/foo")
|
||||
refute ProfileWatcher.yaml_file?("/tmp/x/foo.exs")
|
||||
end
|
||||
end
|
||||
|
||||
describe "should_reload?/1" do
|
||||
test "reloads on :created" do
|
||||
assert ProfileWatcher.should_reload?([:created])
|
||||
end
|
||||
|
||||
test "reloads on :modified" do
|
||||
assert ProfileWatcher.should_reload?([:modified])
|
||||
end
|
||||
|
||||
test "reloads on :removed" do
|
||||
assert ProfileWatcher.should_reload?([:removed])
|
||||
end
|
||||
|
||||
test "reloads on :renamed" do
|
||||
assert ProfileWatcher.should_reload?([:renamed])
|
||||
end
|
||||
|
||||
test "reloads if any of several events matches" do
|
||||
assert ProfileWatcher.should_reload?([:undefined, :modified])
|
||||
end
|
||||
|
||||
test "does not reload on unrelated events" do
|
||||
refute ProfileWatcher.should_reload?([:undefined])
|
||||
refute ProfileWatcher.should_reload?([])
|
||||
end
|
||||
|
||||
test "non-list input returns false" do
|
||||
refute ProfileWatcher.should_reload?(nil)
|
||||
refute ProfileWatcher.should_reload?(:created)
|
||||
end
|
||||
end
|
||||
end
|
||||
107
test/towerops/reports/notifier_test.exs
Normal file
107
test/towerops/reports/notifier_test.exs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
defmodule Towerops.Reports.NotifierTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
import Swoosh.TestAssertions
|
||||
|
||||
alias Towerops.Reports.Notifier
|
||||
alias Towerops.Reports.Report
|
||||
|
||||
describe "deliver_report/2" do
|
||||
test "sends email with csv attachment to single recipient" do
|
||||
report = %Report{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Monthly Uptime",
|
||||
report_type: "uptime_summary",
|
||||
recipients: ["ops@example.com"]
|
||||
}
|
||||
|
||||
assert :ok = Notifier.deliver_report(report, "col1,col2\nfoo,bar\n")
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
assert email.to == [{"", "ops@example.com"}]
|
||||
assert email.subject =~ "Uptime Summary"
|
||||
assert email.subject =~ "Monthly Uptime"
|
||||
assert email.text_body =~ "Monthly Uptime"
|
||||
|
||||
assert [attachment] = email.attachments
|
||||
assert attachment.content_type == "text/csv"
|
||||
assert attachment.filename =~ "uptime_summary_"
|
||||
assert attachment.filename =~ ".csv"
|
||||
end)
|
||||
end
|
||||
|
||||
test "sends email to all recipients" do
|
||||
report = %Report{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Alert History",
|
||||
report_type: "alert_history",
|
||||
recipients: ["a@example.com", "b@example.com", "c@example.com"]
|
||||
}
|
||||
|
||||
assert :ok = Notifier.deliver_report(report, "data\n")
|
||||
|
||||
for recipient <- report.recipients do
|
||||
assert_email_sent(fn email ->
|
||||
assert email.to == [{"", recipient}]
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
test "humanizes known report types in subject" do
|
||||
cases = [
|
||||
{"uptime_summary", "Uptime Summary"},
|
||||
{"alert_history", "Alert History"},
|
||||
{"capacity_trends", "Capacity Trends"},
|
||||
{"rf_link_health", "RF Link Health"}
|
||||
]
|
||||
|
||||
for {type, expected} <- cases do
|
||||
report = %Report{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Test",
|
||||
report_type: type,
|
||||
recipients: ["test@example.com"]
|
||||
}
|
||||
|
||||
assert :ok = Notifier.deliver_report(report, "data")
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
assert email.subject =~ expected
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
test "passes through unknown report types verbatim" do
|
||||
report = %Report{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Custom",
|
||||
report_type: "custom_thing",
|
||||
recipients: ["test@example.com"]
|
||||
}
|
||||
|
||||
assert :ok = Notifier.deliver_report(report, "data")
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
assert email.subject =~ "custom_thing"
|
||||
end)
|
||||
end
|
||||
|
||||
test "filename includes today's date in ISO8601 format" do
|
||||
report = %Report{
|
||||
id: Ecto.UUID.generate(),
|
||||
name: "Test",
|
||||
report_type: "uptime_summary",
|
||||
recipients: ["test@example.com"]
|
||||
}
|
||||
|
||||
today = Date.to_iso8601(Date.utc_today())
|
||||
|
||||
assert :ok = Notifier.deliver_report(report, "data")
|
||||
|
||||
assert_email_sent(fn email ->
|
||||
[attachment] = email.attachments
|
||||
assert attachment.filename == "uptime_summary_#{today}.csv"
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue