Reviewed-on: graham/towerops-web#105
This commit is contained in:
Graham McIntire 2026-03-21 18:36:12 -05:00 committed by graham
parent e54ee75513
commit c8b275bd4c
21 changed files with 1008 additions and 359 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

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

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

View 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"
}
}

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

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

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

View 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

View 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).

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

View 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).