Add comprehensive Dialyzer type specifications

- Add @type definitions to all schema modules:
  - User, UserCredential, Membership, Invitation
  - Organization, Site, AgentAssignment
  - InterfaceStat, SensorReading, Alert
- Fix all compilation warnings with stronger Elixir types:
  - Remove unused require Logger in log_filter.ex
  - Remove unused parse_float(nil) clause
  - Add pin operators (^) for variables in binary pattern matches
- Fix Dialyzer errors (25 → 0):
  - Remove unreachable pattern match clauses
  - Fix unmatched return values with _ = prefix
  - Update @spec for deliver_alert_notification/1
- Properly handle all Phoenix.PubSub.subscribe and Task.start return values
- Explicitly ignore if statement return values where needed

All files now pass mix compile --warnings-as-errors and mix dialyzer.
This commit is contained in:
Graham McIntire 2026-01-17 10:52:02 -06:00
parent 02c163986e
commit b53a53b199
No known key found for this signature in database
25 changed files with 256 additions and 74 deletions

View file

@ -1,2 +1,2 @@
erlang 28.3
elixir 1.19.5-otp-28
elixir 1.20.0-rc.1-otp-28

View file

@ -4,6 +4,10 @@ defmodule Towerops.Accounts.User do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.UserCredential
alias Towerops.Organizations.Membership
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
@ -14,13 +18,28 @@ defmodule Towerops.Accounts.User do
field :authenticated_at, :utc_datetime, virtual: true
field :is_superuser, :boolean, default: false
has_many :memberships, Towerops.Organizations.Membership
has_many :memberships, Membership
has_many :organizations, through: [:memberships, :organization]
has_many :credentials, Towerops.Accounts.UserCredential
has_many :credentials, UserCredential
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
email: String.t(),
password: String.t() | nil,
hashed_password: String.t(),
confirmed_at: DateTime.t() | nil,
authenticated_at: DateTime.t() | nil,
is_superuser: boolean(),
memberships: NotLoaded.t() | [Membership.t()],
organizations: NotLoaded.t() | [Towerops.Organizations.Organization.t()],
credentials: NotLoaded.t() | [UserCredential.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc """
A user changeset for registering or changing the email.

View file

@ -9,6 +9,8 @@ defmodule Towerops.Accounts.UserCredential do
import Ecto.Changeset
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -24,11 +26,29 @@ defmodule Towerops.Accounts.UserCredential do
field :backup_state, :boolean, default: false
field :attestation_format, :string
belongs_to :user, Towerops.Accounts.User
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
credential_id: binary(),
public_key: binary(),
sign_count: integer(),
name: String.t(),
last_used_at: DateTime.t() | nil,
aaguid: binary(),
transports: [String.t()],
backup_eligible: boolean(),
backup_state: boolean(),
attestation_format: String.t() | nil,
user_id: Ecto.UUID.t(),
user: Ecto.Association.NotLoaded.t() | User.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc """
Changeset for creating or updating a credential.
"""

View file

@ -79,10 +79,6 @@ defmodule Towerops.Accounts.WebAuthn do
{:error, reason} = error ->
Logger.error("Failed to decode client data: #{inspect(reason)}")
error
error ->
Logger.error("Failed to decode client data JSON: #{inspect(error)}")
{:error, :invalid_client_data}
end
end
@ -241,11 +237,11 @@ defmodule Towerops.Accounts.WebAuthn do
with <<aaguid::binary-size(16), cred_id_length::16, rest::binary>> <- data,
true <- byte_size(rest) >= cred_id_length,
<<credential_id::binary-size(cred_id_length), public_key_bytes::binary>> <- rest,
<<credential_id::binary-size(^cred_id_length), public_key_bytes::binary>> <- rest,
{:ok, cose_key, remaining} <- CBOR.decode(public_key_bytes),
# Calculate the actual length of the COSE key CBOR data
public_key_cbor_length = byte_size(public_key_bytes) - byte_size(remaining),
<<public_key_cbor::binary-size(public_key_cbor_length), _::binary>> <-
<<public_key_cbor::binary-size(^public_key_cbor_length), _::binary>> <-
public_key_bytes do
{:ok,
%{

View file

@ -9,17 +9,32 @@ defmodule Towerops.Agents.AgentAssignment do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_assignments" do
field :enabled, :boolean, default: true
belongs_to :agent_token, Towerops.Agents.AgentToken
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :agent_token, AgentToken
belongs_to :equipment, Equipment
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
enabled: boolean(),
agent_token_id: Ecto.UUID.t(),
agent_token: NotLoaded.t() | AgentToken.t(),
equipment_id: Ecto.UUID.t(),
equipment: NotLoaded.t() | Equipment.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc """
Changeset for creating and updating agent assignments.

View file

@ -4,6 +4,10 @@ defmodule Towerops.Alerts.Alert do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Equipment.Equipment
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "alerts" do
@ -14,12 +18,28 @@ defmodule Towerops.Alerts.Alert do
field :email_sent_at, :utc_datetime
field :message, :string
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :acknowledged_by, Towerops.Accounts.User
belongs_to :equipment, Equipment
belongs_to :acknowledged_by, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
alert_type: :equipment_down | :equipment_up,
triggered_at: DateTime.t(),
acknowledged_at: DateTime.t() | nil,
resolved_at: DateTime.t() | nil,
email_sent_at: DateTime.t() | nil,
message: String.t() | nil,
equipment_id: Ecto.UUID.t(),
equipment: NotLoaded.t() | Equipment.t(),
acknowledged_by_id: Ecto.UUID.t() | nil,
acknowledged_by: NotLoaded.t() | User.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(alert, attrs) do
alert

View file

@ -17,7 +17,8 @@ defmodule Towerops.Alerts.AlertNotifier do
Returns list of sent emails for tracking.
"""
@spec deliver_alert_notification(Alert) :: {:ok, [{:ok, Swoosh.Email.t()} | {:error, term()}]}
@spec deliver_alert_notification(Alert.t()) ::
{:ok, [{:ok, Swoosh.Email.t()} | {:error, term()}]}
def deliver_alert_notification(%Alert{} = alert) do
equipment =
alert.equipment_id

View file

@ -8,9 +8,10 @@ defmodule Towerops.Application do
@impl true
def start(_type, _args) do
# Run migrations on startup (Ecto handles locking for concurrent runs)
if Application.get_env(:towerops, Towerops.Repo)[:database] != "towerops_test" do
Towerops.Release.migrate()
end
_ =
if Application.get_env(:towerops, Towerops.Repo)[:database] != "towerops_test" do
Towerops.Release.migrate()
end
topologies = Application.get_env(:libcluster, :topologies, [])

View file

@ -24,7 +24,7 @@ defmodule Towerops.Equipment.EventLogger do
@impl true
def init(state) do
# Subscribe to equipment events topic
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:events")
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:events")
Logger.info("EventLogger started and subscribed to equipment events")
{:ok, state}
end

View file

@ -3,8 +3,6 @@ defmodule Towerops.LogFilter do
Filters out noisy log messages from external sources like port scanners and bots.
"""
require Logger
@doc """
Filters out Bandit HTTP errors from invalid requests.

View file

@ -4,7 +4,9 @@ defmodule Towerops.Organizations.Invitation do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@ -15,13 +17,30 @@ defmodule Towerops.Organizations.Invitation do
field :accepted_at, :utc_datetime
field :expires_at, :utc_datetime
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :organization, Organization
belongs_to :invited_by, User
belongs_to :accepted_by, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
email: String.t(),
role: :admin | :member | :viewer,
token: String.t(),
accepted_at: DateTime.t() | nil,
expires_at: DateTime.t(),
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
invited_by_id: Ecto.UUID.t(),
invited_by: NotLoaded.t() | User.t(),
accepted_by_id: Ecto.UUID.t() | nil,
accepted_by: NotLoaded.t() | User.t() | nil,
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(invitation, attrs) do
invitation

View file

@ -4,17 +4,32 @@ defmodule Towerops.Organizations.Membership do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organization_memberships" do
field :role, Ecto.Enum, values: [:owner, :admin, :member, :viewer]
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :user, Towerops.Accounts.User
belongs_to :organization, Organization
belongs_to :user, User
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
role: :owner | :admin | :member | :viewer,
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
user_id: Ecto.UUID.t(),
user: NotLoaded.t() | User.t(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(membership, attrs) do
membership

View file

@ -4,21 +4,39 @@ defmodule Towerops.Organizations.Organization do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organizations" do
field :name, :string
field :slug, :string
belongs_to :default_agent_token, Towerops.Agents.AgentToken
belongs_to :default_agent_token, AgentToken
has_many :memberships, Towerops.Organizations.Membership
has_many :memberships, Membership
has_many :users, through: [:memberships, :user]
has_many :invitations, Towerops.Organizations.Invitation
has_many :invitations, Invitation
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
slug: String.t(),
default_agent_token_id: Ecto.UUID.t() | nil,
default_agent_token: NotLoaded.t() | AgentToken.t() | nil,
memberships: NotLoaded.t() | [Membership.t()],
users: NotLoaded.t() | [Towerops.Accounts.User.t()],
invitations: NotLoaded.t() | [Invitation.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(organization, attrs) do
organization

View file

@ -4,6 +4,10 @@ defmodule Towerops.Sites.Site do
import Ecto.Changeset
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Equipment.Equipment
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
@primary_key {:id, :binary_id, autogenerate: true}
@ -13,15 +17,32 @@ defmodule Towerops.Sites.Site do
field :description, :string
field :location, :string
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :agent_token, Towerops.Agents.AgentToken
belongs_to :organization, Organization
belongs_to :agent_token, AgentToken
belongs_to :parent_site, Site
has_many :child_sites, Site, foreign_key: :parent_site_id
has_many :equipment, Towerops.Equipment.Equipment
has_many :equipment, Equipment
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
name: String.t(),
description: String.t() | nil,
location: String.t() | nil,
organization_id: Ecto.UUID.t(),
organization: NotLoaded.t() | Organization.t(),
agent_token_id: Ecto.UUID.t() | nil,
agent_token: NotLoaded.t() | AgentToken.t() | nil,
parent_site_id: Ecto.UUID.t() | nil,
parent_site: NotLoaded.t() | t() | nil,
child_sites: NotLoaded.t() | [t()],
equipment: NotLoaded.t() | [Equipment.t()],
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@doc false
def changeset(site, attrs) do
site

View file

@ -109,15 +109,16 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device} <- save_discovery_results(equipment, device_info, interfaces, sensors),
{:ok, neighbors} <- NeighborDiscovery.discover_neighbors(client_opts, device.interfaces),
:ok <- save_neighbors(equipment.id, neighbors) do
update_equipment_discovery_time(equipment)
_ = update_equipment_discovery_time(equipment)
Logger.info("SNMP discovery completed successfully for: #{equipment.name}")
# Broadcast discovery completion for real-time updates
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment.id}",
{:discovery_completed, equipment.id}
)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment.id}",
{:discovery_completed, equipment.id}
)
{:ok, device}
else
@ -278,9 +279,9 @@ defmodule Towerops.Snmp.Discovery do
device = upsert_device(equipment, device_info)
# Delete old interfaces/sensors and insert new ones
delete_old_data(device)
_ = delete_old_data(device)
new_interfaces = insert_interfaces(device, interfaces)
insert_sensors(device, sensors)
_ = insert_sensors(device, sensors)
# Return device with interfaces loaded and equipment_id added
%{device | interfaces: Enum.map(new_interfaces, &Map.put(&1, :equipment_id, equipment.id))}

View file

@ -4,6 +4,8 @@ defmodule Towerops.Snmp.InterfaceStat do
import Ecto.Changeset
alias Towerops.Snmp.Interface
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_interface_stats" do
@ -15,11 +17,25 @@ defmodule Towerops.Snmp.InterfaceStat do
field :if_out_discards, :integer
field :checked_at, :utc_datetime
belongs_to :interface, Towerops.Snmp.Interface
belongs_to :interface, Interface
timestamps(type: :utc_datetime, updated_at: false)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
if_in_octets: integer() | nil,
if_out_octets: integer() | nil,
if_in_errors: integer() | nil,
if_out_errors: integer() | nil,
if_in_discards: integer() | nil,
if_out_discards: integer() | nil,
checked_at: DateTime.t(),
interface_id: Ecto.UUID.t(),
interface: Ecto.Association.NotLoaded.t() | Interface.t(),
inserted_at: DateTime.t()
}
@doc false
def changeset(stat, attrs) do
stat

View file

@ -53,7 +53,7 @@ defmodule Towerops.Snmp.PollerWorker do
equipment = Equipment.get_equipment!(equipment_id)
# Subscribe to discovery completion events for this equipment
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
if equipment.snmp_enabled do
# Get the device to check if it has sensors/interfaces
@ -500,7 +500,13 @@ defmodule Towerops.Snmp.PollerWorker do
defp broadcast_interface_events(events) do
Enum.each(events, fn {:event, event_attrs} ->
Phoenix.PubSub.broadcast(Towerops.PubSub, "equipment:events", {:equipment_event, event_attrs})
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:events",
{:equipment_event, event_attrs}
)
Logger.debug("Broadcast event: #{event_attrs.message}")
end)
end
@ -590,7 +596,7 @@ defmodule Towerops.Snmp.PollerWorker do
s when s > 8 ->
# Large binary, try reading last 8 bytes
offset = s - 8
<<_prefix::binary-size(offset), counter::unsigned-big-integer-size(64)>> = value
<<_prefix::binary-size(^offset), counter::unsigned-big-integer-size(64)>> = value
counter
_ ->
@ -896,7 +902,7 @@ defmodule Towerops.Snmp.PollerWorker do
defp broadcast_sensor_events(events) do
Enum.each(events, fn event ->
Phoenix.PubSub.broadcast(Towerops.PubSub, "equipment:events", {:equipment_event, event})
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, "equipment:events", {:equipment_event, event})
Logger.debug("Sensor event: #{event.message}")
end)
end

View file

@ -4,6 +4,8 @@ defmodule Towerops.Snmp.SensorReading do
import Ecto.Changeset
alias Towerops.Snmp.Sensor
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_sensor_readings" do
@ -11,11 +13,21 @@ defmodule Towerops.Snmp.SensorReading do
field :status, :string
field :checked_at, :utc_datetime
belongs_to :sensor, Towerops.Snmp.Sensor
belongs_to :sensor, Sensor
timestamps(type: :utc_datetime, updated_at: false)
end
@type t :: %__MODULE__{
id: Ecto.UUID.t(),
value: float() | nil,
status: String.t(),
checked_at: DateTime.t(),
sensor_id: Ecto.UUID.t(),
sensor: Ecto.Association.NotLoaded.t() | Sensor.t(),
inserted_at: DateTime.t()
}
@doc false
def changeset(reading, attrs) do
reading

View file

@ -356,7 +356,6 @@ defmodule ToweropsWeb.AgentChannel do
defp parse_integer(_), do: nil
defp parse_float(nil), do: nil
defp parse_float(value) when is_float(value), do: value
defp parse_float(value) when is_integer(value), do: value / 1.0

View file

@ -149,9 +149,10 @@ defmodule ToweropsWeb.EquipmentLive.Form do
if equipment.snmp_enabled do
# Run discovery in background
Task.start(fn ->
Snmp.discover_equipment(equipment)
end)
_ =
Task.start(fn ->
Snmp.discover_equipment(equipment)
end)
{:noreply, put_flash(socket, :info, "Discovery started...")}
else
@ -207,7 +208,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
defp handle_equipment_creation(equipment) do
if equipment.snmp_enabled do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
_ = Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment created successfully. SNMP discovery started in background."
else
"Equipment created successfully"
@ -216,7 +217,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
defp handle_equipment_update(old_equipment, equipment) do
if should_trigger_snmp_discovery?(old_equipment, equipment) do
Task.start(fn -> Snmp.discover_equipment(equipment) end)
_ = Task.start(fn -> Snmp.discover_equipment(equipment) end)
"Equipment updated successfully. SNMP discovery started in background."
else
"Equipment updated successfully"

View file

@ -13,10 +13,11 @@ defmodule ToweropsWeb.EquipmentLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
tab = Map.get(params, "tab", "overview")

View file

@ -12,9 +12,10 @@ defmodule ToweropsWeb.GraphLive.Show do
@impl true
def handle_params(%{"id" => equipment_id, "sensor_type" => sensor_type} = params, _, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
end
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "equipment:#{equipment_id}")
end
range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id")

View file

@ -17,9 +17,10 @@ defmodule ToweropsWeb.MobileQRLive do
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
# Start polling to check if token has been completed
if connected?(socket) do
schedule_check()
end
_ =
if connected?(socket) do
schedule_check()
end
socket =
socket

View file

@ -21,7 +21,7 @@ defmodule ToweropsWeb.Plugs.MobileAuth do
{:ok, session} <- validate_session(token),
{:ok, user} <- get_user(session.user_id) do
# Touch the session to update last_used_at
Task.start(fn -> MobileSessions.touch_session(session) end)
_ = Task.start(fn -> MobileSessions.touch_session(session) end)
conn
|> assign(:current_user, user)
@ -59,5 +59,4 @@ defmodule ToweropsWeb.Plugs.MobileAuth do
defp error_message(:missing_token), do: "Authorization header is missing or invalid"
defp error_message(:invalid_token), do: "Invalid or expired authentication token"
defp error_message(:user_not_found), do: "User not found"
defp error_message(_), do: "Authentication failed"
end

View file

@ -11,19 +11,21 @@ defmodule ToweropsWeb.Telemetry do
@impl true
def init(_arg) do
# Attach telemetry handlers for logging request failures
:telemetry.attach(
"towerops-router-exception",
[:phoenix, :router_dispatch, :exception],
&__MODULE__.handle_router_exception/4,
nil
)
_ =
:telemetry.attach(
"towerops-router-exception",
[:phoenix, :router_dispatch, :exception],
&__MODULE__.handle_router_exception/4,
nil
)
:telemetry.attach(
"towerops-endpoint-stop",
[:phoenix, :endpoint, :stop],
&__MODULE__.handle_endpoint_stop/4,
nil
)
_ =
:telemetry.attach(
"towerops-endpoint-stop",
[:phoenix, :endpoint, :stop],
&__MODULE__.handle_endpoint_stop/4,
nil
)
children = [
# Telemetry poller will execute the given period measurements