parent
e54ee75513
commit
c8b275bd4c
21 changed files with 1008 additions and 359 deletions
|
|
@ -12,6 +12,7 @@ defmodule Towerops.Capacity.Resolver do
|
|||
alias Towerops.Repo
|
||||
alias Towerops.Snmp.Sensor
|
||||
|
||||
@gleam :towerops@capacity@resolver
|
||||
@capacity_sensor_types ~w(capacity rate)
|
||||
|
||||
@doc """
|
||||
|
|
@ -51,30 +52,11 @@ defmodule Towerops.Capacity.Resolver do
|
|||
def normalize_sensor_to_bps(%Sensor{last_value: v}) when v <= 0, do: nil
|
||||
|
||||
def normalize_sensor_to_bps(%Sensor{last_value: value, sensor_unit: unit, sensor_divisor: divisor}) do
|
||||
adjusted = value / (divisor || 1)
|
||||
|
||||
bps =
|
||||
case normalize_unit(unit) do
|
||||
:bps -> adjusted
|
||||
:kbps -> adjusted * 1_000
|
||||
:mbps -> adjusted * 1_000_000
|
||||
:gbps -> adjusted * 1_000_000_000
|
||||
_ -> adjusted
|
||||
end
|
||||
|
||||
trunc(bps)
|
||||
end
|
||||
|
||||
defp normalize_unit(nil), do: :bps
|
||||
|
||||
defp normalize_unit(unit) when is_binary(unit) do
|
||||
case String.downcase(unit) do
|
||||
"bps" -> :bps
|
||||
"kbps" -> :kbps
|
||||
"mbps" -> :mbps
|
||||
"gbps" -> :gbps
|
||||
_ -> :bps
|
||||
end
|
||||
@gleam.normalize_to_bps(
|
||||
value / 1.0,
|
||||
unit || "",
|
||||
(divisor || 1) / 1.0
|
||||
)
|
||||
end
|
||||
|
||||
defp resolve_manual(%{capacity_source: "manual", configured_capacity_bps: bps}) when is_integer(bps) and bps > 0 do
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
|
||||
require Logger
|
||||
|
||||
@gleam :towerops@config_changes@correlator
|
||||
|
||||
@pre_window_hours 2
|
||||
@post_window_hours 4
|
||||
|
||||
|
|
@ -100,10 +102,10 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
|
||||
defp evaluate_impact(event, pre, post) do
|
||||
deltas = %{
|
||||
latency_delta: safe_pct_change(pre.avg_latency, post.avg_latency),
|
||||
jitter_delta: safe_pct_change(pre.avg_jitter, post.avg_jitter),
|
||||
loss_delta: safe_pct_change(pre.avg_loss, post.avg_loss),
|
||||
throughput_delta: safe_pct_change(pre.avg_throughput, post.avg_throughput)
|
||||
latency_delta: @gleam.safe_pct_change(to_float(pre.avg_latency), to_float(post.avg_latency)),
|
||||
jitter_delta: @gleam.safe_pct_change(to_float(pre.avg_jitter), to_float(post.avg_jitter)),
|
||||
loss_delta: @gleam.safe_pct_change(to_float(pre.avg_loss), to_float(post.avg_loss)),
|
||||
throughput_delta: @gleam.safe_pct_change(to_float(pre.avg_throughput), to_float(post.avg_throughput))
|
||||
}
|
||||
|
||||
significant? =
|
||||
|
|
@ -148,26 +150,28 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
end
|
||||
|
||||
defp determine_severity(deltas) do
|
||||
cond do
|
||||
deltas.latency_delta > 0.50 || deltas.loss_delta > 1.0 || deltas.throughput_delta < -0.30 ->
|
||||
"critical"
|
||||
|
||||
deltas.latency_delta > @latency_increase_threshold ||
|
||||
deltas.loss_delta > @loss_increase_threshold ||
|
||||
deltas.throughput_delta < -@throughput_drop_threshold ->
|
||||
"warning"
|
||||
|
||||
true ->
|
||||
"info"
|
||||
end
|
||||
@gleam.determine_severity(
|
||||
deltas.latency_delta / 1.0,
|
||||
deltas.loss_delta / 1.0,
|
||||
deltas.throughput_delta / 1.0
|
||||
)
|
||||
end
|
||||
|
||||
defp build_description(deltas, event) do
|
||||
parts =
|
||||
[]
|
||||
|> maybe_add("latency +#{round(deltas.latency_delta * 100)}%", deltas.latency_delta > 0.10)
|
||||
|> maybe_add("loss +#{round(deltas.loss_delta * 100)}%", deltas.loss_delta > 0.10)
|
||||
|> maybe_add("throughput #{round(deltas.throughput_delta * 100)}%", deltas.throughput_delta < -0.05)
|
||||
|> @gleam.maybe_add(
|
||||
"latency +#{round(deltas.latency_delta * 100)}%",
|
||||
deltas.latency_delta > 0.10
|
||||
)
|
||||
|> @gleam.maybe_add(
|
||||
"loss +#{round(deltas.loss_delta * 100)}%",
|
||||
deltas.loss_delta > 0.10
|
||||
)
|
||||
|> @gleam.maybe_add(
|
||||
"throughput #{round(deltas.throughput_delta * 100)}%",
|
||||
deltas.throughput_delta < -0.05
|
||||
)
|
||||
|
||||
sections =
|
||||
if Enum.any?(event.sections_changed),
|
||||
|
|
@ -177,14 +181,9 @@ defmodule Towerops.ConfigChanges.Correlator do
|
|||
"After config change#{sections}: #{Enum.join(parts, ", ")}."
|
||||
end
|
||||
|
||||
defp maybe_add(list, text, true), do: list ++ [text]
|
||||
defp maybe_add(list, _text, false), do: list
|
||||
|
||||
defp safe_pct_change(old, new) when is_number(old) and is_number(new) and old > 0 do
|
||||
(new - old) / old
|
||||
end
|
||||
|
||||
defp safe_pct_change(_, _), do: 0.0
|
||||
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
|
||||
|
||||
defp stringify_metrics(metrics) do
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ defmodule Towerops.EctoTypes.IpAddress do
|
|||
@derive {Jason.Encoder, only: [:address]}
|
||||
defstruct [:address, :version, :tuple]
|
||||
|
||||
# Gleam module for pure parsing/validation functions
|
||||
@gleam :towerops@ecto_types@ip_address
|
||||
|
||||
@impl Ecto.Type
|
||||
def type, do: :string
|
||||
|
||||
|
|
@ -192,36 +195,19 @@ defmodule Towerops.EctoTypes.IpAddress do
|
|||
@spec to_tuple(t()) :: ip_tuple()
|
||||
def to_tuple(%__MODULE__{tuple: tuple}), do: tuple
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp parse_string(""), do: :error
|
||||
# Private helpers — parse_string, detect_version, strip_zone_id delegate to Gleam
|
||||
|
||||
defp parse_string(value) when is_binary(value) do
|
||||
# Strip IPv6 zone ID if present (e.g., "fe80::1%eth0" -> "fe80::1")
|
||||
cleaned = strip_zone_id(value)
|
||||
|
||||
# Reject CIDR notation (should use separate prefix_length field)
|
||||
if String.contains?(cleaned, "/") do
|
||||
:error
|
||||
else
|
||||
# Parse using Erlang's inet module
|
||||
case cleaned |> String.to_charlist() |> :inet.parse_address() do
|
||||
{:ok, ip_tuple} -> {:ok, cleaned, ip_tuple}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
case @gleam.parse_string(value) do
|
||||
{:ok, {address, ip_tuple}} -> {:ok, address, ip_tuple}
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp strip_zone_id(value) do
|
||||
case String.split(value, "%", parts: 2) do
|
||||
[ip, _zone] -> ip
|
||||
[ip] -> ip
|
||||
end
|
||||
defp detect_version(ip_tuple) when is_tuple(ip_tuple) do
|
||||
@gleam.detect_version(tuple_size(ip_tuple))
|
||||
end
|
||||
|
||||
defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 4, do: :ipv4
|
||||
defp detect_version(ip_tuple) when tuple_size(ip_tuple) == 8, do: :ipv6
|
||||
|
||||
defp tuple_to_string(ip_tuple) when is_tuple(ip_tuple) do
|
||||
case :inet.ntoa(ip_tuple) do
|
||||
{:error, _} -> :error
|
||||
|
|
|
|||
|
|
@ -51,6 +51,9 @@ defmodule Towerops.EctoTypes.MacAddress do
|
|||
MAC addresses are stored as VARCHAR(17) in the database (sufficient for
|
||||
colon-separated format). No database migrations are required when adopting
|
||||
this custom type - Ecto handles conversion transparently.
|
||||
|
||||
Parsing and formatting logic is implemented in Gleam at
|
||||
`src/towerops/ecto_types/mac_address.gleam`.
|
||||
"""
|
||||
use Ecto.Type
|
||||
|
||||
|
|
@ -62,11 +65,8 @@ defmodule Towerops.EctoTypes.MacAddress do
|
|||
@derive {Jason.Encoder, only: [:address]}
|
||||
defstruct [:address, :binary]
|
||||
|
||||
# Regex patterns for different MAC address formats
|
||||
@colon_format ~r/^([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2}):([0-9a-fA-F]{2})$/
|
||||
@hyphen_format ~r/^([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})-([0-9a-fA-F]{2})$/
|
||||
@dot_format ~r/^([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})\.([0-9a-fA-F]{4})$/
|
||||
@compact_format ~r/^([0-9a-fA-F]{12})$/
|
||||
# Gleam module for pure parsing/formatting functions
|
||||
@gleam :towerops@ecto_types@mac_address
|
||||
|
||||
@impl Ecto.Type
|
||||
def type, do: :string
|
||||
|
|
@ -82,30 +82,19 @@ defmodule Towerops.EctoTypes.MacAddress do
|
|||
def cast(binary) when is_binary(binary) and byte_size(binary) == 6 do
|
||||
# Check if it's a 6-byte binary (not a 6-character string)
|
||||
if String.printable?(binary) do
|
||||
# It's a short string, try string parsing
|
||||
case parse_string(binary) do
|
||||
{:ok, normalized, bin} ->
|
||||
{:ok, %__MODULE__{address: normalized, binary: bin}}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
# It's a short string, try string parsing via Gleam
|
||||
cast_from_string(binary)
|
||||
else
|
||||
# It's a binary MAC address (SNMP format)
|
||||
normalized = binary_to_colon_string(binary)
|
||||
bytes = :erlang.binary_to_list(binary)
|
||||
normalized = @gleam.format_colon(bytes)
|
||||
{:ok, %__MODULE__{address: normalized, binary: binary}}
|
||||
end
|
||||
end
|
||||
|
||||
# Cast from string representation
|
||||
def cast(value) when is_binary(value) do
|
||||
case parse_string(value) do
|
||||
{:ok, normalized, binary} ->
|
||||
{:ok, %__MODULE__{address: normalized, binary: binary}}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
cast_from_string(value)
|
||||
end
|
||||
|
||||
def cast(_), do: :error
|
||||
|
|
@ -206,130 +195,27 @@ defmodule Towerops.EctoTypes.MacAddress do
|
|||
"""
|
||||
@spec format(t(), :colon | :hyphen | :dot | :compact) :: String.t()
|
||||
def format(%__MODULE__{binary: binary}, format_type) do
|
||||
bytes = :erlang.binary_to_list(binary)
|
||||
|
||||
case format_type do
|
||||
:colon -> binary_to_colon_string(binary)
|
||||
:hyphen -> binary_to_hyphen_string(binary)
|
||||
:dot -> binary_to_dot_string(binary)
|
||||
:compact -> binary_to_compact_string(binary)
|
||||
:colon -> @gleam.format_colon(bytes)
|
||||
:hyphen -> @gleam.format_hyphen(bytes)
|
||||
:dot -> @gleam.format_dot(bytes)
|
||||
:compact -> @gleam.format_compact(bytes)
|
||||
end
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp parse_string(""), do: :error
|
||||
defp cast_from_string(value) do
|
||||
case @gleam.parse_string(value) do
|
||||
{:ok, bytes} ->
|
||||
binary = :erlang.list_to_binary(bytes)
|
||||
normalized = @gleam.format_colon(bytes)
|
||||
{:ok, %__MODULE__{address: normalized, binary: binary}}
|
||||
|
||||
defp parse_string(value) when is_binary(value) do
|
||||
value
|
||||
|> String.trim()
|
||||
|> try_parse_formats()
|
||||
end
|
||||
|
||||
defp try_parse_formats(value) do
|
||||
cond do
|
||||
Regex.match?(@colon_format, value) -> parse_colon_format(value)
|
||||
Regex.match?(@hyphen_format, value) -> parse_hyphen_format(value)
|
||||
Regex.match?(@dot_format, value) -> parse_dot_format(value)
|
||||
Regex.match?(@compact_format, value) -> parse_compact_format(value)
|
||||
true -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_colon_format(value) do
|
||||
case Regex.run(@colon_format, value) do
|
||||
[_, o1, o2, o3, o4, o5, o6] ->
|
||||
build_mac_address([o1, o2, o3, o4, o5, o6])
|
||||
|
||||
_ ->
|
||||
{:error, _} ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_hyphen_format(value) do
|
||||
case Regex.run(@hyphen_format, value) do
|
||||
[_, o1, o2, o3, o4, o5, o6] ->
|
||||
build_mac_address([o1, o2, o3, o4, o5, o6])
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_dot_format(value) do
|
||||
case Regex.run(@dot_format, value) do
|
||||
[_, g1, g2, g3] ->
|
||||
# Split each group into 2-character octets
|
||||
<<o1::binary-2, o2::binary-2>> = g1
|
||||
<<o3::binary-2, o4::binary-2>> = g2
|
||||
<<o5::binary-2, o6::binary-2>> = g3
|
||||
build_mac_address([o1, o2, o3, o4, o5, o6])
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_compact_format(value) do
|
||||
case Regex.run(@compact_format, value) do
|
||||
[_, mac] ->
|
||||
# Split into 2-character octets
|
||||
<<o1::binary-2, o2::binary-2, o3::binary-2, o4::binary-2, o5::binary-2, o6::binary-2>> =
|
||||
mac
|
||||
|
||||
build_mac_address([o1, o2, o3, o4, o5, o6])
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp build_mac_address(octets) do
|
||||
# Convert hex strings to integers
|
||||
with {:ok, bytes} <- parse_hex_octets(octets) do
|
||||
binary = :erlang.list_to_binary(bytes)
|
||||
normalized = binary_to_colon_string(binary)
|
||||
{:ok, normalized, binary}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_hex_octets(octets) do
|
||||
bytes =
|
||||
Enum.map(octets, fn octet ->
|
||||
{value, ""} = Integer.parse(String.downcase(octet), 16)
|
||||
value
|
||||
end)
|
||||
|
||||
{:ok, bytes}
|
||||
rescue
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
defp binary_to_colon_string(<<o1, o2, o3, o4, o5, o6>>) do
|
||||
Enum.map_join([o1, o2, o3, o4, o5, o6], ":", &format_octet/1)
|
||||
end
|
||||
|
||||
defp binary_to_hyphen_string(<<o1, o2, o3, o4, o5, o6>>) do
|
||||
Enum.map_join([o1, o2, o3, o4, o5, o6], "-", &format_octet/1)
|
||||
end
|
||||
|
||||
defp binary_to_dot_string(<<o1, o2, o3, o4, o5, o6>>) do
|
||||
Enum.join(
|
||||
[
|
||||
format_octet(o1) <> format_octet(o2),
|
||||
format_octet(o3) <> format_octet(o4),
|
||||
format_octet(o5) <> format_octet(o6)
|
||||
],
|
||||
"."
|
||||
)
|
||||
end
|
||||
|
||||
defp binary_to_compact_string(<<o1, o2, o3, o4, o5, o6>>) do
|
||||
Enum.map_join([o1, o2, o3, o4, o5, o6], "", &format_octet/1)
|
||||
end
|
||||
|
||||
defp format_octet(byte) do
|
||||
byte
|
||||
|> Integer.to_string(16)
|
||||
|> String.downcase()
|
||||
|> String.pad_leading(2, "0")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -72,9 +72,8 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
@derive {Jason.Encoder, only: [:oid, :named]}
|
||||
defstruct [:oid, :named, :parts]
|
||||
|
||||
# OID validation patterns
|
||||
@numeric_oid_pattern ~r/^\.?([0-9]+)(\.([0-9]+))*$/
|
||||
@named_oid_pattern ~r/^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?((\.[a-zA-Z0-9_-]+)|(\.[0-9]+))*$/
|
||||
# Gleam module for pure OID parsing/manipulation logic
|
||||
@gleam :towerops@ecto_types@snmp_oid
|
||||
|
||||
@impl Ecto.Type
|
||||
def type, do: :string
|
||||
|
|
@ -91,7 +90,7 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
case parse_oid(value) do
|
||||
{:ok, normalized_oid, parts} ->
|
||||
# Try to resolve if it's a named OID
|
||||
named = if is_named_oid?(value), do: value
|
||||
named = if @gleam.is_named_oid(value), do: value
|
||||
{:ok, %__MODULE__{oid: normalized_oid, named: named, parts: parts}}
|
||||
|
||||
:error ->
|
||||
|
|
@ -102,7 +101,7 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
# Cast from integer list (OID parts)
|
||||
def cast(parts) when is_list(parts) do
|
||||
if Enum.all?(parts, &is_integer/1) and Enum.all?(parts, &(&1 >= 0)) do
|
||||
normalized = "." <> Enum.join(parts, ".")
|
||||
normalized = @gleam.parts_to_string(parts)
|
||||
{:ok, %__MODULE__{oid: normalized, named: nil, parts: parts}}
|
||||
else
|
||||
:error
|
||||
|
|
@ -203,8 +202,8 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
"""
|
||||
@spec parent(t()) :: t()
|
||||
def parent(%__MODULE__{parts: parts}) when length(parts) > 1 do
|
||||
parent_parts = Enum.slice(parts, 0..-2//1)
|
||||
parent_oid = "." <> Enum.join(parent_parts, ".")
|
||||
parent_parts = @gleam.parent_parts(parts)
|
||||
parent_oid = @gleam.parts_to_string(parent_parts)
|
||||
%__MODULE__{oid: parent_oid, named: nil, parts: parent_parts}
|
||||
end
|
||||
|
||||
|
|
@ -222,8 +221,7 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
"""
|
||||
@spec child_of?(t(), t()) :: boolean()
|
||||
def child_of?(%__MODULE__{parts: child_parts}, %__MODULE__{parts: parent_parts}) do
|
||||
length(child_parts) > length(parent_parts) and
|
||||
List.starts_with?(child_parts, parent_parts)
|
||||
@gleam.child_of(child_parts, parent_parts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -238,8 +236,8 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
"""
|
||||
@spec append(t(), [non_neg_integer()]) :: t()
|
||||
def append(%__MODULE__{parts: parts}, additional_parts) when is_list(additional_parts) do
|
||||
new_parts = parts ++ additional_parts
|
||||
new_oid = "." <> Enum.join(new_parts, ".")
|
||||
new_parts = @gleam.append_parts(parts, additional_parts)
|
||||
new_oid = @gleam.parts_to_string(new_parts)
|
||||
%__MODULE__{oid: new_oid, named: nil, parts: new_parts}
|
||||
end
|
||||
|
||||
|
|
@ -261,7 +259,7 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
# Try SnmpKit resolution
|
||||
case SnmpKit.resolve(oid_string) do
|
||||
{:ok, resolved} ->
|
||||
{:ok, ensure_leading_dot(resolved)}
|
||||
{:ok, @gleam.ensure_leading_dot(resolved)}
|
||||
|
||||
{:error, _} = error ->
|
||||
resolve_numeric_oid(oid_string, error)
|
||||
|
|
@ -269,17 +267,13 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
end
|
||||
|
||||
defp resolve_numeric_oid(oid_string, error) do
|
||||
if Regex.match?(@numeric_oid_pattern, oid_string) do
|
||||
{:ok, ensure_leading_dot(oid_string)}
|
||||
if @gleam.is_numeric_oid(oid_string) do
|
||||
{:ok, @gleam.ensure_leading_dot(oid_string)}
|
||||
else
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_leading_dot(oid_string) do
|
||||
if String.starts_with?(oid_string, "."), do: oid_string, else: "." <> oid_string
|
||||
end
|
||||
|
||||
# Private helpers
|
||||
|
||||
defp parse_oid(""), do: :error
|
||||
|
|
@ -288,48 +282,19 @@ defmodule Towerops.EctoTypes.SnmpOid do
|
|||
value = String.trim(value)
|
||||
|
||||
cond do
|
||||
Regex.match?(@numeric_oid_pattern, value) ->
|
||||
parse_numeric_oid(value)
|
||||
|
||||
Regex.match?(@named_oid_pattern, value) ->
|
||||
# Try to resolve named OID to numeric
|
||||
case resolve(value) do
|
||||
{:ok, resolved} -> parse_numeric_oid(resolved)
|
||||
{:error, _} -> :error
|
||||
end
|
||||
|
||||
true ->
|
||||
:error
|
||||
@gleam.is_numeric_oid(value) -> unwrap_parsed(@gleam.parse_numeric_oid(value))
|
||||
@gleam.is_named_oid(value) -> resolve_and_parse(value)
|
||||
true -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_numeric_oid(value) do
|
||||
# Strip leading dot if present
|
||||
value = String.trim_leading(value, ".")
|
||||
defp unwrap_parsed({:ok, {normalized, parts}}), do: {:ok, normalized, parts}
|
||||
defp unwrap_parsed({:error, _}), do: :error
|
||||
|
||||
# Split into parts and validate
|
||||
case String.split(value, ".") do
|
||||
[""] ->
|
||||
:error
|
||||
|
||||
parts ->
|
||||
try do
|
||||
int_parts = Enum.map(parts, &String.to_integer/1)
|
||||
|
||||
if Enum.all?(int_parts, &(&1 >= 0)) do
|
||||
normalized = "." <> Enum.join(int_parts, ".")
|
||||
{:ok, normalized, int_parts}
|
||||
else
|
||||
:error
|
||||
end
|
||||
rescue
|
||||
_ -> :error
|
||||
end
|
||||
defp resolve_and_parse(value) do
|
||||
case resolve(value) do
|
||||
{:ok, resolved} -> unwrap_parsed(@gleam.parse_numeric_oid(resolved))
|
||||
{:error, _} -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp is_named_oid?(value) when is_binary(value) do
|
||||
Regex.match?(@named_oid_pattern, value) and
|
||||
not Regex.match?(@numeric_oid_pattern, value)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,19 +6,38 @@ defmodule Towerops.Organizations.Policy do
|
|||
|
||||
| Role | Write | Financials | Description |
|
||||
|--------------|-------|------------|------------------------------------------|
|
||||
| `:owner` | ✅ | ✅ | Full access, can delete organization |
|
||||
| `:admin` | ✅ | ✅ | Full access except deleting organization |
|
||||
| `:executive` | ❌ | ✅ | Read-only with full financial visibility |
|
||||
| `:technician`| ✅* | ❌ | Field ops: devices, sites, alerts only |
|
||||
| `:member` | ✅* | ❌ | Legacy alias for technician |
|
||||
| `:viewer` | ❌ | ❌ | Read-only, no financial data |
|
||||
| `:owner` | Yes | Yes | Full access, can delete organization |
|
||||
| `:admin` | Yes | Yes | Full access except deleting organization |
|
||||
| `:executive` | No | Yes | Read-only with full financial visibility |
|
||||
| `:technician`| Yes* | No | Field ops: devices, sites, alerts only |
|
||||
| `:member` | Yes* | No | Legacy alias for technician |
|
||||
| `:viewer` | No | No | Read-only, no financial data |
|
||||
|
||||
*Technicians/members can create/edit devices, sites, and alerts but cannot
|
||||
manage memberships, invitations, integrations, or billing configuration.
|
||||
|
||||
Permission logic is implemented in Gleam at
|
||||
`src/towerops/organizations/policy.gleam`. This module provides the Elixir
|
||||
interface, handling Membership struct unwrapping and mapping unknown atoms
|
||||
to the Gleam type system.
|
||||
"""
|
||||
|
||||
alias Towerops.Organizations.Membership
|
||||
|
||||
@gleam_module :towerops@organizations@policy
|
||||
|
||||
@known_actions [:view, :list, :create, :edit, :delete, :acknowledge, :manage]
|
||||
@known_resources [
|
||||
:organization,
|
||||
:site,
|
||||
:device,
|
||||
:alert,
|
||||
:membership,
|
||||
:invitation,
|
||||
:integration,
|
||||
:financials
|
||||
]
|
||||
|
||||
@doc """
|
||||
Checks if a user can perform an action on a resource within an organization.
|
||||
|
||||
|
|
@ -37,7 +56,7 @@ defmodule Towerops.Organizations.Policy do
|
|||
false
|
||||
"""
|
||||
def can?(%Membership{role: role}, action, resource) do
|
||||
check_permission(role, action, resource)
|
||||
@gleam_module.check_permission(role, map_action(action), map_resource(resource))
|
||||
end
|
||||
|
||||
def can?(nil, _action, _resource), do: false
|
||||
|
|
@ -45,44 +64,12 @@ defmodule Towerops.Organizations.Policy do
|
|||
@doc """
|
||||
Returns true if the given role can view financial data (MRR, revenue, billing).
|
||||
"""
|
||||
def can_view_financials?(%Membership{role: role}), do: financials_visible?(role)
|
||||
def can_view_financials?(%Membership{role: role}), do: @gleam_module.financials_visible(role)
|
||||
def can_view_financials?(nil), do: false
|
||||
|
||||
defp financials_visible?(role) when role in [:owner, :admin, :executive], do: true
|
||||
defp financials_visible?(_role), do: false
|
||||
defp map_action(action) when action in @known_actions, do: action
|
||||
defp map_action(_action), do: :other_action
|
||||
|
||||
# ── Owner ──────────────────────────────────────────────────────────────
|
||||
defp check_permission(:owner, _action, _resource), do: true
|
||||
|
||||
# ── Admin ──────────────────────────────────────────────────────────────
|
||||
defp check_permission(:admin, :delete, :organization), do: false
|
||||
defp check_permission(:admin, _action, _resource), do: true
|
||||
|
||||
# ── Executive (read-only + financials) ─────────────────────────────────
|
||||
defp check_permission(:executive, action, _resource) when action in [:view, :list], do: true
|
||||
defp check_permission(:executive, :acknowledge, :alert), do: true
|
||||
defp check_permission(:executive, _action, _resource), do: false
|
||||
|
||||
# ── Technician (field ops, no financials) ──────────────────────────────
|
||||
defp check_permission(:technician, :view, :financials), do: false
|
||||
defp check_permission(:technician, action, :organization) when action in [:view, :list], do: true
|
||||
defp check_permission(:technician, :delete, _resource), do: false
|
||||
defp check_permission(:technician, action, :membership) when action in [:create, :edit], do: false
|
||||
defp check_permission(:technician, action, :invitation) when action in [:create, :delete], do: false
|
||||
defp check_permission(:technician, action, :integration) when action in [:create, :edit, :delete], do: false
|
||||
|
||||
defp check_permission(:technician, action, resource)
|
||||
when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true
|
||||
|
||||
defp check_permission(:technician, :acknowledge, :alert), do: true
|
||||
defp check_permission(:technician, _action, _resource), do: false
|
||||
|
||||
# ── Member (legacy, same as technician) ────────────────────────────────
|
||||
defp check_permission(:member, action, resource), do: check_permission(:technician, action, resource)
|
||||
|
||||
# ── Viewer (read-only, no financials) ──────────────────────────────────
|
||||
defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true
|
||||
defp check_permission(:viewer, :view, :financials), do: false
|
||||
defp check_permission(:viewer, :acknowledge, :alert), do: true
|
||||
defp check_permission(:viewer, _action, _resource), do: false
|
||||
defp map_resource(resource) when resource in @known_resources, do: resource
|
||||
defp map_resource(_resource), do: :other_resource
|
||||
end
|
||||
|
|
|
|||
|
|
@ -71,25 +71,14 @@ defmodule Towerops.Preseem.Baseline do
|
|||
end
|
||||
|
||||
defp compute_stats(values) do
|
||||
sorted = Enum.sort(values)
|
||||
n = length(sorted)
|
||||
mean = Enum.sum(sorted) / n
|
||||
|
||||
variance =
|
||||
sorted
|
||||
|> Enum.map(fn v -> (v - mean) * (v - mean) end)
|
||||
|> Enum.sum()
|
||||
|> Kernel./(max(n - 1, 1))
|
||||
|
||||
stddev = :math.sqrt(variance)
|
||||
p5_idx = max(round(n * 0.05) - 1, 0)
|
||||
p95_idx = min(round(n * 0.95) - 1, n - 1)
|
||||
float_values = Enum.map(values, &to_float/1)
|
||||
stats = :towerops@preseem@baseline.compute_stats(float_values)
|
||||
|
||||
%{
|
||||
mean: Float.round(mean / 1, 4),
|
||||
stddev: Float.round(stddev / 1, 4),
|
||||
p5: sorted |> Enum.at(p5_idx) |> to_float(),
|
||||
p95: sorted |> Enum.at(p95_idx) |> to_float()
|
||||
mean: elem(stats, 1),
|
||||
stddev: elem(stats, 2),
|
||||
p5: elem(stats, 3),
|
||||
p95: elem(stats, 4)
|
||||
}
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ defmodule Towerops.Result do
|
|||
@moduledoc """
|
||||
Generic result type for representing success or failure outcomes.
|
||||
|
||||
Core logic is implemented in Gleam (`:towerops@result`).
|
||||
|
||||
This module provides a type-safe way to handle operations that can succeed or fail,
|
||||
using Elixir's standard {:ok, value} | {:error, reason} pattern with enhanced type safety.
|
||||
|
||||
|
|
@ -29,6 +31,8 @@ defmodule Towerops.Result do
|
|||
# => {:error, :service_unavailable}
|
||||
"""
|
||||
|
||||
@gleam :towerops@result
|
||||
|
||||
@type t(ok_value, error_value) :: {:ok, ok_value} | {:error, error_value}
|
||||
|
||||
@type t(ok_value) :: t(ok_value, term())
|
||||
|
|
@ -45,9 +49,7 @@ defmodule Towerops.Result do
|
|||
iex> Result.map({:error, :not_found}, fn x -> x * 2 end)
|
||||
{:error, :not_found}
|
||||
"""
|
||||
@spec map(t(a, e), (a -> b)) :: t(b, e) when a: var, b: var, e: var
|
||||
def map({:ok, value}, fun) when is_function(fun, 1), do: {:ok, fun.(value)}
|
||||
def map({:error, _} = err, fun) when is_function(fun, 1), do: err
|
||||
defdelegate map(result, fun), to: @gleam
|
||||
|
||||
@doc """
|
||||
Maps the error value using the given function.
|
||||
|
|
@ -61,9 +63,7 @@ defmodule Towerops.Result do
|
|||
iex> Result.map_error({:ok, 42}, fn _ -> :network_error end)
|
||||
{:ok, 42}
|
||||
"""
|
||||
@spec map_error(t(o, a), (a -> b)) :: t(o, b) when o: var, a: var, b: var
|
||||
def map_error({:ok, _} = ok, fun) when is_function(fun, 1), do: ok
|
||||
def map_error({:error, err}, fun) when is_function(fun, 1), do: {:error, fun.(err)}
|
||||
defdelegate map_error(result, fun), to: @gleam
|
||||
|
||||
@doc """
|
||||
Chains together operations that return results.
|
||||
|
|
@ -78,9 +78,7 @@ defmodule Towerops.Result do
|
|||
iex> Result.and_then({:error, :not_found}, fn x -> {:ok, x * 2} end)
|
||||
{:error, :not_found}
|
||||
"""
|
||||
@spec and_then(t(a, e), (a -> t(b, e))) :: t(b, e) when a: var, b: var, e: var
|
||||
def and_then({:ok, value}, fun) when is_function(fun, 1), do: fun.(value)
|
||||
def and_then({:error, _} = err, fun) when is_function(fun, 1), do: err
|
||||
defdelegate and_then(result, fun), to: @gleam
|
||||
|
||||
@doc """
|
||||
Unwraps a result, returning the success value or a default on error.
|
||||
|
|
@ -93,9 +91,7 @@ defmodule Towerops.Result do
|
|||
iex> Result.unwrap_or({:error, :not_found}, 0)
|
||||
0
|
||||
"""
|
||||
@spec unwrap_or(t(a, term()), a) :: a when a: var
|
||||
def unwrap_or({:ok, value}, _default), do: value
|
||||
def unwrap_or({:error, _}, default), do: default
|
||||
defdelegate unwrap_or(result, default), to: @gleam
|
||||
|
||||
@doc """
|
||||
Checks if the result is a success.
|
||||
|
|
@ -108,9 +104,7 @@ defmodule Towerops.Result do
|
|||
iex> Result.ok?({:error, :not_found})
|
||||
false
|
||||
"""
|
||||
@spec ok?(t(any(), any())) :: boolean()
|
||||
def ok?({:ok, _}), do: true
|
||||
def ok?({:error, _}), do: false
|
||||
def ok?(result), do: @gleam.is_ok(result)
|
||||
|
||||
@doc """
|
||||
Checks if the result is an error.
|
||||
|
|
@ -123,7 +117,5 @@ defmodule Towerops.Result do
|
|||
iex> Result.error?({:ok, 42})
|
||||
false
|
||||
"""
|
||||
@spec error?(t(any(), any())) :: boolean()
|
||||
def error?({:error, _}), do: true
|
||||
def error?({:ok, _}), do: false
|
||||
def error?(result), do: @gleam.is_error(result)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,12 +4,17 @@ defmodule Towerops.Snmp.SensorChangeDetector do
|
|||
|
||||
Shared between DevicePollerWorker and AgentChannel to ensure consistent
|
||||
event detection across both Phoenix-polled and agent-polled devices.
|
||||
|
||||
Pure decision logic (threshold checking, value formatting) is implemented
|
||||
in Gleam. PubSub broadcasting, Repo queries, and event building stay here.
|
||||
"""
|
||||
|
||||
alias Towerops.Snmp.Sensor
|
||||
|
||||
require Logger
|
||||
|
||||
@gleam :towerops@snmp@sensor_change_detector
|
||||
|
||||
@doc """
|
||||
Detects sensor changes and broadcasts events via PubSub.
|
||||
|
||||
|
|
@ -175,19 +180,28 @@ defmodule Towerops.Snmp.SensorChangeDetector do
|
|||
end
|
||||
|
||||
defp value_was_over_threshold?(last_value, thresholds) do
|
||||
(thresholds.warning_high && last_value >= thresholds.warning_high) ||
|
||||
(thresholds.critical_high && last_value >= thresholds.critical_high) ||
|
||||
(thresholds.warning_low && last_value <= thresholds.warning_low) ||
|
||||
(thresholds.critical_low && last_value <= thresholds.critical_low)
|
||||
@gleam.value_was_over_threshold(
|
||||
last_value / 1.0,
|
||||
to_gleam_option(thresholds.warning_high),
|
||||
to_gleam_option(thresholds.critical_high),
|
||||
to_gleam_option(thresholds.warning_low),
|
||||
to_gleam_option(thresholds.critical_low)
|
||||
)
|
||||
end
|
||||
|
||||
defp value_is_normal?(current_value, thresholds) do
|
||||
(thresholds.warning_high == nil || current_value < thresholds.warning_high) &&
|
||||
(thresholds.critical_high == nil || current_value < thresholds.critical_high) &&
|
||||
(thresholds.warning_low == nil || current_value > thresholds.warning_low) &&
|
||||
(thresholds.critical_low == nil || current_value > thresholds.critical_low)
|
||||
@gleam.value_is_normal(
|
||||
current_value / 1.0,
|
||||
to_gleam_option(thresholds.warning_high),
|
||||
to_gleam_option(thresholds.critical_high),
|
||||
to_gleam_option(thresholds.warning_low),
|
||||
to_gleam_option(thresholds.critical_low)
|
||||
)
|
||||
end
|
||||
|
||||
defp to_gleam_option(nil), do: :none
|
||||
defp to_gleam_option(value) when is_number(value), do: {:some, value / 1.0}
|
||||
|
||||
# Change detection
|
||||
|
||||
defp maybe_add_change_event(events, sensor, current_value, timestamp, device_id) do
|
||||
|
|
@ -299,12 +313,8 @@ defmodule Towerops.Snmp.SensorChangeDetector do
|
|||
end
|
||||
end
|
||||
|
||||
defp format_sensor_value(value, "") when is_number(value) do
|
||||
"#{trunc(value)}"
|
||||
end
|
||||
|
||||
defp format_sensor_value(value, unit) when is_number(value) do
|
||||
"#{Float.round(value / 1.0, 1)}#{unit}"
|
||||
@gleam.format_sensor_value(value / 1.0, unit || "")
|
||||
end
|
||||
|
||||
defp format_sensor_value(_, _), do: "N/A"
|
||||
|
|
|
|||
38
src/towerops/capacity/resolver.gleam
Normal file
38
src/towerops/capacity/resolver.gleam
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/// Capacity unit normalization.
|
||||
/// Converts sensor values to bits per second based on their unit string.
|
||||
import gleam/string
|
||||
|
||||
pub type SpeedUnit {
|
||||
Bps
|
||||
Kbps
|
||||
Mbps
|
||||
Gbps
|
||||
}
|
||||
|
||||
/// Normalize a sensor value to bits per second.
|
||||
pub fn normalize_to_bps(value: Float, unit: String, divisor: Float) -> Int {
|
||||
let adjusted = value /. divisor
|
||||
|
||||
let bps = case normalize_unit(unit) {
|
||||
Bps -> adjusted
|
||||
Kbps -> adjusted *. 1000.0
|
||||
Mbps -> adjusted *. 1_000_000.0
|
||||
Gbps -> adjusted *. 1_000_000_000.0
|
||||
}
|
||||
|
||||
float_to_int(bps)
|
||||
}
|
||||
|
||||
/// Parse a unit string into a SpeedUnit.
|
||||
pub fn normalize_unit(unit: String) -> SpeedUnit {
|
||||
case string.lowercase(unit) {
|
||||
"bps" -> Bps
|
||||
"kbps" -> Kbps
|
||||
"mbps" -> Mbps
|
||||
"gbps" -> Gbps
|
||||
_ -> Bps
|
||||
}
|
||||
}
|
||||
|
||||
@external(erlang, "erlang", "trunc")
|
||||
fn float_to_int(value: Float) -> Int
|
||||
54
src/towerops/config_changes/correlator.gleam
Normal file
54
src/towerops/config_changes/correlator.gleam
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// Config change correlation math.
|
||||
///
|
||||
/// Pure functions for percentage-change calculations, severity classification,
|
||||
/// and conditional list building used by the Elixir Correlator module.
|
||||
import gleam/list
|
||||
|
||||
/// Calculate percentage change between two values.
|
||||
/// Returns 0.0 if old value is zero or negative.
|
||||
pub fn safe_pct_change(old: Float, new_val: Float) -> Float {
|
||||
case old >. 0.0 {
|
||||
True -> { new_val -. old } /. old
|
||||
False -> 0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub fn determine_severity(
|
||||
latency_delta: Float,
|
||||
loss_delta: Float,
|
||||
throughput_delta: Float,
|
||||
) -> String {
|
||||
case
|
||||
latency_delta >. 0.50
|
||||
|| loss_delta >. 1.0
|
||||
|| throughput_delta <. -0.30
|
||||
{
|
||||
True -> "critical"
|
||||
False ->
|
||||
case
|
||||
latency_delta >. 0.20
|
||||
|| loss_delta >. 0.50
|
||||
|| throughput_delta <. -0.15
|
||||
{
|
||||
True -> "warning"
|
||||
False -> "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append text to list if condition is true, otherwise return list unchanged.
|
||||
pub fn maybe_add(
|
||||
parts: List(String),
|
||||
text: String,
|
||||
condition: Bool,
|
||||
) -> List(String) {
|
||||
case condition {
|
||||
True -> list.append(parts, [text])
|
||||
False -> parts
|
||||
}
|
||||
}
|
||||
70
src/towerops/ecto_types/ip_address.gleam
Normal file
70
src/towerops/ecto_types/ip_address.gleam
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/// Pure IP address parsing and validation functions.
|
||||
///
|
||||
/// Handles string parsing, zone ID stripping, CIDR rejection, and version
|
||||
/// detection. Delegates actual inet parsing to Erlang FFI.
|
||||
///
|
||||
/// Compiles to `:towerops@ecto_types@ip_address`.
|
||||
import gleam/dynamic.{type Dynamic}
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@external(erlang, "towerops_ip_address_ffi", "parse_address")
|
||||
fn inet_parse(value: String) -> Result(Dynamic, Nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// IP address version: IPv4 (4-tuple) or IPv6 (8-tuple).
|
||||
pub type IpVersion {
|
||||
Ipv4
|
||||
Ipv6
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Strips the IPv6 zone ID from an address string.
|
||||
///
|
||||
/// Zone IDs appear after a "%" character (e.g., "fe80::1%eth0" -> "fe80::1").
|
||||
/// Returns the input unchanged if no zone ID is present.
|
||||
pub fn strip_zone_id(value: String) -> String {
|
||||
case string.split(value, "%") {
|
||||
[ip, _, ..] -> ip
|
||||
_ -> value
|
||||
}
|
||||
}
|
||||
|
||||
/// Detects the IP version based on the tuple size.
|
||||
///
|
||||
/// A tuple of size 4 is IPv4, anything else is IPv6.
|
||||
pub fn detect_version(size: Int) -> IpVersion {
|
||||
case size {
|
||||
4 -> Ipv4
|
||||
_ -> Ipv6
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an IP address string, stripping zone IDs and rejecting CIDR notation.
|
||||
///
|
||||
/// Returns Ok(#(cleaned_string, ip_tuple)) on success or Error(Nil) on failure.
|
||||
pub fn parse_string(value: String) -> Result(#(String, Dynamic), Nil) {
|
||||
case value {
|
||||
"" -> Error(Nil)
|
||||
_ -> {
|
||||
let cleaned = strip_zone_id(value)
|
||||
case string.contains(cleaned, "/") {
|
||||
True -> Error(Nil)
|
||||
False ->
|
||||
case inet_parse(cleaned) {
|
||||
Ok(ip_tuple) -> Ok(#(cleaned, ip_tuple))
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
src/towerops/ecto_types/mac_address.gleam
Normal file
193
src/towerops/ecto_types/mac_address.gleam
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/// Pure MAC address parsing and formatting functions.
|
||||
///
|
||||
/// Parses MAC address strings in multiple formats (colon, hyphen, dot, compact)
|
||||
/// into lists of 6 byte values, and formats byte lists back into string
|
||||
/// representations.
|
||||
///
|
||||
/// Compiles to `:towerops@ecto_types@mac_address`.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses a MAC address string in any supported format into a list of 6 byte
|
||||
/// values (0-255).
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - Colon-separated: "00:11:22:33:44:55"
|
||||
/// - Hyphen-separated: "00-11-22-33-44-55"
|
||||
/// - Dot-separated (Cisco): "0011.2233.4455"
|
||||
/// - Compact: "001122334455"
|
||||
///
|
||||
/// Case-insensitive. Returns Error(Nil) for invalid input.
|
||||
pub fn parse_string(value: String) -> Result(List(Int), Nil) {
|
||||
let trimmed = string.trim(value)
|
||||
case trimmed {
|
||||
"" -> Error(Nil)
|
||||
_ -> try_parse_formats(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Formats a list of 6 byte values as "aa:bb:cc:dd:ee:ff".
|
||||
pub fn format_colon(bytes: List(Int)) -> String {
|
||||
bytes
|
||||
|> list.map(byte_to_hex)
|
||||
|> string.join(":")
|
||||
}
|
||||
|
||||
/// Formats a list of 6 byte values as "aa-bb-cc-dd-ee-ff".
|
||||
pub fn format_hyphen(bytes: List(Int)) -> String {
|
||||
bytes
|
||||
|> list.map(byte_to_hex)
|
||||
|> string.join("-")
|
||||
}
|
||||
|
||||
/// Formats a list of 6 byte values as "aabb.ccdd.eeff" (Cisco format).
|
||||
pub fn format_dot(bytes: List(Int)) -> String {
|
||||
case bytes {
|
||||
[o1, o2, o3, o4, o5, o6] ->
|
||||
string.join(
|
||||
[
|
||||
byte_to_hex(o1) <> byte_to_hex(o2),
|
||||
byte_to_hex(o3) <> byte_to_hex(o4),
|
||||
byte_to_hex(o5) <> byte_to_hex(o6),
|
||||
],
|
||||
".",
|
||||
)
|
||||
_ -> ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a list of 6 byte values as "aabbccddeeff" (compact, no separators).
|
||||
pub fn format_compact(bytes: List(Int)) -> String {
|
||||
bytes
|
||||
|> list.map(byte_to_hex)
|
||||
|> string.join("")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Tries each supported format in order.
|
||||
fn try_parse_formats(value: String) -> Result(List(Int), Nil) {
|
||||
// Try colon-separated (6 parts split by ":")
|
||||
case try_split_format(value, ":") {
|
||||
Ok(bytes) -> Ok(bytes)
|
||||
Error(_) ->
|
||||
// Try hyphen-separated (6 parts split by "-")
|
||||
case try_split_format(value, "-") {
|
||||
Ok(bytes) -> Ok(bytes)
|
||||
Error(_) ->
|
||||
// Try dot-separated (3 groups of 4 hex chars split by ".")
|
||||
case try_dot_format(value) {
|
||||
Ok(bytes) -> Ok(bytes)
|
||||
Error(_) ->
|
||||
// Try compact (12 hex chars, no separator)
|
||||
try_compact_format(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to parse a MAC address by splitting on a separator.
|
||||
/// Expects exactly 6 parts, each being 2 hex characters.
|
||||
fn try_split_format(value: String, separator: String) -> Result(List(Int), Nil) {
|
||||
let parts = string.split(value, separator)
|
||||
case list.length(parts) {
|
||||
6 -> parse_hex_parts(parts, 2)
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to parse a dot-separated (Cisco) MAC address.
|
||||
/// Expects 3 groups of exactly 4 hex characters.
|
||||
fn try_dot_format(value: String) -> Result(List(Int), Nil) {
|
||||
let groups = string.split(value, ".")
|
||||
case groups {
|
||||
[g1, g2, g3] ->
|
||||
case
|
||||
string.length(g1) == 4
|
||||
&& string.length(g2) == 4
|
||||
&& string.length(g3) == 4
|
||||
{
|
||||
True -> {
|
||||
// Split each 4-char group into two 2-char octets
|
||||
let parts = split_group(g1, split_group(g2, split_group(g3, [])))
|
||||
parse_hex_parts(parts, 2)
|
||||
}
|
||||
False -> Error(Nil)
|
||||
}
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a 4-character hex group into two 2-character parts,
|
||||
/// prepending to an accumulator.
|
||||
fn split_group(group: String, acc: List(String)) -> List(String) {
|
||||
let first = string.slice(group, 0, 2)
|
||||
let second = string.slice(group, 2, 2)
|
||||
[first, second, ..acc]
|
||||
}
|
||||
|
||||
/// Tries to parse a compact MAC address (12 hex characters, no separator).
|
||||
fn try_compact_format(value: String) -> Result(List(Int), Nil) {
|
||||
case string.length(value) {
|
||||
12 -> {
|
||||
let parts = [
|
||||
string.slice(value, 0, 2),
|
||||
string.slice(value, 2, 2),
|
||||
string.slice(value, 4, 2),
|
||||
string.slice(value, 6, 2),
|
||||
string.slice(value, 8, 2),
|
||||
string.slice(value, 10, 2),
|
||||
]
|
||||
parse_hex_parts(parts, 2)
|
||||
}
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a list of hex string parts into a list of integer byte values.
|
||||
/// Each part must be exactly `expected_len` characters and valid hex.
|
||||
fn parse_hex_parts(
|
||||
parts: List(String),
|
||||
expected_len: Int,
|
||||
) -> Result(List(Int), Nil) {
|
||||
parts
|
||||
|> list.try_map(fn(part) {
|
||||
case string.length(part) == expected_len {
|
||||
True ->
|
||||
string.lowercase(part)
|
||||
|> int.base_parse(16)
|
||||
False -> Error(Nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: Formatting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts a single byte (0-255) to a zero-padded 2-character lowercase
|
||||
/// hex string.
|
||||
fn byte_to_hex(byte: Int) -> String {
|
||||
case int.to_base_string(byte, 16) {
|
||||
Ok(hex) -> {
|
||||
let lower = string.lowercase(hex)
|
||||
case string.length(lower) {
|
||||
1 -> "0" <> lower
|
||||
_ -> lower
|
||||
}
|
||||
}
|
||||
// Base 16 is always valid, so this branch is unreachable
|
||||
Error(_) -> "00"
|
||||
}
|
||||
}
|
||||
148
src/towerops/ecto_types/snmp_oid.gleam
Normal file
148
src/towerops/ecto_types/snmp_oid.gleam
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/// Pure SNMP OID parsing and manipulation functions.
|
||||
///
|
||||
/// Handles numeric OID validation, parsing, normalization, and list-based
|
||||
/// OID operations (parent, child_of, append). Named OID resolution stays
|
||||
/// in Elixir (SnmpKit).
|
||||
///
|
||||
/// Compiles to `:towerops@ecto_types@snmp_oid`.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/regexp
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Checks if a string matches a numeric OID pattern.
|
||||
///
|
||||
/// Accepts an optional leading dot, then one or more groups of digits
|
||||
/// separated by dots (e.g., ".1.3.6.1" or "1.3.6.1").
|
||||
pub fn is_numeric_oid(value: String) -> Bool {
|
||||
let assert Ok(re) = regexp.from_string("^\\.?([0-9]+)(\\.[0-9]+)*$")
|
||||
regexp.check(re, value)
|
||||
}
|
||||
|
||||
/// Checks if a string matches a named OID pattern.
|
||||
///
|
||||
/// Named OIDs start with a letter, may contain "::" for MIB prefix,
|
||||
/// and allow dots with alphanumeric/digit suffixes. Returns False for
|
||||
/// pure numeric OIDs.
|
||||
pub fn is_named_oid(value: String) -> Bool {
|
||||
let assert Ok(numeric_re) = regexp.from_string("^\\.?([0-9]+)(\\.[0-9]+)*$")
|
||||
let assert Ok(named_re) =
|
||||
regexp.from_string(
|
||||
"^[a-zA-Z][a-zA-Z0-9_-]*(::[a-zA-Z][a-zA-Z0-9_-]*)?(\\.[a-zA-Z0-9_-]+|\\.[0-9]+)*$",
|
||||
)
|
||||
regexp.check(named_re, value) && !regexp.check(numeric_re, value)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: Parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validates a numeric OID string, normalizes the leading dot, and returns
|
||||
/// the canonical string representation along with the integer parts.
|
||||
///
|
||||
/// Strips a leading dot, splits on ".", parses each part as a non-negative
|
||||
/// integer, then rebuilds with a leading dot.
|
||||
///
|
||||
/// Returns `Ok(#(oid_string, parts))` or `Error(Nil)`.
|
||||
pub fn parse_numeric_oid(value: String) -> Result(#(String, List(Int)), Nil) {
|
||||
let trimmed = case string.starts_with(value, ".") {
|
||||
True -> string.drop_start(value, 1)
|
||||
False -> value
|
||||
}
|
||||
case trimmed {
|
||||
"" -> Error(Nil)
|
||||
_ -> {
|
||||
let raw_parts = string.split(trimmed, ".")
|
||||
case parse_all_ints(raw_parts, []) {
|
||||
Ok(int_parts) ->
|
||||
case list.all(int_parts, fn(n) { n >= 0 }) {
|
||||
True -> Ok(#(parts_to_string(int_parts), int_parts))
|
||||
False -> Error(Nil)
|
||||
}
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: String helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Prepends "." to the string if not already present.
|
||||
pub fn ensure_leading_dot(value: String) -> String {
|
||||
case string.starts_with(value, ".") {
|
||||
True -> value
|
||||
False -> "." <> value
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a dotted-decimal string with a leading dot from a list of integers.
|
||||
///
|
||||
/// Example: `[1, 3, 6]` -> `".1.3.6"`
|
||||
pub fn parts_to_string(parts: List(Int)) -> String {
|
||||
"."
|
||||
<> parts
|
||||
|> list.map(int.to_string)
|
||||
|> string.join(".")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API: List operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns all parts except the last (the parent OID's parts).
|
||||
pub fn parent_parts(parts: List(Int)) -> List(Int) {
|
||||
let len = list.length(parts)
|
||||
case len > 1 {
|
||||
True -> list.take(parts, len - 1)
|
||||
False -> parts
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns True if child starts with all of parent's parts AND is strictly
|
||||
/// longer (i.e., a proper child, not equal).
|
||||
pub fn child_of(child: List(Int), parent: List(Int)) -> Bool {
|
||||
list.length(child) > list.length(parent) && starts_with(child, parent)
|
||||
}
|
||||
|
||||
/// Concatenates two lists of OID parts.
|
||||
pub fn append_parts(parts: List(Int), additional: List(Int)) -> List(Int) {
|
||||
list.append(parts, additional)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Checks if `haystack` starts with all elements of `prefix` in order.
|
||||
fn starts_with(haystack: List(a), prefix: List(a)) -> Bool {
|
||||
case prefix {
|
||||
[] -> True
|
||||
[p, ..ptail] ->
|
||||
case haystack {
|
||||
[h, ..htail] -> p == h && starts_with(htail, ptail)
|
||||
[] -> False
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a list of string parts into integers, accumulating in reverse
|
||||
/// then reversing at the end.
|
||||
fn parse_all_ints(
|
||||
parts: List(String),
|
||||
acc: List(Int),
|
||||
) -> Result(List(Int), Nil) {
|
||||
case parts {
|
||||
[] -> Ok(list.reverse(acc))
|
||||
[head, ..tail] ->
|
||||
case int.parse(head) {
|
||||
Ok(n) -> parse_all_ints(tail, [n, ..acc])
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/towerops/organizations/policy.gleam
Normal file
124
src/towerops/organizations/policy.gleam
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/// Authorization policy for organization resources.
|
||||
///
|
||||
/// Pure permission-check logic using pattern matching on role, action, and
|
||||
/// resource. Called from the Elixir wrapper module which handles Membership
|
||||
/// struct unwrapping and unknown-atom mapping.
|
||||
|
||||
/// Organization membership roles, ordered from most to least privileged.
|
||||
pub type Role {
|
||||
Owner
|
||||
Admin
|
||||
Executive
|
||||
Technician
|
||||
Member
|
||||
Viewer
|
||||
}
|
||||
|
||||
/// Actions that can be performed on resources.
|
||||
pub type Action {
|
||||
View
|
||||
List
|
||||
Create
|
||||
Edit
|
||||
Delete
|
||||
Acknowledge
|
||||
Manage
|
||||
OtherAction
|
||||
}
|
||||
|
||||
/// Organization resources subject to authorization.
|
||||
pub type Resource {
|
||||
Organization
|
||||
Site
|
||||
Device
|
||||
Alert
|
||||
Membership
|
||||
Invitation
|
||||
Integration
|
||||
Financials
|
||||
OtherResource
|
||||
}
|
||||
|
||||
/// Checks if a role can perform an action on a resource.
|
||||
pub fn check_permission(role: Role, action: Action, resource: Resource) -> Bool {
|
||||
case role {
|
||||
Owner -> True
|
||||
Admin -> check_admin(action, resource)
|
||||
Executive -> check_executive(action, resource)
|
||||
Technician -> check_technician(action, resource)
|
||||
Member -> check_technician(action, resource)
|
||||
Viewer -> check_viewer(action, resource)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the given role can view financial data.
|
||||
pub fn financials_visible(role: Role) -> Bool {
|
||||
case role {
|
||||
Owner -> True
|
||||
Admin -> True
|
||||
Executive -> True
|
||||
_ -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn check_admin(action: Action, resource: Resource) -> Bool {
|
||||
case action, resource {
|
||||
Delete, Organization -> False
|
||||
_, _ -> True
|
||||
}
|
||||
}
|
||||
|
||||
fn check_executive(action: Action, resource: Resource) -> Bool {
|
||||
case action, resource {
|
||||
View, _ -> True
|
||||
List, _ -> True
|
||||
Acknowledge, Alert -> True
|
||||
_, _ -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn check_technician(action: Action, resource: Resource) -> Bool {
|
||||
case action, resource {
|
||||
// Deny financials access
|
||||
View, Financials -> False
|
||||
// Allow viewing/listing the organization
|
||||
View, Organization -> True
|
||||
List, Organization -> True
|
||||
// Deny all deletes
|
||||
Delete, _ -> False
|
||||
// Deny membership management
|
||||
Create, Membership -> False
|
||||
Edit, Membership -> False
|
||||
// Deny invitation management
|
||||
Create, Invitation -> False
|
||||
// Deny integration management
|
||||
Create, Integration -> False
|
||||
Edit, Integration -> False
|
||||
// Allow view/list/create/edit on sites, devices, and alerts
|
||||
View, Site -> True
|
||||
View, Device -> True
|
||||
View, Alert -> True
|
||||
List, Site -> True
|
||||
List, Device -> True
|
||||
List, Alert -> True
|
||||
Create, Site -> True
|
||||
Create, Device -> True
|
||||
Create, Alert -> True
|
||||
Edit, Site -> True
|
||||
Edit, Device -> True
|
||||
Edit, Alert -> True
|
||||
// Allow acknowledging alerts
|
||||
Acknowledge, Alert -> True
|
||||
// Deny everything else
|
||||
_, _ -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn check_viewer(action: Action, resource: Resource) -> Bool {
|
||||
case action, resource {
|
||||
View, _ -> True
|
||||
List, _ -> True
|
||||
Acknowledge, Alert -> True
|
||||
_, _ -> False
|
||||
}
|
||||
}
|
||||
55
src/towerops/preseem/baseline.gleam
Normal file
55
src/towerops/preseem/baseline.gleam
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import gleam/float
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
|
||||
/// Statistical summary of a list of values.
|
||||
pub type Stats {
|
||||
Stats(mean: Float, stddev: Float, p5: Float, p95: Float)
|
||||
}
|
||||
|
||||
/// Compute descriptive statistics (mean, stddev, p5, p95) for a list of floats.
|
||||
/// The list must be non-empty. Returns zeroed Stats for an empty list.
|
||||
pub fn compute_stats(values: List(Float)) -> Stats {
|
||||
case values {
|
||||
[] -> Stats(mean: 0.0, stddev: 0.0, p5: 0.0, p95: 0.0)
|
||||
_ -> {
|
||||
let sorted = list.sort(values, float.compare)
|
||||
let n = list.length(sorted)
|
||||
let sum = float_sum(sorted)
|
||||
let mean = sum /. int.to_float(n)
|
||||
|
||||
let variance =
|
||||
sorted
|
||||
|> list.map(fn(v) { { v -. mean } *. { v -. mean } })
|
||||
|> float_sum()
|
||||
|> fn(s) { s /. int.to_float(int.max(n - 1, 1)) }
|
||||
|
||||
let stddev = float_sqrt(variance)
|
||||
let p5_idx = int.max(float_round(int.to_float(n) *. 0.05) - 1, 0)
|
||||
let p95_idx = int.min(float_round(int.to_float(n) *. 0.95) - 1, n - 1)
|
||||
|
||||
Stats(
|
||||
mean: float_round_to(mean, 4),
|
||||
stddev: float_round_to(stddev, 4),
|
||||
p5: float_round_to(list_at(sorted, p5_idx), 4),
|
||||
p95: float_round_to(list_at(sorted, p95_idx), 4),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn float_sum(values: List(Float)) -> Float {
|
||||
list.fold(values, 0.0, fn(acc, v) { acc +. v })
|
||||
}
|
||||
|
||||
@external(erlang, "towerops_baseline_ffi", "float_sqrt")
|
||||
fn float_sqrt(value: Float) -> Float
|
||||
|
||||
@external(erlang, "towerops_baseline_ffi", "float_round_to")
|
||||
fn float_round_to(value: Float, decimals: Int) -> Float
|
||||
|
||||
@external(erlang, "towerops_baseline_ffi", "float_round")
|
||||
fn float_round(value: Float) -> Int
|
||||
|
||||
@external(erlang, "towerops_baseline_ffi", "list_at")
|
||||
fn list_at(list: List(a), index: Int) -> a
|
||||
54
src/towerops/result.gleam
Normal file
54
src/towerops/result.gleam
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// Maps the success value using the given function.
|
||||
/// Error values are passed through unchanged.
|
||||
pub fn map(result: Result(a, e), fun: fn(a) -> b) -> Result(b, e) {
|
||||
case result {
|
||||
Ok(value) -> Ok(fun(value))
|
||||
Error(err) -> Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the error value using the given function.
|
||||
/// Success values are passed through unchanged.
|
||||
pub fn map_error(result: Result(a, e), fun: fn(e) -> f) -> Result(a, f) {
|
||||
case result {
|
||||
Ok(value) -> Ok(value)
|
||||
Error(err) -> Error(fun(err))
|
||||
}
|
||||
}
|
||||
|
||||
/// Chains together operations that return results.
|
||||
/// If the first result is an error, returns it immediately.
|
||||
/// Otherwise, applies the function to the success value.
|
||||
pub fn and_then(
|
||||
result: Result(a, e),
|
||||
fun: fn(a) -> Result(b, e),
|
||||
) -> Result(b, e) {
|
||||
case result {
|
||||
Ok(value) -> fun(value)
|
||||
Error(err) -> Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unwraps a result, returning the success value or a default on error.
|
||||
pub fn unwrap_or(result: Result(a, e), default: a) -> a {
|
||||
case result {
|
||||
Ok(value) -> value
|
||||
Error(_) -> default
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the result is a success.
|
||||
pub fn is_ok(result: Result(a, e)) -> Bool {
|
||||
case result {
|
||||
Ok(_) -> True
|
||||
Error(_) -> False
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the result is an error.
|
||||
pub fn is_error(result: Result(a, e)) -> Bool {
|
||||
case result {
|
||||
Ok(_) -> False
|
||||
Error(_) -> True
|
||||
}
|
||||
}
|
||||
83
src/towerops/snmp/sensor_change_detector.gleam
Normal file
83
src/towerops/snmp/sensor_change_detector.gleam
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import gleam/int
|
||||
import gleam/option.{type Option, None, Some}
|
||||
|
||||
/// Format a sensor value with its unit for display in event messages.
|
||||
///
|
||||
/// When the unit is empty (count sensors), the value is truncated to an integer
|
||||
/// string (e.g. 1000.0 -> "1000"). Otherwise, the value is rounded to 1 decimal
|
||||
/// place and the unit is appended (e.g. 50.0, "Hz" -> "50.0Hz").
|
||||
pub fn format_sensor_value(value: Float, unit: String) -> String {
|
||||
case unit {
|
||||
"" -> int.to_string(trunc_float(value))
|
||||
_ -> format_float_1dp(value) <> unit
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the last value exceeded any configured threshold.
|
||||
///
|
||||
/// Returns True if the value was at or above any high threshold,
|
||||
/// or at or below any low threshold.
|
||||
pub fn value_was_over_threshold(
|
||||
last_value: Float,
|
||||
warning_high: Option(Float),
|
||||
critical_high: Option(Float),
|
||||
warning_low: Option(Float),
|
||||
critical_low: Option(Float),
|
||||
) -> Bool {
|
||||
check_at_or_above(last_value, warning_high)
|
||||
|| check_at_or_above(last_value, critical_high)
|
||||
|| check_at_or_below(last_value, warning_low)
|
||||
|| check_at_or_below(last_value, critical_low)
|
||||
}
|
||||
|
||||
/// Check whether the current value is within all configured thresholds.
|
||||
///
|
||||
/// Returns True only if the value is strictly below all high thresholds
|
||||
/// and strictly above all low thresholds. Unconfigured thresholds (None)
|
||||
/// are treated as not restricting.
|
||||
pub fn value_is_normal(
|
||||
current_value: Float,
|
||||
warning_high: Option(Float),
|
||||
critical_high: Option(Float),
|
||||
warning_low: Option(Float),
|
||||
critical_low: Option(Float),
|
||||
) -> Bool {
|
||||
is_below(current_value, warning_high)
|
||||
&& is_below(current_value, critical_high)
|
||||
&& is_above(current_value, warning_low)
|
||||
&& is_above(current_value, critical_low)
|
||||
}
|
||||
|
||||
fn check_at_or_above(value: Float, threshold: Option(Float)) -> Bool {
|
||||
case threshold {
|
||||
Some(t) -> value >=. t
|
||||
None -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn check_at_or_below(value: Float, threshold: Option(Float)) -> Bool {
|
||||
case threshold {
|
||||
Some(t) -> value <=. t
|
||||
None -> False
|
||||
}
|
||||
}
|
||||
|
||||
fn is_below(value: Float, threshold: Option(Float)) -> Bool {
|
||||
case threshold {
|
||||
Some(t) -> value <. t
|
||||
None -> True
|
||||
}
|
||||
}
|
||||
|
||||
fn is_above(value: Float, threshold: Option(Float)) -> Bool {
|
||||
case threshold {
|
||||
Some(t) -> value >. t
|
||||
None -> True
|
||||
}
|
||||
}
|
||||
|
||||
@external(erlang, "towerops_sensor_ffi", "trunc_float")
|
||||
fn trunc_float(value: Float) -> Int
|
||||
|
||||
@external(erlang, "towerops_sensor_ffi", "format_float_1dp")
|
||||
fn format_float_1dp(value: Float) -> String
|
||||
14
src/towerops_baseline_ffi.erl
Normal file
14
src/towerops_baseline_ffi.erl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-module(towerops_baseline_ffi).
|
||||
-export([float_sqrt/1, float_round_to/2, float_round/1, list_at/2]).
|
||||
|
||||
float_sqrt(Value) ->
|
||||
math:sqrt(Value).
|
||||
|
||||
float_round_to(Value, Decimals) ->
|
||||
'Elixir.Float':'round'(Value, Decimals).
|
||||
|
||||
float_round(Value) ->
|
||||
round(Value).
|
||||
|
||||
list_at(List, Index) ->
|
||||
lists:nth(Index + 1, List).
|
||||
11
src/towerops_ip_address_ffi.erl
Normal file
11
src/towerops_ip_address_ffi.erl
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-module(towerops_ip_address_ffi).
|
||||
-export([parse_address/1]).
|
||||
|
||||
%% Parse a cleaned IP address string using inet:parse_address/1.
|
||||
%% Returns {ok, IpTuple} or {error, nil}.
|
||||
parse_address(Bin) when is_binary(Bin) ->
|
||||
Charlist = binary_to_list(Bin),
|
||||
case inet:parse_address(Charlist) of
|
||||
{ok, IpTuple} -> {ok, IpTuple};
|
||||
{error, _} -> {error, nil}
|
||||
end.
|
||||
9
src/towerops_sensor_ffi.erl
Normal file
9
src/towerops_sensor_ffi.erl
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-module(towerops_sensor_ffi).
|
||||
-export([trunc_float/1, format_float_1dp/1]).
|
||||
|
||||
trunc_float(Value) ->
|
||||
erlang:trunc(Value).
|
||||
|
||||
format_float_1dp(Value) ->
|
||||
Rounded = 'Elixir.Float':round(Value, 1),
|
||||
'Elixir.String.Chars':to_string(Rounded).
|
||||
Loading…
Add table
Reference in a new issue