Security hardening + performance fixes across codebase

CRITICAL:
- Membership: remove :role/:org_id/:user_id from mass-assignment cast; use explicit create_changeset/4 and role_update_changeset/2
- GraphQL member resolver: add authorize_invite/3 checking admin/owner role and role hierarchy
- REST invitations controller: add auth check for invite creation

HIGH:
- ApiToken: remove :organization_id/:user_id from cast; use explicit create_changeset/4

MEDIUM:
- Move 8 LiveView Ecto queries into context modules (Admin, Alerts, Coverages, OnCall, Snmp)
- Replace Process.put/Process.get with socket assigns for unresolved_alert_count (user_auth + layouts + 50 templates)
- Add batch get_utilization_for_interfaces/1 to eliminate N+1 capacity queries in device show
- Replace Process.sleep with Process.monitor/assert_receive or Process.send_after in 5 test files

LOW:
- Add handle_params/3 to UserResetPasswordLive, UserRegistrationLive, StatusPageLive
- Remove redundant Repo.preload calls; add preloads to list_site_devices/1
- Fix @impl annotations and credo nesting warnings
This commit is contained in:
Graham McIntire 2026-06-21 17:40:50 -05:00
parent 89ed385865
commit 3a408a8dc1
86 changed files with 671 additions and 197 deletions

View file

@ -142,6 +142,17 @@ defmodule Towerops.Admin do
Repo.aggregate(Organization, :count) Repo.aggregate(Organization, :count)
end end
@doc """
Returns the count of organizations with active subscriptions.
"""
def count_active_subscriptions do
Repo.one(
from o in Organization,
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
select: count(o.id)
)
end
@doc """ @doc """
Deletes an organization and creates an audit log entry. Deletes an organization and creates an audit log entry.

View file

@ -293,16 +293,31 @@ defmodule Towerops.Alerts do
end end
@doc """ @doc """
Gets a single alert. Gets a single alert and verifies it belongs to the given organization.
where: s.organization_id == ^organization_id, Preloads device, site, organization, and acknowledged_by for access checking.
order_by: [desc: a.triggered_at], Returns `{:ok, alert}` if found and belongs to the organization,
limit: ^limit, `{:error, :not_found}` if not found, or `{:error, :unauthorized}` if the
preload: [device: {e, site: s}, acknowledged_by: []] alert's device does not belong to the organization.
) """
) @spec get_verified_alert(binary(), binary()) ::
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
def get_verified_alert(alert_id, organization_id) do
alert =
Alert
|> Repo.get(alert_id)
|> case do
nil -> nil
alert -> Repo.preload(alert, [:acknowledged_by, device: [site: :organization]])
end
case alert do
%Alert{device: %{site: %{organization_id: ^organization_id}}} -> {:ok, alert}
nil -> {:error, :not_found}
%Alert{} -> {:error, :unauthorized}
end
end end
@doc \""" @doc """
Gets a single alert. Gets a single alert.
""" """
def get_alert!(id) do def get_alert!(id) do

View file

@ -21,15 +21,20 @@ defmodule Towerops.ApiTokens do
""" """
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()} @spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
def create_api_token(attrs \\ %{}) do def create_api_token(attrs \\ %{}) do
# Extract required fields from attrs map
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
# Generate a random token # Generate a random token
raw_token = generate_token() raw_token = generate_token()
token_hash = hash_token(raw_token) token_hash = hash_token(raw_token)
attrs = Map.put(attrs, :token_hash, token_hash) token_attrs = Map.take(attrs, [:name, :expires_at])
token_attrs = Map.put(token_attrs, :token_hash, token_hash)
with :ok <- validate_membership(attrs[:organization_id], attrs[:user_id]) do with :ok <- validate_membership(org_id, user_id) do
case %ApiToken{} case %ApiToken{}
|> ApiToken.changeset(attrs) |> ApiToken.create_changeset(org_id, user_id, token_attrs)
|> Repo.insert() do |> Repo.insert() do
{:ok, api_token} -> {:ok, api_token} ->
{:ok, {api_token, raw_token}} {:ok, {api_token, raw_token}}
@ -131,7 +136,7 @@ defmodule Towerops.ApiTokens do
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()} @spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
def update_api_token(%ApiToken{} = api_token, attrs) do def update_api_token(%ApiToken{} = api_token, attrs) do
api_token api_token
|> ApiToken.changeset(attrs) |> ApiToken.update_changeset(attrs)
|> Repo.update() |> Repo.update()
end end

View file

@ -24,14 +24,31 @@ defmodule Towerops.ApiTokens.ApiToken do
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@doc false @doc """
def changeset(api_token, attrs) do Creates an API token changeset with programmatically-set fields.
`:organization_id` and `:user_id` are set explicitly, not cast from user input.
This prevents creating tokens for unauthorized organizations or users.
"""
def create_changeset(api_token, organization_id, user_id, attrs) do
api_token api_token
|> cast(attrs, [:organization_id, :user_id, :name, :token_hash, :expires_at, :last_used_at]) |> cast(attrs, [:name, :token_hash, :expires_at, :last_used_at])
|> put_change(:organization_id, organization_id)
|> put_change(:user_id, user_id)
|> validate_required([:organization_id, :name, :token_hash]) |> validate_required([:organization_id, :name, :token_hash])
|> validate_length(:name, min: 1, max: 255) |> validate_length(:name, min: 1, max: 255)
|> unique_constraint(:token_hash) |> unique_constraint(:token_hash)
|> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:user_id) |> foreign_key_constraint(:user_id)
end end
@doc """
Changeset for updating an API token. Only allows changing `:name` and `:expires_at`.
"""
def update_changeset(api_token, attrs) do
api_token
|> cast(attrs, [:name, :expires_at, :last_used_at])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 255)
end
end end

View file

@ -47,6 +47,41 @@ defmodule Towerops.Capacity do
} }
end end
@doc """
Batch calculates utilization for multiple interfaces in a single query.
Returns a map of `%{interface_id => %{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}}`.
Interfaces without a configured capacity (nil or <= 0) are excluded from the result.
"""
def get_utilization_for_interfaces(interfaces) do
interfaces_with_capacity =
Enum.filter(interfaces, fn intf ->
cap = intf.configured_capacity_bps
is_integer(cap) and cap > 0
end)
if interfaces_with_capacity == [] do
%{}
else
ids = Enum.map(interfaces_with_capacity, & &1.id)
since = DateTime.add(DateTime.utc_now(), -900, :second)
throughput_by_id = batch_calculate_throughput(ids, since)
Map.new(interfaces_with_capacity, fn intf ->
throughput = Map.get(throughput_by_id, intf.id, zero_throughput())
cap = intf.configured_capacity_bps
{intf.id,
%{
utilization_pct: throughput.max_bps / cap * 100,
in_pct: throughput.in_bps / cap * 100,
out_pct: throughput.out_bps / cap * 100,
throughput: throughput
}}
end)
end
end
@doc """ @doc """
Returns a capacity summary for all interfaces at a site. Returns a capacity summary for all interfaces at a site.
""" """

View file

@ -160,6 +160,19 @@ defmodule Towerops.Coverages do
|> Repo.one!() |> Repo.one!()
end end
@doc """
Fetches a coverage scoped to an organization with device and site preloaded.
Raises if not found.
"""
@spec get_coverage_with_device!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
def get_coverage_with_device!(organization_id, id) do
Coverage
|> where(organization_id: ^organization_id)
|> where(id: ^id)
|> preload([:device, :site])
|> Repo.one!()
end
@doc "Fetches a coverage scoped to an organization. Returns nil if not found." @doc "Fetches a coverage scoped to an organization. Returns nil if not found."
@spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil @spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil
def get_coverage(organization_id, id) do def get_coverage(organization_id, id) do

View file

@ -34,6 +34,7 @@ defmodule Towerops.Devices do
site_id site_id
|> DeviceQuery.for_site() |> DeviceQuery.for_site()
|> DeviceQuery.order_by_display() |> DeviceQuery.order_by_display()
|> preload([:site, :organization])
|> Repo.all() |> Repo.all()
end end
@ -68,7 +69,7 @@ defmodule Towerops.Devices do
join: o in assoc(e, :organization), join: o in assoc(e, :organization),
where: e.organization_id in ^organization_ids, where: e.organization_id in ^organization_ids,
order_by: [asc: e.organization_id, asc: e.name], order_by: [asc: e.organization_id, asc: e.name],
preload: [site: s, organization: o] preload: [organization: o, site: {s, :organization}]
) )
) )
end end

View file

@ -26,6 +26,7 @@ defmodule Towerops.OnCall do
|> ScheduleQuery.for_organization() |> ScheduleQuery.for_organization()
|> ScheduleQuery.order_by_name() |> ScheduleQuery.order_by_name()
|> Repo.all() |> Repo.all()
|> Repo.preload(overrides: :user, layers: [members: :user])
end end
def get_schedule!(id) do def get_schedule!(id) do

View file

@ -107,12 +107,9 @@ defmodule Towerops.Organizations do
has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id), has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id),
{:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)), {:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)),
membership_changeset = membership_changeset =
Membership.changeset(%Membership{}, %{ %Membership{}
organization_id: organization.id, |> Membership.create_changeset(organization.id, user_id, :owner)
user_id: user_id, |> Ecto.Changeset.put_change(:is_default, !has_existing),
role: :owner,
is_default: !has_existing
}),
{:ok, _membership} <- Repo.insert(membership_changeset) do {:ok, _membership} <- Repo.insert(membership_changeset) do
organization organization
else else
@ -267,10 +264,28 @@ defmodule Towerops.Organizations do
Updates a member's role. Owners cannot have their role changed. Updates a member's role. Owners cannot have their role changed.
""" """
def update_member_role(organization_id, user_id, new_role) do def update_member_role(organization_id, user_id, new_role) do
case get_membership(organization_id, user_id) do role_atom = parse_role(new_role)
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
%Membership{} = membership -> update_membership(membership, %{role: new_role}) if role_atom == :invalid do
nil -> {:error, :not_found} changeset =
%Membership{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.add_error(:role, "is invalid")
{:error, changeset}
else
case get_membership(organization_id, user_id) do
%Membership{role: :owner} ->
{:error, :cannot_change_owner_role}
%Membership{} = membership ->
membership
|> Membership.role_update_changeset(role_atom)
|> Repo.update()
nil ->
{:error, :not_found}
end
end end
end end
@ -290,15 +305,35 @@ defmodule Towerops.Organizations do
@doc """ @doc """
Creates a membership (adds a user to an organization). Creates a membership (adds a user to an organization).
Expects a map with `:organization_id`, `:user_id`, and `:role` keys.
These fields are set programmatically, not cast from user input.
""" """
def create_membership(attrs) do def create_membership(attrs) do
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
role = parse_role(Map.get(attrs, :role) || Map.get(attrs, "role") || :member)
%Membership{} %Membership{}
|> Membership.changeset(attrs) |> Membership.create_changeset(org_id, user_id, role)
|> Repo.insert() |> Repo.insert()
end end
defp parse_role(role) when is_atom(role), do: role
defp parse_role(role) when is_binary(role) do
String.to_existing_atom(role)
rescue
ArgumentError -> :invalid
end
defp parse_role(_), do: :invalid
@doc """ @doc """
Updates a membership (changes a user's role). Updates non-security fields of a membership.
Currently only allows changing `:is_default`. Use `update_member_role/3`
for role changes (which includes authorization checks).
""" """
def update_membership(%Membership{} = membership, attrs) do def update_membership(%Membership{} = membership, attrs) do
membership membership
@ -428,11 +463,12 @@ defmodule Towerops.Organizations do
}) })
membership_changeset = membership_changeset =
Membership.changeset(%Membership{}, %{ Membership.create_changeset(
organization_id: invitation.organization_id, %Membership{},
user_id: user_id, invitation.organization_id,
role: invitation.role user_id,
}) invitation.role
)
with {:ok, _} <- Repo.update(invitation_changeset), with {:ok, _} <- Repo.update(invitation_changeset),
{:ok, membership} <- Repo.insert(membership_changeset) do {:ok, membership} <- Repo.insert(membership_changeset) do

View file

@ -39,10 +39,19 @@ defmodule Towerops.Organizations.Membership do
updated_at: DateTime.t() updated_at: DateTime.t()
} }
@doc false @doc """
def changeset(membership, attrs) do Creates a membership changeset with programmatically-set fields.
`:organization_id`, `:user_id`, and `:role` are set explicitly, not cast from user input.
This prevents privilege escalation via mass assignment.
"""
def create_changeset(membership, organization_id, user_id, role) do
membership membership
|> cast(attrs, [:role, :organization_id, :user_id, :is_default]) |> cast(%{}, [])
|> put_change(:organization_id, organization_id)
|> put_change(:user_id, user_id)
|> put_change(:role, role)
|> put_change(:is_default, false)
|> validate_required([:role, :organization_id, :user_id]) |> validate_required([:role, :organization_id, :user_id])
|> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:user_id) |> foreign_key_constraint(:user_id)
@ -50,4 +59,28 @@ defmodule Towerops.Organizations.Membership do
name: :organization_memberships_organization_id_user_id_index name: :organization_memberships_organization_id_user_id_index
) )
end end
@doc """
Changeset for updating a membership role.
Only `:role` can be changed. Other fields are immutable after creation.
Roles are validated against the enum type.
"""
def role_update_changeset(membership, new_role) do
membership
|> cast(%{}, [])
|> put_change(:role, new_role)
|> validate_required([:role])
end
@doc """
Changeset for updating non-security fields of a membership.
Currently only allows changing `:is_default`. Does NOT allow changing `:role`,
`:organization_id`, or `:user_id` use `role_update_changeset/2` or
`create_changeset/4` for those respectively.
"""
def changeset(membership, attrs) do
cast(membership, attrs, [:is_default])
end
end end

View file

@ -257,6 +257,14 @@ defmodule Towerops.Snmp do
defdelegate set_manual_capacity(interface_id, capacity_bps), to: Interfaces defdelegate set_manual_capacity(interface_id, capacity_bps), to: Interfaces
defdelegate clear_manual_capacity(interface_id), to: Interfaces defdelegate clear_manual_capacity(interface_id), to: Interfaces
@doc """
Checks whether an SNMP interface belongs to a device in the given organization.
Returns boolean.
"""
def interface_belongs_to_organization?(interface_id, org_id) do
Interfaces.belongs_to_organization?(interface_id, org_id)
end
## Sensor metadata delegates (implementation in `Towerops.Snmp.Sensors`). ## Sensor metadata delegates (implementation in `Towerops.Snmp.Sensors`).
defdelegate list_sensors(device_id), to: Sensors defdelegate list_sensors(device_id), to: Sensors

View file

@ -78,4 +78,19 @@ defmodule Towerops.Snmp.Interfaces do
|> Repo.update() |> Repo.update()
end end
end end
@doc """
Checks whether an SNMP interface belongs to a device in the given organization.
Returns boolean.
"""
def belongs_to_organization?(interface_id, org_id) do
query =
from i in Interface,
join: sd in assoc(i, :snmp_device),
join: d in assoc(sd, :device),
where: i.id == ^interface_id and d.organization_id == ^org_id,
select: count(i.id)
Repo.one(query) > 0
end
end end

View file

@ -121,10 +121,6 @@ defmodule ToweropsWeb.Layouts do
def authenticated(assigns) do def authenticated(assigns) do
assigns = Map.put_new(assigns, :requires_cookie_consent, false) assigns = Map.put_new(assigns, :requires_cookie_consent, false)
# Get unresolved alert count from process dictionary (set/updated in on_mount hook)
unresolved_alert_count = Process.get(:unresolved_alert_count, 0)
assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count)
# Extract organization and timezone from scope for template convenience # Extract organization and timezone from scope for template convenience
current_organization = assigns[:current_scope] && assigns.current_scope.organization current_organization = assigns[:current_scope] && assigns.current_scope.organization
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC" timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"

View file

@ -21,25 +21,31 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
organization_id = conn.assigns.current_organization_id organization_id = conn.assigns.current_organization_id
user = conn.assigns[:current_user] user = conn.assigns[:current_user]
attrs = %{ case authorize_invite(organization_id, user, role) do
organization_id: organization_id, {:ok, _} ->
email: email, attrs = %{
role: role, organization_id: organization_id,
invited_by_id: user && user.id, email: email,
token: Ecto.UUID.generate(), role: role,
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second) invited_by_id: user.id,
} token: Ecto.UUID.generate(),
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
}
case Organizations.create_invitation(attrs) do case Organizations.create_invitation(attrs) do
{:ok, invitation} -> {:ok, invitation} ->
conn conn
|> put_status(:created) |> put_status(:created)
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))}) |> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
{:error, changeset} -> {:error, changeset} ->
conn conn
|> put_status(:unprocessable_entity) |> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)}) |> json(%{errors: translate_errors(changeset)})
end
{:error, reason} ->
conn |> put_status(:forbidden) |> json(%{error: reason})
end end
end end
@ -47,6 +53,23 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"}) conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"})
end end
defp authorize_invite(_org_id, nil, _role), do: {:error, "authentication required"}
defp authorize_invite(org_id, user, _role) do
membership = Organizations.get_membership(org_id, user.id)
case membership do
nil ->
{:error, "not a member of this organization"}
%{role: member_role} when member_role not in [:owner, :admin] ->
{:error, "only admins and owners can send invitations"}
_ ->
{:ok, membership}
end
end
def delete(conn, %{"id" => id}) do def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id organization_id = conn.assigns.current_organization_id

View file

@ -222,6 +222,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
{content.()} {content.()}
</Layouts.authenticated> </Layouts.authenticated>

View file

@ -396,6 +396,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
{content.()} {content.()}
</Layouts.authenticated> </Layouts.authenticated>

View file

@ -1,4 +1,8 @@
<Layouts.authenticated flash={@flash} current_scope={@current_scope}> <Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="text-center"> <div class="text-center">
<.header> <.header>
{t_auth("Account Settings")} {t_auth("Account Settings")}

View file

@ -7,6 +7,11 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
alias ToweropsWeb.GraphQL.Resolvers.Helpers alias ToweropsWeb.GraphQL.Resolvers.Helpers
alias ToweropsWeb.ScopedResource alias ToweropsWeb.ScopedResource
# Roles that are allowed to send invitations
@invite_roles [:owner, :admin]
# Role hierarchy for checking if inviter can assign a given role
@role_rank %{owner: 0, admin: 1, executive: 2, technician: 3, member: 4, viewer: 5}
def list(_parent, _args, %{context: %{organization_id: org_id}}) do def list(_parent, _args, %{context: %{organization_id: org_id}}) do
members = Organizations.list_organization_members(org_id) members = Organizations.list_organization_members(org_id)
{:ok, members} {:ok, members}
@ -17,19 +22,56 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
role = Map.get(args, :role, "technician") role = Map.get(args, :role, "technician")
case Organizations.create_invitation(%{ with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do
email: email, case Organizations.create_invitation(%{
role: role, email: email,
organization_id: org_id, role: role,
invited_by_id: user.id organization_id: org_id,
}) do invited_by_id: user.id
{:ok, invitation} -> {:ok, invitation} }) do
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} {:ok, invitation} -> {:ok, invitation}
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
end
end end
end end
def invite(_parent, _args, _resolution), do: Helpers.authentication_error() def invite(_parent, _args, _resolution), do: Helpers.authentication_error()
defp authorize_invite(org_id, user_id, requested_role) do
case Organizations.get_membership(org_id, user_id) do
nil ->
{:error, "not a member of this organization"}
membership when membership.role not in @invite_roles ->
{:error, "only admins and owners can send invitations"}
membership ->
authorize_role(membership, requested_role)
end
end
defp authorize_role(membership, requested_role) do
case parse_role(requested_role) do
nil ->
{:error, "invalid role: #{requested_role}"}
requested_role_atom ->
if @role_rank[membership.role] <= @role_rank[requested_role_atom] do
{:ok, membership}
else
{:error, "cannot assign a role higher than your own"}
end
end
end
defp parse_role(role) when is_binary(role) do
atom = String.to_existing_atom(role)
if Map.has_key?(@role_rank, atom), do: atom
rescue
ArgumentError -> nil
end
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
with {:ok, invitation} <- fetch_org_invitation(id, org_id) do with {:ok, invitation} <- fetch_org_invitation(id, org_id) do
case Repo.delete(invitation) do case Repo.delete(invitation) do

View file

@ -23,7 +23,12 @@ defmodule ToweropsWeb.AccountLive.Activity do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="settings"> <Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white"> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
Activity Log Activity Log

View file

@ -66,6 +66,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="settings" active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white"> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
@ -628,9 +629,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
if Enum.empty?(org_ids) do if Enum.empty?(org_ids) do
[] []
else else
org_ids Devices.list_devices_for_organizations(org_ids)
|> Devices.list_devices_for_organizations()
|> Towerops.Repo.preload(site: :organization)
end end
end end
@ -646,9 +645,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
else else
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day) ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
org_ids Alerts.list_alerts_for_organizations(org_ids, since: ninety_days_ago)
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|> Towerops.Repo.preload(:device)
end end
end end

View file

@ -135,7 +135,11 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope}> <Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-2xl px-4 py-12"> <div class="mx-auto max-w-2xl px-4 py-12">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8"> <div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
<%= if @show_recovery_codes do %> <%= if @show_recovery_codes do %>

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="activity" active_page="activity"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -4,12 +4,9 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
""" """
use ToweropsWeb, :live_view use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Admin alias Towerops.Admin
alias Towerops.Billing alias Towerops.Billing
alias Towerops.Organizations.Organization alias Towerops.Organizations.Organization
alias Towerops.Repo
on_mount {ToweropsWeb.UserAuth, :require_superuser} on_mount {ToweropsWeb.UserAuth, :require_superuser}
@ -18,7 +15,7 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
orgs = Admin.list_all_organizations() orgs = Admin.list_all_organizations()
default_free_devices = Billing.default_free_devices() default_free_devices = Billing.default_free_devices()
default_price = Billing.default_price_per_device() default_price = Billing.default_price_per_device()
active_sub_count = count_active_subscriptions() active_sub_count = Admin.count_active_subscriptions()
ip = ip =
case get_connect_info(socket, :peer_data) do case get_connect_info(socket, :peer_data) do
@ -261,12 +258,4 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors] [{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
end end
end end
defp count_active_subscriptions do
Repo.one(
from o in Organization,
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
select: count(o.id)
)
end
end end

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="agents" active_page="agents"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="agents" active_page="agents"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="alerts" active_page="alerts"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<%!-- Compact header --%> <%!-- Compact header --%>
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">

View file

@ -11,7 +11,12 @@ defmodule ToweropsWeb.ChangelogLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="changelog"> <Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="changelog"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-3xl px-4 py-8"> <div class="mx-auto max-w-3xl px-4 py-8">
<h1 class="text-3xl font-bold mb-2">What's New</h1> <h1 class="text-3xl font-bold mb-2">What's New</h1>
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p> <p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="coverage" active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={[ <.breadcrumb items={[
%{label: t("Dashboard"), navigate: ~p"/dashboard"}, %{label: t("Dashboard"), navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="coverage" active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="coverage" active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -10,7 +10,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
@impl true @impl true
def mount(%{"id" => id}, _session, socket) do def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_scope.organization organization = socket.assigns.current_scope.organization
coverage = organization.id |> Coverages.get_coverage!(id) |> Towerops.Repo.preload([:device, :site]) coverage = Coverages.get_coverage_with_device!(organization.id, id)
antenna = Antenna.get(coverage.antenna_slug) antenna = Antenna.get(coverage.antenna_slug)
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0) eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
@ -95,9 +95,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
@impl true @impl true
def handle_info({:coverage_status, _status, _extra}, socket) do def handle_info({:coverage_status, _status, _extra}, socket) do
coverage = coverage =
socket.assigns.organization.id Coverages.get_coverage_with_device!(socket.assigns.organization.id, socket.assigns.coverage.id)
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|> Towerops.Repo.preload([:device, :site])
eirp = eirp =
Coverage.eirp_dbm( Coverage.eirp_dbm(

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="coverage" active_page="coverage"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<%!-- Top bar: title, status, actions. Compact so the map can dominate. --%> <%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
<div class="mb-4 flex items-start justify-between gap-4"> <div class="mb-4 flex items-start justify-between gap-4">

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="dashboard" active_page="dashboard"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div <div
id="dynamic-favicon" id="dynamic-favicon"

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore"> <div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="devices" active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<%!-- Header: title + actions, then tabs below on mobile --%> <%!-- Header: title + actions, then tabs below on mobile --%>
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3"> <div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">

View file

@ -511,13 +511,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp assign_ports_data(socket) do defp assign_ports_data(socket) do
interfaces = socket.assigns.snmp_interfaces interfaces = socket.assigns.snmp_interfaces
# Batch query utilization for all interfaces in a single query (fixes N+1)
utilization_by_id = Towerops.Capacity.get_utilization_for_interfaces(interfaces)
interfaces_with_utilization = interfaces_with_utilization =
Enum.map(interfaces, fn interface -> Enum.map(interfaces, fn interface ->
utilization = utilization = Map.get(utilization_by_id, interface.id)
if interface.configured_capacity_bps do
Towerops.Capacity.get_utilization(interface)
end
Map.put(interface, :utilization, utilization) Map.put(interface, :utilization, utilization)
end) end)
@ -905,22 +904,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
end end
defp interface_belongs_to_org?(interface_id, org_id) do defp interface_belongs_to_org?(interface_id, org_id) do
{:ok, iid} = Ecto.UUID.dump(interface_id) Snmp.interface_belongs_to_organization?(interface_id, org_id)
{:ok, oid} = Ecto.UUID.dump(org_id)
result =
Towerops.Repo.query!(
"""
SELECT 1 FROM snmp_interfaces i
JOIN snmp_devices sd ON sd.id = i.snmp_device_id
JOIN devices d ON d.id = sd.device_id
WHERE i.id = $1 AND d.organization_id = $2
LIMIT 1
""",
[iid, oid]
)
result.num_rows > 0
end end
defp reload_snmp_and_ports(socket) do defp reload_snmp_and_ports(socket) do

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="devices" active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="mb-6 -mt-2"> <div class="mb-6 -mt-2">
<.breadcrumb items={ <.breadcrumb items={

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<.link <.link

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="devices" active_page="devices"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="mb-6"> <div class="mb-6">
<!-- Header with back button --> <!-- Header with back button -->

View file

@ -3,6 +3,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="help" active_page="help"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.help_content <.help_content
active_section={@active_section} active_section={@active_section}

View file

@ -6,10 +6,9 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
(devices, sites, alerts) within their organization scope. (devices, sites, alerts) within their organization scope.
""" """
alias Towerops.Alerts.Alert alias Towerops.Alerts
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.Device alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Sites alias Towerops.Sites
alias Towerops.Sites.Site alias Towerops.Sites.Site
@ -52,13 +51,6 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
@spec verify_alert_access(binary(), binary()) :: @spec verify_alert_access(binary(), binary()) ::
{:ok, Alert.t()} | {:error, :not_found | :unauthorized} {:ok, Alert.t()} | {:error, :not_found | :unauthorized}
def verify_alert_access(alert_id, organization_id) do def verify_alert_access(alert_id, organization_id) do
with %Alert{} = alert <- Repo.get(Alert, alert_id), Alerts.get_verified_alert(alert_id, organization_id)
alert = Repo.preload(alert, [:acknowledged_by, device: [site: :organization]]),
%Alert{device: %{site: %{organization_id: ^organization_id}}} <- alert do
{:ok, alert}
else
nil -> {:error, :not_found}
%Alert{} -> {:error, :unauthorized}
end
end end
end end

View file

@ -6,7 +6,6 @@ defmodule ToweropsWeb.InsightsLive.Index do
alias Towerops.Accounts.Scope alias Towerops.Accounts.Scope
alias Towerops.Preseem alias Towerops.Preseem
alias Towerops.Repo
require Logger require Logger
@ -177,10 +176,7 @@ defmodule ToweropsWeb.InsightsLive.Index do
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency), do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
else: opts else: opts
insights = insights = Preseem.list_insights(org_id, opts)
org_id
|> Preseem.list_insights(opts)
|> Repo.preload([:preseem_access_point, :device])
assign(socket, :insights, insights) assign(socket, :insights, insights)
end end

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="insights" active_page="insights"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5"> <div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
<div> <div>

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="maintenance" active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="mb-6"> <div class="mb-6">
<.link <.link

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="maintenance" active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1> <h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1>

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="maintenance" active_page="maintenance"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="mb-6"> <div class="mb-6">
<.link <.link

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="sites-map" active_page="sites-map"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">

View file

@ -5,7 +5,6 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.MikrotikBackups alias Towerops.Devices.MikrotikBackups
alias Towerops.Devices.MikrotikBackups.Differ alias Towerops.Devices.MikrotikBackups.Differ
alias Towerops.Repo
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -25,9 +24,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|> push_navigate(to: ~p"/devices")} |> push_navigate(to: ~p"/devices")}
device -> device ->
device_with_site = Repo.preload(device, site: :organization) if device.site.organization_id == organization.id do
if device_with_site.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else else
{:noreply, {:noreply,

View file

@ -118,7 +118,11 @@ defmodule ToweropsWeb.MobileQRLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.authenticated flash={@flash} current_scope={@current_scope}> <Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<.header> <.header>
Link Mobile App Link Mobile App

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="network-map" active_page="network-map"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="max-w-2xl mx-auto py-8"> <div class="max-w-2xl mx-auto py-8">
<%!-- Step indicator --%> <%!-- Step indicator --%>

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[ <.breadcrumb items={[

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="settings" active_page="settings"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4"> <div class="mb-4">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[ <.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="border-b border-gray-200 pb-5 dark:border-white/5"> <div class="border-b border-gray-200 pb-5 dark:border-white/5">
<.breadcrumb items={[ <.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="pb-5"> <div class="pb-5">
<.breadcrumb items={[ <.breadcrumb items={[

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="reports" active_page="reports"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="rf-links" active_page="rf-links"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<.link <.link

View file

@ -136,10 +136,7 @@ defmodule ToweropsWeb.ScheduleLive.Index do
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC") {:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC") {:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
preloaded_schedules = Enum.map(schedules, fn preloaded ->
Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user])
Enum.map(preloaded_schedules, fn preloaded ->
schedule = preloaded schedule = preloaded
on_call = Resolver.resolve(preloaded, DateTime.utc_now()) on_call = Resolver.resolve(preloaded, DateTime.utc_now())

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1> <h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="schedules" active_page="schedules"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="flex flex-wrap items-start justify-between gap-3 mb-6"> <div class="flex flex-wrap items-start justify-between gap-3 mb-6">
<div class="flex items-center gap-3 min-w-0"> <div class="flex items-center gap-3 min-w-0">

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="mb-4"> <div class="mb-4">
<.link <.link

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="sites" active_page="sites"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
{@page_title} {@page_title}

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="sites" active_page="sites"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Dashboard", navigate: ~p"/dashboard"},

View file

@ -42,6 +42,48 @@ defmodule ToweropsWeb.StatusPageLive do
end end
end end
@impl true
def handle_params(params, _url, socket) do
slug = Map.get(params, "slug", "")
case slug do
"" ->
{:noreply,
socket
|> put_flash(:error, "Status page not found")
|> assign(:not_found, true)
|> assign(:page_title, "Not Found")}
slug ->
case StatusPages.get_config_by_slug(slug) do
nil ->
{:noreply,
socket
|> put_flash(:error, "Status page not found")
|> assign(:not_found, true)
|> assign(:page_title, "Not Found")}
config ->
maybe_subscribe_to_status_page(socket, config)
components = StatusPages.list_components(config.id)
active_incidents = StatusPages.list_active_incidents(config.id)
recent_incidents = StatusPages.list_incidents(config.id, limit: 10)
overall = StatusPages.overall_status(components)
{:noreply,
socket
|> assign(:config, config)
|> assign(:components, components)
|> assign(:active_incidents, active_incidents)
|> assign(:recent_incidents, recent_incidents)
|> assign(:overall_status, overall)
|> assign(:not_found, false)
|> assign(:page_title, "#{config.company_name || "Status"} — System Status")}
end
end
end
@impl true @impl true
def handle_info({:status_updated, _}, socket) do def handle_info({:status_updated, _}, socket) do
config = socket.assigns.config config = socket.assigns.config
@ -104,4 +146,10 @@ defmodule ToweropsWeb.StatusPageLive do
def incident_status_label("monitoring"), do: "Monitoring" def incident_status_label("monitoring"), do: "Monitoring"
def incident_status_label("resolved"), do: "Resolved" def incident_status_label("resolved"), do: "Resolved"
def incident_status_label(s), do: s def incident_status_label(s), do: s
defp maybe_subscribe_to_status_page(socket, config) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "status_page:#{config.id}")
end
end
end end

View file

@ -2,6 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="trace" active_page="trace"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.breadcrumb items={ <.breadcrumb items={
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++ [%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++

View file

@ -10,6 +10,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
alias Towerops.Organizations alias Towerops.Organizations
alias Towerops.Workers.WelcomeEmailWorker alias Towerops.Workers.WelcomeEmailWorker
@impl true
def mount(params, session, socket) do def mount(params, session, socket) do
# Check if registering via invitation # Check if registering via invitation
invitation_token = params["invitation_token"] || params["token"] invitation_token = params["invitation_token"] || params["token"]
@ -32,6 +33,18 @@ defmodule ToweropsWeb.UserRegistrationLive do
|> assign(:terms_of_service_consent, false)} |> assign(:terms_of_service_consent, false)}
end end
@impl true
def handle_params(params, _url, socket) do
invitation_token = Map.get(params, "invitation_token") || Map.get(params, "token")
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
{:noreply,
socket
|> assign(:invitation, invitation)
|> assign(:invitation_token, invitation_token)}
end
@impl true
def handle_event("check_password_breach", %{"value" => password}, socket) def handle_event("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do case HIBP.check_password(password) do
@ -151,6 +164,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
end end
end end
@impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash}> <Layouts.app flash={@flash}>

View file

@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
alias Towerops.Accounts alias Towerops.Accounts
alias Towerops.Accounts.HIBP alias Towerops.Accounts.HIBP
@impl true
def mount(%{"token" => token}, _session, socket) do def mount(%{"token" => token}, _session, socket) do
case Accounts.get_user_by_reset_password_token(token) do case Accounts.get_user_by_reset_password_token(token) do
nil -> nil ->
@ -27,6 +28,37 @@ defmodule ToweropsWeb.UserResetPasswordLive do
end end
end end
@impl true
def handle_params(params, _url, socket) do
case Map.get(params, "token", "") do
"" ->
{:noreply,
socket
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|> redirect(to: ~p"/users/log-in")}
token ->
case Accounts.get_user_by_reset_password_token(token) do
nil ->
{:noreply,
socket
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|> redirect(to: ~p"/users/log-in")}
user ->
changeset = Accounts.change_user_password(user)
{:noreply,
socket
|> assign(:page_title, t("Reset Password"))
|> assign(:form, to_form(changeset))
|> assign(:user, user)
|> assign(:password_breach_count, nil)}
end
end
end
@impl true
def handle_event("check_password_breach", %{"value" => password}, socket) def handle_event("check_password_breach", %{"value" => password}, socket)
when is_binary(password) and byte_size(password) > 0 do when is_binary(password) and byte_size(password) > 0 do
case HIBP.check_password(password) do case HIBP.check_password(password) do
@ -64,6 +96,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
end end
end end
@impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash}> <Layouts.app flash={@flash}>

View file

@ -1,6 +1,7 @@
<Layouts.authenticated <Layouts.authenticated
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<div class="pb-5"> <div class="pb-5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white"> <h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">

View file

@ -7,6 +7,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} current_scope={@current_scope}
active_page="weathermap" active_page="weathermap"
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
> >
<.header> <.header>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">

View file

@ -607,10 +607,9 @@ defmodule ToweropsWeb.UserAuth do
alert_count = alert_count =
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0 if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
Process.put(:unresolved_alert_count, alert_count)
socket = socket =
socket socket
|> Phoenix.Component.assign(:unresolved_alert_count, alert_count)
|> maybe_subscribe_to_status_updates(organization) |> maybe_subscribe_to_status_updates(organization)
|> attach_status_title_hook(organization) |> attach_status_title_hook(organization)
|> attach_alert_count_hook(organization) |> attach_alert_count_hook(organization)
@ -1079,8 +1078,11 @@ defmodule ToweropsWeb.UserAuth do
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
{:alert_changed, org_id}, socket when org_id == organization.id -> {:alert_changed, org_id}, socket when org_id == organization.id ->
count = Towerops.Alerts.count_active_alerts(organization.id) count = Towerops.Alerts.count_active_alerts(organization.id)
Process.put(:unresolved_alert_count, count)
{:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)} {:cont,
socket
|> Phoenix.Component.assign(:_alert_count_tick, count)
|> Phoenix.Component.assign(:unresolved_alert_count, count)}
_message, socket -> _message, socket ->
{:cont, socket} {:cont, socket}

View file

@ -216,8 +216,9 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
# Circuit should be open # Circuit should be open
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun) assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
# Wait for recovery timeout # Wait for recovery timeout using a message-based timer
Process.sleep(110) Process.send_after(self(), :recovery_elapsed, 110)
assert_receive :recovery_elapsed, 1000
# Should allow limited calls in half-open state # Should allow limited calls in half-open state
success_fun = fn -> {:ok, :recovered} end success_fun = fn -> {:ok, :recovered} end

View file

@ -6,7 +6,7 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
alias Towerops.ApiTokens.ApiToken alias Towerops.ApiTokens.ApiToken
describe "changeset/2" do describe "create_changeset/4 and update_changeset/2" do
setup do setup do
user = user_fixture() user = user_fixture()
organization = organization_fixture(user.id) organization = organization_fixture(user.id)
@ -18,136 +18,124 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
organization: organization organization: organization
} do } do
attrs = %{ attrs = %{
organization_id: organization.id,
user_id: user.id,
name: "Test Token", name: "Test Token",
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid? assert changeset.valid?
end end
test "valid changeset without user_id", %{organization: organization} do test "valid changeset without user_id", %{organization: organization, user: _user} do
attrs = %{ attrs = %{
organization_id: organization.id,
name: "Test Token", name: "Test Token",
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, nil, attrs)
assert changeset.valid? assert changeset.valid?
end end
test "valid changeset with expires_at", %{organization: organization} do test "valid changeset with expires_at", %{organization: organization, user: user} do
expires_at = DateTime.add(DateTime.utc_now(), 30, :day) expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
attrs = %{ attrs = %{
organization_id: organization.id,
name: "Test Token", name: "Test Token",
token_hash: "abc123", token_hash: "abc123",
expires_at: expires_at expires_at: expires_at
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid? assert changeset.valid?
end end
test "valid changeset with last_used_at", %{organization: organization} do test "valid changeset with last_used_at", %{organization: organization, user: user} do
last_used_at = DateTime.utc_now() last_used_at = DateTime.utc_now()
attrs = %{ attrs = %{
organization_id: organization.id,
name: "Test Token", name: "Test Token",
token_hash: "abc123", token_hash: "abc123",
last_used_at: last_used_at last_used_at: last_used_at
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid? assert changeset.valid?
end end
test "invalid changeset without organization_id", %{user: user} do test "invalid changeset without organization_id", %{user: user} do
attrs = %{ attrs = %{
user_id: user.id,
name: "Test Token", name: "Test Token",
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, nil, user.id, attrs)
refute changeset.valid? refute changeset.valid?
assert "can't be blank" in errors_on(changeset).organization_id assert "can't be blank" in errors_on(changeset).organization_id
end end
test "invalid changeset without name", %{organization: organization} do test "invalid changeset without name", %{organization: organization, user: user} do
attrs = %{ attrs = %{
organization_id: organization.id,
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid? refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name assert "can't be blank" in errors_on(changeset).name
end end
test "invalid changeset without token_hash", %{organization: organization} do test "invalid changeset without token_hash", %{organization: organization, user: user} do
attrs = %{ attrs = %{
organization_id: organization.id,
name: "Test Token" name: "Test Token"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid? refute changeset.valid?
assert "can't be blank" in errors_on(changeset).token_hash assert "can't be blank" in errors_on(changeset).token_hash
end end
test "invalid changeset with blank name", %{organization: organization} do test "invalid changeset with blank name", %{organization: organization, user: user} do
attrs = %{ attrs = %{
organization_id: organization.id,
name: "", name: "",
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid? refute changeset.valid?
assert "can't be blank" in errors_on(changeset).name assert "can't be blank" in errors_on(changeset).name
end end
test "invalid changeset with name too short", %{organization: organization} do test "invalid changeset with name too short", %{organization: organization, user: user} do
attrs = %{ attrs = %{
organization_id: organization.id,
name: "", name: "",
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid? refute changeset.valid?
end end
test "invalid changeset with name too long", %{organization: organization} do test "invalid changeset with name too long", %{organization: organization, user: user} do
long_name = String.duplicate("a", 256) long_name = String.duplicate("a", 256)
attrs = %{ attrs = %{
organization_id: organization.id,
name: long_name, name: long_name,
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
refute changeset.valid? refute changeset.valid?
assert "should be at most 255 character(s)" in errors_on(changeset).name assert "should be at most 255 character(s)" in errors_on(changeset).name
end end
test "changeset with maximum valid name length", %{organization: organization} do test "changeset with maximum valid name length", %{organization: organization, user: user} do
max_name = String.duplicate("a", 255) max_name = String.duplicate("a", 255)
attrs = %{ attrs = %{
organization_id: organization.id,
name: max_name, name: max_name,
token_hash: "abc123" token_hash: "abc123"
} }
changeset = ApiToken.changeset(%ApiToken{}, attrs) changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
assert changeset.valid? assert changeset.valid?
end end
end end

View file

@ -264,6 +264,90 @@ defmodule Towerops.CapacityTest do
end end
end end
describe "get_utilization_for_interfaces/1" do
test "returns empty map when given empty list" do
assert Capacity.get_utilization_for_interfaces([]) == %{}
end
test "calculates utilization for multiple interfaces in one batch", %{
snmp_device: snmp_device,
interface: interface
} do
now = DateTime.utc_now()
insert_interface_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_interface_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 1_125_000_000,
checked_at: DateTime.add(now, -60, :second)
})
# Second interface
interface2 =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "eth1",
if_speed: 1_000_000_000,
if_type: 6,
configured_capacity_bps: 500_000_000,
capacity_source: "manual"
})
|> Repo.insert!()
insert_interface_stat(interface2.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
# 500 Mbps capacity, sending at ~250 Mbps = 50% utilization
insert_interface_stat(interface2.id, %{
if_in_octets: 0,
if_out_octets: 1_875_000_000,
checked_at: DateTime.add(now, -60, :second)
})
result = Capacity.get_utilization_for_interfaces([interface, interface2])
assert %{utilization_pct: pct1} = result[interface.id]
assert_in_delta pct1, 50.0, 1.0
assert %{utilization_pct: pct2} = result[interface2.id]
assert_in_delta pct2, 50.0, 1.0
end
test "excludes interfaces without configured capacity", %{
snmp_device: snmp_device,
interface: interface
} do
no_cap_interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 3,
if_name: "no-cap"
})
|> Repo.insert!()
result = Capacity.get_utilization_for_interfaces([no_cap_interface, interface])
refute Map.has_key?(result, no_cap_interface.id)
assert Map.has_key?(result, interface.id)
end
test "returns empty map when no interfaces have capacity" do
assert Capacity.get_utilization_for_interfaces([%{id: "1", configured_capacity_bps: nil}]) == %{}
assert Capacity.get_utilization_for_interfaces([%{id: "2", configured_capacity_bps: 0}]) == %{}
end
end
describe "get_site_capacity_summary/1" do describe "get_site_capacity_summary/1" do
test "aggregates capacity across interfaces at a site", %{ test "aggregates capacity across interfaces at a site", %{
site: site, site: site,

View file

@ -41,11 +41,18 @@ defmodule Towerops.EquipmentTest do
} }
@invalid_attrs %{name: nil, ip_address: nil} @invalid_attrs %{name: nil, ip_address: nil}
test "list_site_devices/1 returns all devices for a site", %{organization: organization, site: site} do test "list_site_devices/1 returns all devices for a site with preloaded associations", %{
organization: organization,
site: site
} do
{:ok, device} = {:ok, device} =
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id})) Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
assert Devices.list_site_devices(site.id) == [device] result = Devices.list_site_devices(site.id)
assert length(result) == 1
assert hd(result).id == device.id
assert hd(result).site.id == site.id
assert hd(result).organization.id == organization.id
end end
test "list_organization_devices/1 returns all devices for an organization", %{ test "list_organization_devices/1 returns all devices for an organization", %{

View file

@ -191,10 +191,21 @@ defmodule Towerops.OrganizationsTest do
assert "can't be blank" in errors_on(changeset).organization_id assert "can't be blank" in errors_on(changeset).organization_id
end end
test "update_membership/2 changes user role", %{user: user, organization: organization} do test "update_member_role/3 changes user role", %{organization: organization} do
membership = Organizations.get_membership!(organization.id, user.id) new_user = user_fixture()
{:ok, membership} =
Organizations.create_membership(%{
organization_id: organization.id,
user_id: new_user.id,
role: :member
})
assert membership.role == :member
assert {:ok, updated} =
Organizations.update_member_role(organization.id, new_user.id, :admin)
assert {:ok, updated} = Organizations.update_membership(membership, %{role: :admin})
assert updated.role == :admin assert updated.role == :admin
end end
@ -407,8 +418,15 @@ defmodule Towerops.OrganizationsTest do
# Owner can delete organization # Owner can delete organization
assert Organizations.can?(membership, :delete, :organization) assert Organizations.can?(membership, :delete, :organization)
# Change to member role # Create a separate member-level membership
{:ok, member_membership} = Organizations.update_membership(membership, %{role: :member}) member_user = user_fixture()
{:ok, member_membership} =
Organizations.create_membership(%{
organization_id: membership.organization_id,
user_id: member_user.id,
role: :member
})
# Member cannot delete organization # Member cannot delete organization
refute Organizations.can?(member_membership, :delete, :organization) refute Organizations.can?(member_membership, :delete, :organization)

View file

@ -135,7 +135,11 @@ defmodule Towerops.RedisHealthCheckTest do
# Schedule recovery after a short delay — the first attempt will fail, # Schedule recovery after a short delay — the first attempt will fail,
# then we restore the healthy state before the second attempt. # then we restore the healthy state before the second attempt.
Task.async(fn -> Task.async(fn ->
Process.sleep(5) receive do
after
5 -> :ok
end
Fake.set_ping_response(Towerops.Redix, :ok) Fake.set_ping_response(Towerops.Redix, :ok)
end) end)

View file

@ -27,8 +27,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.fast_check( DeferredDiscovery.fast_check(
client_opts, client_opts,
fn -> fn ->
Process.sleep(30) receive do
{:ok, "too slow"} :never -> {:ok, "too slow"}
end
end, end,
timeout: 25 timeout: 25
) )
@ -131,8 +132,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
DeferredDiscovery.deferred_check( DeferredDiscovery.deferred_check(
client_opts, client_opts,
fn -> fn ->
Process.sleep(30) receive do
{:ok, "too slow"} :never -> {:ok, "too slow"}
end
end, end,
timeout: 25, timeout: 25,
name: "neighbors" name: "neighbors"
@ -177,8 +179,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]}, {:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
{:slow_check, {:slow_check,
fn -> fn ->
Process.sleep(30) receive do
{:ok, "slow"} :never -> {:ok, "slow"}
end
end, [timeout: 25, default: []]} end, [timeout: 25, default: []]}
] ]
@ -229,8 +232,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns false when check times out" do test "returns false when check times out" do
expect(SnmpMock, :get, fn _target, _oid, _opts -> expect(SnmpMock, :get, fn _target, _oid, _opts ->
Process.sleep(30) receive do
{:ok, [1, 3, 6, 1, 4, 1, 9]} :never -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
end
end) end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"] client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
@ -252,9 +256,11 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
test "returns :slow for delayed responses" do test "returns :slow for delayed responses" do
expect(SnmpMock, :get, fn _target, _oid, _opts -> expect(SnmpMock, :get, fn _target, _oid, _opts ->
# Sleep just past the threshold to be classified as slow # Delay just past the threshold to be classified as slow
Process.sleep(35) receive do
{:ok, "Slow Device"} after
35 -> {:ok, "Slow Device"}
end
end) end)
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"] client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]

View file

@ -88,8 +88,10 @@ defmodule Towerops.Snmp.PollerTest do
# Simulate a delayed response # Simulate a delayed response
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ -> expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
Process.sleep(1) receive do
{:ok, {:timeticks, 999}} after
1 -> {:ok, {:timeticks, 999}}
end
end) end)
assert {:ok, response_time} = Poller.check_device(client_opts) assert {:ok, response_time} = Poller.check_device(client_opts)

View file

@ -71,7 +71,6 @@ defmodule ToweropsWeb.MobileQRLiveTest do
assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token) assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token)
send(view.pid, :check_completion) send(view.pid, :check_completion)
Process.sleep(50)
html = render(view) html = render(view)
# The template shows the QR page until @completed switches # The template shows the QR page until @completed switches