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:
Graham McIntire 2026-04-24 09:48:43 -05:00
parent 36e58d4d9f
commit 3ca0834ef0
154 changed files with 12212 additions and 4429 deletions

View file

@ -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),

View file

@ -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 ->

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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
%{

View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 --

View file

@ -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

View 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

View file

@ -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)

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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 =

View file

@ -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"},

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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
View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View file

@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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