diff --git a/lib/towerops/admin.ex b/lib/towerops/admin.ex index abb4a8b7..95b97c66 100644 --- a/lib/towerops/admin.ex +++ b/lib/towerops/admin.ex @@ -142,6 +142,17 @@ defmodule Towerops.Admin do Repo.aggregate(Organization, :count) 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 """ Deletes an organization and creates an audit log entry. diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 7665ec8d..ac326081 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -293,16 +293,31 @@ defmodule Towerops.Alerts do end @doc """ - Gets a single alert. - where: s.organization_id == ^organization_id, - order_by: [desc: a.triggered_at], - limit: ^limit, - preload: [device: {e, site: s}, acknowledged_by: []] - ) - ) + Gets a single alert and verifies it belongs to the given organization. + Preloads device, site, organization, and acknowledged_by for access checking. + Returns `{:ok, alert}` if found and belongs to the organization, + `{:error, :not_found}` if not found, or `{:error, :unauthorized}` if the + 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 - @doc \""" + @doc """ Gets a single alert. """ def get_alert!(id) do diff --git a/lib/towerops/api_tokens.ex b/lib/towerops/api_tokens.ex index 2e64c78b..9e888e9a 100644 --- a/lib/towerops/api_tokens.ex +++ b/lib/towerops/api_tokens.ex @@ -21,15 +21,20 @@ defmodule Towerops.ApiTokens do """ @spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()} 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 raw_token = generate_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{} - |> ApiToken.changeset(attrs) + |> ApiToken.create_changeset(org_id, user_id, token_attrs) |> Repo.insert() do {:ok, api_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()} def update_api_token(%ApiToken{} = api_token, attrs) do api_token - |> ApiToken.changeset(attrs) + |> ApiToken.update_changeset(attrs) |> Repo.update() end diff --git a/lib/towerops/api_tokens/api_token.ex b/lib/towerops/api_tokens/api_token.ex index 6468c5b0..601e3c5d 100644 --- a/lib/towerops/api_tokens/api_token.ex +++ b/lib/towerops/api_tokens/api_token.ex @@ -24,14 +24,31 @@ defmodule Towerops.ApiTokens.ApiToken do timestamps(type: :utc_datetime) end - @doc false - def changeset(api_token, attrs) do + @doc """ + 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 - |> 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_length(:name, min: 1, max: 255) |> unique_constraint(:token_hash) |> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:user_id) 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 diff --git a/lib/towerops/capacity.ex b/lib/towerops/capacity.ex index 43f757cd..ed90b653 100644 --- a/lib/towerops/capacity.ex +++ b/lib/towerops/capacity.ex @@ -47,6 +47,41 @@ defmodule Towerops.Capacity do } 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 """ Returns a capacity summary for all interfaces at a site. """ diff --git a/lib/towerops/coverages.ex b/lib/towerops/coverages.ex index e48fc6c1..46448225 100644 --- a/lib/towerops/coverages.ex +++ b/lib/towerops/coverages.ex @@ -160,6 +160,19 @@ defmodule Towerops.Coverages do |> Repo.one!() 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." @spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil def get_coverage(organization_id, id) do diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 8f6da331..cc56c541 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -34,6 +34,7 @@ defmodule Towerops.Devices do site_id |> DeviceQuery.for_site() |> DeviceQuery.order_by_display() + |> preload([:site, :organization]) |> Repo.all() end @@ -68,7 +69,7 @@ defmodule Towerops.Devices do join: o in assoc(e, :organization), where: e.organization_id in ^organization_ids, order_by: [asc: e.organization_id, asc: e.name], - preload: [site: s, organization: o] + preload: [organization: o, site: {s, :organization}] ) ) end diff --git a/lib/towerops/on_call.ex b/lib/towerops/on_call.ex index 733d4860..121408be 100644 --- a/lib/towerops/on_call.ex +++ b/lib/towerops/on_call.ex @@ -26,6 +26,7 @@ defmodule Towerops.OnCall do |> ScheduleQuery.for_organization() |> ScheduleQuery.order_by_name() |> Repo.all() + |> Repo.preload(overrides: :user, layers: [members: :user]) end def get_schedule!(id) do diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index b6783d11..6d7753d8 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -107,12 +107,9 @@ defmodule Towerops.Organizations do has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id), {:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)), membership_changeset = - Membership.changeset(%Membership{}, %{ - organization_id: organization.id, - user_id: user_id, - role: :owner, - is_default: !has_existing - }), + %Membership{} + |> Membership.create_changeset(organization.id, user_id, :owner) + |> Ecto.Changeset.put_change(:is_default, !has_existing), {:ok, _membership} <- Repo.insert(membership_changeset) do organization else @@ -267,10 +264,28 @@ defmodule Towerops.Organizations do Updates a member's role. Owners cannot have their role changed. """ def update_member_role(organization_id, user_id, new_role) do - case get_membership(organization_id, user_id) do - %Membership{role: :owner} -> {:error, :cannot_change_owner_role} - %Membership{} = membership -> update_membership(membership, %{role: new_role}) - nil -> {:error, :not_found} + role_atom = parse_role(new_role) + + if role_atom == :invalid do + 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 @@ -290,15 +305,35 @@ defmodule Towerops.Organizations do @doc """ 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 + 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.changeset(attrs) + |> Membership.create_changeset(org_id, user_id, role) |> Repo.insert() 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 """ - 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 membership @@ -428,11 +463,12 @@ defmodule Towerops.Organizations do }) membership_changeset = - Membership.changeset(%Membership{}, %{ - organization_id: invitation.organization_id, - user_id: user_id, - role: invitation.role - }) + Membership.create_changeset( + %Membership{}, + invitation.organization_id, + user_id, + invitation.role + ) with {:ok, _} <- Repo.update(invitation_changeset), {:ok, membership} <- Repo.insert(membership_changeset) do diff --git a/lib/towerops/organizations/membership.ex b/lib/towerops/organizations/membership.ex index 54e5566b..ac2f149f 100644 --- a/lib/towerops/organizations/membership.ex +++ b/lib/towerops/organizations/membership.ex @@ -39,10 +39,19 @@ defmodule Towerops.Organizations.Membership do updated_at: DateTime.t() } - @doc false - def changeset(membership, attrs) do + @doc """ + 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 - |> 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]) |> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:user_id) @@ -50,4 +59,28 @@ defmodule Towerops.Organizations.Membership do name: :organization_memberships_organization_id_user_id_index ) 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 diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 0d905db5..53e2c069 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -257,6 +257,14 @@ defmodule Towerops.Snmp do defdelegate set_manual_capacity(interface_id, capacity_bps), 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`). defdelegate list_sensors(device_id), to: Sensors diff --git a/lib/towerops/snmp/interfaces.ex b/lib/towerops/snmp/interfaces.ex index fa208b65..2a282225 100644 --- a/lib/towerops/snmp/interfaces.ex +++ b/lib/towerops/snmp/interfaces.ex @@ -78,4 +78,19 @@ defmodule Towerops.Snmp.Interfaces do |> Repo.update() 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 diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex index 6baca897..f7102a1d 100644 --- a/lib/towerops_web/components/layouts.ex +++ b/lib/towerops_web/components/layouts.ex @@ -121,10 +121,6 @@ defmodule ToweropsWeb.Layouts do def authenticated(assigns) do 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 current_organization = assigns[:current_scope] && assigns.current_scope.organization timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC" diff --git a/lib/towerops_web/controllers/api/v1/invitations_controller.ex b/lib/towerops_web/controllers/api/v1/invitations_controller.ex index 31abc5a4..0724ebb4 100644 --- a/lib/towerops_web/controllers/api/v1/invitations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/invitations_controller.ex @@ -21,25 +21,31 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do organization_id = conn.assigns.current_organization_id user = conn.assigns[:current_user] - attrs = %{ - organization_id: organization_id, - email: email, - role: role, - invited_by_id: user && user.id, - token: Ecto.UUID.generate(), - expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second) - } + case authorize_invite(organization_id, user, role) do + {:ok, _} -> + attrs = %{ + organization_id: organization_id, + email: email, + role: role, + 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 - {:ok, invitation} -> - conn - |> put_status(:created) - |> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))}) + case Organizations.create_invitation(attrs) do + {:ok, invitation} -> + conn + |> put_status(:created) + |> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))}) - {:error, changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + + {:error, reason} -> + conn |> put_status(:forbidden) |> json(%{error: reason}) end end @@ -47,6 +53,23 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"}) 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 organization_id = conn.assigns.current_organization_id diff --git a/lib/towerops_web/controllers/page_html/privacy.html.heex b/lib/towerops_web/controllers/page_html/privacy.html.heex index b4e9a7c6..d920fc55 100644 --- a/lib/towerops_web/controllers/page_html/privacy.html.heex +++ b/lib/towerops_web/controllers/page_html/privacy.html.heex @@ -222,6 +222,7 @@ {content.()} diff --git a/lib/towerops_web/controllers/page_html/terms.html.heex b/lib/towerops_web/controllers/page_html/terms.html.heex index 2b711a1c..9591c9fb 100644 --- a/lib/towerops_web/controllers/page_html/terms.html.heex +++ b/lib/towerops_web/controllers/page_html/terms.html.heex @@ -396,6 +396,7 @@ {content.()} diff --git a/lib/towerops_web/controllers/user_settings_html/edit.html.heex b/lib/towerops_web/controllers/user_settings_html/edit.html.heex index bc373823..621160e3 100644 --- a/lib/towerops_web/controllers/user_settings_html/edit.html.heex +++ b/lib/towerops_web/controllers/user_settings_html/edit.html.heex @@ -1,4 +1,8 @@ - +
<.header> {t_auth("Account Settings")} diff --git a/lib/towerops_web/graphql/resolvers/member.ex b/lib/towerops_web/graphql/resolvers/member.ex index e7f0c07a..9f5ab8eb 100644 --- a/lib/towerops_web/graphql/resolvers/member.ex +++ b/lib/towerops_web/graphql/resolvers/member.ex @@ -7,6 +7,11 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do alias ToweropsWeb.GraphQL.Resolvers.Helpers 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 members = Organizations.list_organization_members(org_id) {: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 role = Map.get(args, :role, "technician") - case Organizations.create_invitation(%{ - email: email, - role: role, - organization_id: org_id, - invited_by_id: user.id - }) do - {:ok, invitation} -> {:ok, invitation} - {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do + case Organizations.create_invitation(%{ + email: email, + role: role, + organization_id: org_id, + invited_by_id: user.id + }) do + {:ok, invitation} -> {:ok, invitation} + {:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)} + end end end 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 with {:ok, invitation} <- fetch_org_invitation(id, org_id) do case Repo.delete(invitation) do diff --git a/lib/towerops_web/live/account_live/activity.ex b/lib/towerops_web/live/account_live/activity.ex index b32c4c31..cc042cb5 100644 --- a/lib/towerops_web/live/account_live/activity.ex +++ b/lib/towerops_web/live/account_live/activity.ex @@ -23,7 +23,12 @@ defmodule ToweropsWeb.AccountLive.Activity do @impl true def render(assigns) do ~H""" - +

Activity Log diff --git a/lib/towerops_web/live/account_live/my_data.ex b/lib/towerops_web/live/account_live/my_data.ex index 584310cf..95a31cea 100644 --- a/lib/towerops_web/live/account_live/my_data.ex +++ b/lib/towerops_web/live/account_live/my_data.ex @@ -66,6 +66,7 @@ defmodule ToweropsWeb.AccountLive.MyData do flash={@flash} current_scope={@current_scope} active_page="settings" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >

@@ -628,9 +629,7 @@ defmodule ToweropsWeb.AccountLive.MyData do if Enum.empty?(org_ids) do [] else - org_ids - |> Devices.list_devices_for_organizations() - |> Towerops.Repo.preload(site: :organization) + Devices.list_devices_for_organizations(org_ids) end end @@ -646,9 +645,7 @@ defmodule ToweropsWeb.AccountLive.MyData do else ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day) - org_ids - |> Alerts.list_alerts_for_organizations(since: ninety_days_ago) - |> Towerops.Repo.preload(:device) + Alerts.list_alerts_for_organizations(org_ids, since: ninety_days_ago) end end diff --git a/lib/towerops_web/live/account_live/totp_enrollment.ex b/lib/towerops_web/live/account_live/totp_enrollment.ex index e058600b..61130f04 100644 --- a/lib/towerops_web/live/account_live/totp_enrollment.ex +++ b/lib/towerops_web/live/account_live/totp_enrollment.ex @@ -135,7 +135,11 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do @impl true def render(assigns) do ~H""" - +
<%= if @show_recovery_codes do %> diff --git a/lib/towerops_web/live/activity_feed_live.html.heex b/lib/towerops_web/live/activity_feed_live.html.heex index f1131eea..156f0728 100644 --- a/lib/towerops_web/live/activity_feed_live.html.heex +++ b/lib/towerops_web/live/activity_feed_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="activity" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header>
diff --git a/lib/towerops_web/live/admin/org_live/index.ex b/lib/towerops_web/live/admin/org_live/index.ex index dc4ac52f..0e40927c 100644 --- a/lib/towerops_web/live/admin/org_live/index.ex +++ b/lib/towerops_web/live/admin/org_live/index.ex @@ -4,12 +4,9 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do """ use ToweropsWeb, :live_view - import Ecto.Query - alias Towerops.Admin alias Towerops.Billing alias Towerops.Organizations.Organization - alias Towerops.Repo on_mount {ToweropsWeb.UserAuth, :require_superuser} @@ -18,7 +15,7 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do orgs = Admin.list_all_organizations() default_free_devices = Billing.default_free_devices() default_price = Billing.default_price_per_device() - active_sub_count = count_active_subscriptions() + active_sub_count = Admin.count_active_subscriptions() ip = 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] 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 diff --git a/lib/towerops_web/live/agent_live/index.html.heex b/lib/towerops_web/live/agent_live/index.html.heex index b96524ee..b74c69cf 100644 --- a/lib/towerops_web/live/agent_live/index.html.heex +++ b/lib/towerops_web/live/agent_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="agents" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> {@page_title} diff --git a/lib/towerops_web/live/agent_live/show.html.heex b/lib/towerops_web/live/agent_live/show.html.heex index e70743d3..848d00c3 100644 --- a/lib/towerops_web/live/agent_live/show.html.heex +++ b/lib/towerops_web/live/agent_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="agents" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> {@page_title} diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index b54f61f0..c041fe6b 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="alerts" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <%!-- Compact header --%>
diff --git a/lib/towerops_web/live/changelog_live.ex b/lib/towerops_web/live/changelog_live.ex index ae65db90..e15335c2 100644 --- a/lib/towerops_web/live/changelog_live.ex +++ b/lib/towerops_web/live/changelog_live.ex @@ -11,7 +11,12 @@ defmodule ToweropsWeb.ChangelogLive do @impl true def render(assigns) do ~H""" - +

What's New

Latest updates and improvements to TowerOps.

diff --git a/lib/towerops_web/live/coverage_live/form.html.heex b/lib/towerops_web/live/coverage_live/form.html.heex index 0bdc4fb7..f626e6be 100644 --- a/lib/towerops_web/live/coverage_live/form.html.heex +++ b/lib/towerops_web/live/coverage_live/form.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="coverage" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={[ %{label: t("Dashboard"), navigate: ~p"/dashboard"}, diff --git a/lib/towerops_web/live/coverage_live/index.html.heex b/lib/towerops_web/live/coverage_live/index.html.heex index be4c88ad..c4894254 100644 --- a/lib/towerops_web/live/coverage_live/index.html.heex +++ b/lib/towerops_web/live/coverage_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="coverage" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> {@page_title} diff --git a/lib/towerops_web/live/coverage_live/map.html.heex b/lib/towerops_web/live/coverage_live/map.html.heex index fa79a088..b13a55cf 100644 --- a/lib/towerops_web/live/coverage_live/map.html.heex +++ b/lib/towerops_web/live/coverage_live/map.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="coverage" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> {@page_title} diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex index beeaf02d..85cfd273 100644 --- a/lib/towerops_web/live/coverage_live/show.ex +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -10,7 +10,7 @@ defmodule ToweropsWeb.CoverageLive.Show do @impl true def mount(%{"id" => id}, _session, socket) do 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) eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0) @@ -95,9 +95,7 @@ defmodule ToweropsWeb.CoverageLive.Show do @impl true def handle_info({:coverage_status, _status, _extra}, socket) do coverage = - socket.assigns.organization.id - |> Coverages.get_coverage!(socket.assigns.coverage.id) - |> Towerops.Repo.preload([:device, :site]) + Coverages.get_coverage_with_device!(socket.assigns.organization.id, socket.assigns.coverage.id) eirp = Coverage.eirp_dbm( diff --git a/lib/towerops_web/live/coverage_live/show.html.heex b/lib/towerops_web/live/coverage_live/show.html.heex index 8ac1fd72..97b42836 100644 --- a/lib/towerops_web/live/coverage_live/show.html.heex +++ b/lib/towerops_web/live/coverage_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="coverage" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index 3f4c62cf..c70511d2 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="dashboard" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index bd7f78cd..1e630e4f 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="devices" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <%!-- Header: title + actions, then tabs below on mobile --%>
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 7bfcaee3..e22fe25b 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -511,13 +511,12 @@ defmodule ToweropsWeb.DeviceLive.Show do defp assign_ports_data(socket) do 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 = Enum.map(interfaces, fn interface -> - utilization = - if interface.configured_capacity_bps do - Towerops.Capacity.get_utilization(interface) - end - + utilization = Map.get(utilization_by_id, interface.id) Map.put(interface, :utilization, utilization) end) @@ -905,22 +904,7 @@ defmodule ToweropsWeb.DeviceLive.Show do end defp interface_belongs_to_org?(interface_id, org_id) do - {:ok, iid} = Ecto.UUID.dump(interface_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 + Snmp.interface_belongs_to_organization?(interface_id, org_id) end defp reload_snmp_and_ports(socket) do diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index eac1b9ae..5af170ba 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="devices" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
<.breadcrumb items={ diff --git a/lib/towerops_web/live/escalation_policy_live/form.html.heex b/lib/towerops_web/live/escalation_policy_live/form.html.heex index 8bb938db..460489b9 100644 --- a/lib/towerops_web/live/escalation_policy_live/form.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/form.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
<.link diff --git a/lib/towerops_web/live/escalation_policy_live/index.html.heex b/lib/towerops_web/live/escalation_policy_live/index.html.heex index 42c6d896..2bc689d7 100644 --- a/lib/towerops_web/live/escalation_policy_live/index.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/escalation_policy_live/show.html.heex b/lib/towerops_web/live/escalation_policy_live/show.html.heex index 98010070..2cabd9a8 100644 --- a/lib/towerops_web/live/escalation_policy_live/show.html.heex +++ b/lib/towerops_web/live/escalation_policy_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/graph_live/show.html.heex b/lib/towerops_web/live/graph_live/show.html.heex index ecd1e630..692eda60 100644 --- a/lib/towerops_web/live/graph_live/show.html.heex +++ b/lib/towerops_web/live/graph_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="devices" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/help_live/index.html.heex b/lib/towerops_web/live/help_live/index.html.heex index c38b9a07..9e6af858 100644 --- a/lib/towerops_web/live/help_live/index.html.heex +++ b/lib/towerops_web/live/help_live/index.html.heex @@ -3,6 +3,7 @@ flash={@flash} current_scope={@current_scope} active_page="help" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.help_content active_section={@active_section} diff --git a/lib/towerops_web/live/helpers/access_control.ex b/lib/towerops_web/live/helpers/access_control.ex index f1f4f124..c6f5a594 100644 --- a/lib/towerops_web/live/helpers/access_control.ex +++ b/lib/towerops_web/live/helpers/access_control.ex @@ -6,10 +6,9 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do (devices, sites, alerts) within their organization scope. """ - alias Towerops.Alerts.Alert + alias Towerops.Alerts alias Towerops.Devices alias Towerops.Devices.Device - alias Towerops.Repo alias Towerops.Sites alias Towerops.Sites.Site @@ -52,13 +51,6 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do @spec verify_alert_access(binary(), binary()) :: {:ok, Alert.t()} | {:error, :not_found | :unauthorized} def verify_alert_access(alert_id, organization_id) do - with %Alert{} = alert <- Repo.get(Alert, alert_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 + Alerts.get_verified_alert(alert_id, organization_id) end end diff --git a/lib/towerops_web/live/insights_live/index.ex b/lib/towerops_web/live/insights_live/index.ex index 7509c36b..dae70ebd 100644 --- a/lib/towerops_web/live/insights_live/index.ex +++ b/lib/towerops_web/live/insights_live/index.ex @@ -6,7 +6,6 @@ defmodule ToweropsWeb.InsightsLive.Index do alias Towerops.Accounts.Scope alias Towerops.Preseem - alias Towerops.Repo require Logger @@ -177,10 +176,7 @@ defmodule ToweropsWeb.InsightsLive.Index do do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency), else: opts - insights = - org_id - |> Preseem.list_insights(opts) - |> Repo.preload([:preseem_access_point, :device]) + insights = Preseem.list_insights(org_id, opts) assign(socket, :insights, insights) end diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 6518b29d..aafd3131 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="insights" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/maintenance_live/form.html.heex b/lib/towerops_web/live/maintenance_live/form.html.heex index 5a41b5eb..296afed9 100644 --- a/lib/towerops_web/live/maintenance_live/form.html.heex +++ b/lib/towerops_web/live/maintenance_live/form.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="maintenance" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
<.link diff --git a/lib/towerops_web/live/maintenance_live/index.html.heex b/lib/towerops_web/live/maintenance_live/index.html.heex index c8cf7786..def8a287 100644 --- a/lib/towerops_web/live/maintenance_live/index.html.heex +++ b/lib/towerops_web/live/maintenance_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="maintenance" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >

{t("Maintenance Windows")}

diff --git a/lib/towerops_web/live/maintenance_live/show.html.heex b/lib/towerops_web/live/maintenance_live/show.html.heex index 59da30fb..94601102 100644 --- a/lib/towerops_web/live/maintenance_live/show.html.heex +++ b/lib/towerops_web/live/maintenance_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="maintenance" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
<.link diff --git a/lib/towerops_web/live/map_live/index.html.heex b/lib/towerops_web/live/map_live/index.html.heex index a6f65156..13c4bacd 100644 --- a/lib/towerops_web/live/map_live/index.html.heex +++ b/lib/towerops_web/live/map_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="sites-map" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> diff --git a/lib/towerops_web/live/mikrotik_backup_live/compare.ex b/lib/towerops_web/live/mikrotik_backup_live/compare.ex index 8bf92f7e..c75d0805 100644 --- a/lib/towerops_web/live/mikrotik_backup_live/compare.ex +++ b/lib/towerops_web/live/mikrotik_backup_live/compare.ex @@ -5,7 +5,6 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do alias Towerops.Devices alias Towerops.Devices.MikrotikBackups alias Towerops.Devices.MikrotikBackups.Differ - alias Towerops.Repo @impl true def mount(_params, _session, socket) do @@ -25,9 +24,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do |> push_navigate(to: ~p"/devices")} device -> - device_with_site = Repo.preload(device, site: :organization) - - if device_with_site.site.organization_id == organization.id do + if device.site.organization_id == organization.id do load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id) else {:noreply, diff --git a/lib/towerops_web/live/mobile_qr_live.ex b/lib/towerops_web/live/mobile_qr_live.ex index fa7d2974..68a4801f 100644 --- a/lib/towerops_web/live/mobile_qr_live.ex +++ b/lib/towerops_web/live/mobile_qr_live.ex @@ -118,7 +118,11 @@ defmodule ToweropsWeb.MobileQRLive do @impl true def render(assigns) do ~H""" - +
<.header> Link Mobile App diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex index 56f9119e..f4e7943b 100644 --- a/lib/towerops_web/live/network_map_live.html.heex +++ b/lib/towerops_web/live/network_map_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="network-map" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> diff --git a/lib/towerops_web/live/onboarding_live.html.heex b/lib/towerops_web/live/onboarding_live.html.heex index bbe3d48b..9eb2e9d8 100644 --- a/lib/towerops_web/live/onboarding_live.html.heex +++ b/lib/towerops_web/live/onboarding_live.html.heex @@ -1,6 +1,7 @@
<%!-- Step indicator --%> diff --git a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex index baf73d6f..3b5af70c 100644 --- a/lib/towerops_web/live/org/gaiia_mapping_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_mapping_live.html.heex @@ -1,6 +1,7 @@
<.breadcrumb items={[ diff --git a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex index 6b067b77..bf55fcd2 100644 --- a/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex +++ b/lib/towerops_web/live/org/gaiia_reconciliation_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="settings" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={[ %{label: "Dashboard", navigate: ~p"/dashboard"}, diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index ef228661..270bbb33 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -1,6 +1,7 @@
diff --git a/lib/towerops_web/live/org/preseem_devices_live.html.heex b/lib/towerops_web/live/org/preseem_devices_live.html.heex index 95ee933c..63f0c6eb 100644 --- a/lib/towerops_web/live/org/preseem_devices_live.html.heex +++ b/lib/towerops_web/live/org/preseem_devices_live.html.heex @@ -1,6 +1,7 @@
<.breadcrumb items={[ diff --git a/lib/towerops_web/live/org/preseem_insights_live.html.heex b/lib/towerops_web/live/org/preseem_insights_live.html.heex index 818a95d3..00186adf 100644 --- a/lib/towerops_web/live/org/preseem_insights_live.html.heex +++ b/lib/towerops_web/live/org/preseem_insights_live.html.heex @@ -1,6 +1,7 @@
<.breadcrumb items={[ diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index fa094fc9..9f62325e 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -1,6 +1,7 @@
<.breadcrumb items={[ diff --git a/lib/towerops_web/live/org_live/index.html.heex b/lib/towerops_web/live/org_live/index.html.heex index 95dc764f..6bcd6c18 100644 --- a/lib/towerops_web/live/org_live/index.html.heex +++ b/lib/towerops_web/live/org_live/index.html.heex @@ -1,6 +1,7 @@ <.header> {@page_title} diff --git a/lib/towerops_web/live/org_live/new.html.heex b/lib/towerops_web/live/org_live/new.html.heex index f04fbe58..afd2afc1 100644 --- a/lib/towerops_web/live/org_live/new.html.heex +++ b/lib/towerops_web/live/org_live/new.html.heex @@ -1,6 +1,7 @@ <.header> {@page_title} diff --git a/lib/towerops_web/live/reports_live.html.heex b/lib/towerops_web/live/reports_live.html.heex index 4030e695..264d13b6 100644 --- a/lib/towerops_web/live/reports_live.html.heex +++ b/lib/towerops_web/live/reports_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="reports" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={[ %{label: "Dashboard", navigate: ~p"/dashboard"}, diff --git a/lib/towerops_web/live/rf_link_health_live.html.heex b/lib/towerops_web/live/rf_link_health_live.html.heex index cec61cda..166f1c8b 100644 --- a/lib/towerops_web/live/rf_link_health_live.html.heex +++ b/lib/towerops_web/live/rf_link_health_live.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="rf-links" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={[ %{label: "Dashboard", navigate: ~p"/dashboard"}, diff --git a/lib/towerops_web/live/schedule_live/form.html.heex b/lib/towerops_web/live/schedule_live/form.html.heex index 8f7bee84..7a60f1c4 100644 --- a/lib/towerops_web/live/schedule_live/form.html.heex +++ b/lib/towerops_web/live/schedule_live/form.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
<.link diff --git a/lib/towerops_web/live/schedule_live/index.ex b/lib/towerops_web/live/schedule_live/index.ex index 921687ee..c16b341f 100644 --- a/lib/towerops_web/live/schedule_live/index.ex +++ b/lib/towerops_web/live/schedule_live/index.ex @@ -136,10 +136,7 @@ defmodule ToweropsWeb.ScheduleLive.Index do {: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") - preloaded_schedules = - Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user]) - - Enum.map(preloaded_schedules, fn preloaded -> + Enum.map(schedules, fn preloaded -> schedule = preloaded on_call = Resolver.resolve(preloaded, DateTime.utc_now()) diff --git a/lib/towerops_web/live/schedule_live/index.html.heex b/lib/towerops_web/live/schedule_live/index.html.heex index 604ab06f..41aec2ae 100644 --- a/lib/towerops_web/live/schedule_live/index.html.heex +++ b/lib/towerops_web/live/schedule_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >

{t("On-Call & Schedules")}

diff --git a/lib/towerops_web/live/schedule_live/show.html.heex b/lib/towerops_web/live/schedule_live/show.html.heex index acb4d3d3..9b6550dd 100644 --- a/lib/towerops_web/live/schedule_live/show.html.heex +++ b/lib/towerops_web/live/schedule_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="schedules" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} >
diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex index e388f31e..45c2e654 100644 --- a/lib/towerops_web/live/site_live/form.html.heex +++ b/lib/towerops_web/live/site_live/form.html.heex @@ -1,6 +1,7 @@
<.link diff --git a/lib/towerops_web/live/site_live/index.html.heex b/lib/towerops_web/live/site_live/index.html.heex index f3fe3434..862107f1 100644 --- a/lib/towerops_web/live/site_live/index.html.heex +++ b/lib/towerops_web/live/site_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="sites" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> {@page_title} diff --git a/lib/towerops_web/live/site_live/show.html.heex b/lib/towerops_web/live/site_live/show.html.heex index ba0cf474..e2698dac 100644 --- a/lib/towerops_web/live/site_live/show.html.heex +++ b/lib/towerops_web/live/site_live/show.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="sites" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={[ %{label: "Dashboard", navigate: ~p"/dashboard"}, diff --git a/lib/towerops_web/live/status_page_live.ex b/lib/towerops_web/live/status_page_live.ex index 9b9fe742..988fed7c 100644 --- a/lib/towerops_web/live/status_page_live.ex +++ b/lib/towerops_web/live/status_page_live.ex @@ -42,6 +42,48 @@ defmodule ToweropsWeb.StatusPageLive do 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 def handle_info({:status_updated, _}, socket) do config = socket.assigns.config @@ -104,4 +146,10 @@ defmodule ToweropsWeb.StatusPageLive do def incident_status_label("monitoring"), do: "Monitoring" def incident_status_label("resolved"), do: "Resolved" 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 diff --git a/lib/towerops_web/live/trace_live/index.html.heex b/lib/towerops_web/live/trace_live/index.html.heex index e862b4f6..a4c34cf6 100644 --- a/lib/towerops_web/live/trace_live/index.html.heex +++ b/lib/towerops_web/live/trace_live/index.html.heex @@ -2,6 +2,7 @@ flash={@flash} current_scope={@current_scope} active_page="trace" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.breadcrumb items={ [%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++ diff --git a/lib/towerops_web/live/user_registration_live.ex b/lib/towerops_web/live/user_registration_live.ex index c184ffb2..de4e6949 100644 --- a/lib/towerops_web/live/user_registration_live.ex +++ b/lib/towerops_web/live/user_registration_live.ex @@ -10,6 +10,7 @@ defmodule ToweropsWeb.UserRegistrationLive do alias Towerops.Organizations alias Towerops.Workers.WelcomeEmailWorker + @impl true def mount(params, session, socket) do # Check if registering via invitation invitation_token = params["invitation_token"] || params["token"] @@ -32,6 +33,18 @@ defmodule ToweropsWeb.UserRegistrationLive do |> assign(:terms_of_service_consent, false)} 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) when is_binary(password) and byte_size(password) > 0 do case HIBP.check_password(password) do @@ -151,6 +164,7 @@ defmodule ToweropsWeb.UserRegistrationLive do end end + @impl true def render(assigns) do ~H""" diff --git a/lib/towerops_web/live/user_reset_password_live.ex b/lib/towerops_web/live/user_reset_password_live.ex index 78e53e97..bbee5a3d 100644 --- a/lib/towerops_web/live/user_reset_password_live.ex +++ b/lib/towerops_web/live/user_reset_password_live.ex @@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do alias Towerops.Accounts alias Towerops.Accounts.HIBP + @impl true def mount(%{"token" => token}, _session, socket) do case Accounts.get_user_by_reset_password_token(token) do nil -> @@ -27,6 +28,37 @@ defmodule ToweropsWeb.UserResetPasswordLive do 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) when is_binary(password) and byte_size(password) > 0 do case HIBP.check_password(password) do @@ -64,6 +96,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do end end + @impl true def render(assigns) do ~H""" diff --git a/lib/towerops_web/live/user_settings_live.html.heex b/lib/towerops_web/live/user_settings_live.html.heex index 6add0eae..be8a5654 100644 --- a/lib/towerops_web/live/user_settings_live.html.heex +++ b/lib/towerops_web/live/user_settings_live.html.heex @@ -1,6 +1,7 @@

diff --git a/lib/towerops_web/live/weathermap_live.html.heex b/lib/towerops_web/live/weathermap_live.html.heex index 79b350b7..49c46bd3 100644 --- a/lib/towerops_web/live/weathermap_live.html.heex +++ b/lib/towerops_web/live/weathermap_live.html.heex @@ -7,6 +7,7 @@ flash={@flash} current_scope={@current_scope} active_page="weathermap" + unresolved_alert_count={assigns[:unresolved_alert_count] || 0} > <.header> diff --git a/lib/towerops_web/user_auth.ex b/lib/towerops_web/user_auth.ex index eef575c1..1d5b505e 100644 --- a/lib/towerops_web/user_auth.ex +++ b/lib/towerops_web/user_auth.ex @@ -607,10 +607,9 @@ defmodule ToweropsWeb.UserAuth do alert_count = if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0 - Process.put(:unresolved_alert_count, alert_count) - socket = socket + |> Phoenix.Component.assign(:unresolved_alert_count, alert_count) |> maybe_subscribe_to_status_updates(organization) |> attach_status_title_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 {:alert_changed, org_id}, socket when org_id == 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 -> {:cont, socket} diff --git a/test/snmpkit/snmp_lib/error_handler_test.exs b/test/snmpkit/snmp_lib/error_handler_test.exs index 78b86d10..0272a03f 100644 --- a/test/snmpkit/snmp_lib/error_handler_test.exs +++ b/test/snmpkit/snmp_lib/error_handler_test.exs @@ -216,8 +216,9 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do # Circuit should be open assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun) - # Wait for recovery timeout - Process.sleep(110) + # Wait for recovery timeout using a message-based timer + Process.send_after(self(), :recovery_elapsed, 110) + assert_receive :recovery_elapsed, 1000 # Should allow limited calls in half-open state success_fun = fn -> {:ok, :recovered} end diff --git a/test/towerops/api_tokens/api_token_test.exs b/test/towerops/api_tokens/api_token_test.exs index b209e626..180c3680 100644 --- a/test/towerops/api_tokens/api_token_test.exs +++ b/test/towerops/api_tokens/api_token_test.exs @@ -6,7 +6,7 @@ defmodule Towerops.ApiTokens.ApiTokenTest do alias Towerops.ApiTokens.ApiToken - describe "changeset/2" do + describe "create_changeset/4 and update_changeset/2" do setup do user = user_fixture() organization = organization_fixture(user.id) @@ -18,136 +18,124 @@ defmodule Towerops.ApiTokens.ApiTokenTest do organization: organization } do attrs = %{ - organization_id: organization.id, - user_id: user.id, name: "Test Token", token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) assert changeset.valid? end - test "valid changeset without user_id", %{organization: organization} do + test "valid changeset without user_id", %{organization: organization, user: _user} do attrs = %{ - organization_id: organization.id, name: "Test Token", token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, nil, attrs) assert changeset.valid? 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) attrs = %{ - organization_id: organization.id, name: "Test Token", token_hash: "abc123", expires_at: expires_at } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) assert changeset.valid? 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() attrs = %{ - organization_id: organization.id, name: "Test Token", token_hash: "abc123", last_used_at: last_used_at } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) assert changeset.valid? end test "invalid changeset without organization_id", %{user: user} do attrs = %{ - user_id: user.id, name: "Test Token", token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, nil, user.id, attrs) refute changeset.valid? assert "can't be blank" in errors_on(changeset).organization_id end - test "invalid changeset without name", %{organization: organization} do + test "invalid changeset without name", %{organization: organization, user: user} do attrs = %{ - organization_id: organization.id, token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) refute changeset.valid? assert "can't be blank" in errors_on(changeset).name end - test "invalid changeset without token_hash", %{organization: organization} do + test "invalid changeset without token_hash", %{organization: organization, user: user} do attrs = %{ - organization_id: organization.id, name: "Test Token" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) refute changeset.valid? assert "can't be blank" in errors_on(changeset).token_hash end - test "invalid changeset with blank name", %{organization: organization} do + test "invalid changeset with blank name", %{organization: organization, user: user} do attrs = %{ - organization_id: organization.id, name: "", token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) refute changeset.valid? assert "can't be blank" in errors_on(changeset).name end - test "invalid changeset with name too short", %{organization: organization} do + test "invalid changeset with name too short", %{organization: organization, user: user} do attrs = %{ - organization_id: organization.id, name: "", token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) refute changeset.valid? 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) attrs = %{ - organization_id: organization.id, name: long_name, token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) refute changeset.valid? assert "should be at most 255 character(s)" in errors_on(changeset).name 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) attrs = %{ - organization_id: organization.id, name: max_name, token_hash: "abc123" } - changeset = ApiToken.changeset(%ApiToken{}, attrs) + changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs) assert changeset.valid? end end diff --git a/test/towerops/capacity_test.exs b/test/towerops/capacity_test.exs index a3542068..1f5df925 100644 --- a/test/towerops/capacity_test.exs +++ b/test/towerops/capacity_test.exs @@ -264,6 +264,90 @@ defmodule Towerops.CapacityTest do 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 test "aggregates capacity across interfaces at a site", %{ site: site, diff --git a/test/towerops/devices_test.exs b/test/towerops/devices_test.exs index a3c5cd0b..678c1f48 100644 --- a/test/towerops/devices_test.exs +++ b/test/towerops/devices_test.exs @@ -41,11 +41,18 @@ defmodule Towerops.EquipmentTest do } @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} = 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 test "list_organization_devices/1 returns all devices for an organization", %{ diff --git a/test/towerops/organizations_test.exs b/test/towerops/organizations_test.exs index d1a18e29..0be2cfd6 100644 --- a/test/towerops/organizations_test.exs +++ b/test/towerops/organizations_test.exs @@ -191,10 +191,21 @@ defmodule Towerops.OrganizationsTest do assert "can't be blank" in errors_on(changeset).organization_id end - test "update_membership/2 changes user role", %{user: user, organization: organization} do - membership = Organizations.get_membership!(organization.id, user.id) + test "update_member_role/3 changes user role", %{organization: organization} do + 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 end @@ -407,8 +418,15 @@ defmodule Towerops.OrganizationsTest do # Owner can delete organization assert Organizations.can?(membership, :delete, :organization) - # Change to member role - {:ok, member_membership} = Organizations.update_membership(membership, %{role: :member}) + # Create a separate member-level membership + 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 refute Organizations.can?(member_membership, :delete, :organization) diff --git a/test/towerops/redis_health_check_test.exs b/test/towerops/redis_health_check_test.exs index fe664a61..8d0b0fe2 100644 --- a/test/towerops/redis_health_check_test.exs +++ b/test/towerops/redis_health_check_test.exs @@ -135,7 +135,11 @@ defmodule Towerops.RedisHealthCheckTest do # Schedule recovery after a short delay — the first attempt will fail, # then we restore the healthy state before the second attempt. Task.async(fn -> - Process.sleep(5) + receive do + after + 5 -> :ok + end + Fake.set_ping_response(Towerops.Redix, :ok) end) diff --git a/test/towerops/snmp/deferred_discovery_test.exs b/test/towerops/snmp/deferred_discovery_test.exs index 87aedbea..c3c537f5 100644 --- a/test/towerops/snmp/deferred_discovery_test.exs +++ b/test/towerops/snmp/deferred_discovery_test.exs @@ -27,8 +27,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do DeferredDiscovery.fast_check( client_opts, fn -> - Process.sleep(30) - {:ok, "too slow"} + receive do + :never -> {:ok, "too slow"} + end end, timeout: 25 ) @@ -131,8 +132,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do DeferredDiscovery.deferred_check( client_opts, fn -> - Process.sleep(30) - {:ok, "too slow"} + receive do + :never -> {:ok, "too slow"} + end end, timeout: 25, name: "neighbors" @@ -177,8 +179,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do {:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]}, {:slow_check, fn -> - Process.sleep(30) - {:ok, "slow"} + receive do + :never -> {:ok, "slow"} + end end, [timeout: 25, default: []]} ] @@ -229,8 +232,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do test "returns false when check times out" do expect(SnmpMock, :get, fn _target, _oid, _opts -> - Process.sleep(30) - {:ok, [1, 3, 6, 1, 4, 1, 9]} + receive do + :never -> {:ok, [1, 3, 6, 1, 4, 1, 9]} + end end) 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 expect(SnmpMock, :get, fn _target, _oid, _opts -> - # Sleep just past the threshold to be classified as slow - Process.sleep(35) - {:ok, "Slow Device"} + # Delay just past the threshold to be classified as slow + receive do + after + 35 -> {:ok, "Slow Device"} + end end) client_opts = [ip: "192.168.1.1", community: "public", version: "2c"] diff --git a/test/towerops/snmp/poller_test.exs b/test/towerops/snmp/poller_test.exs index 55f32c18..1b1b9084 100644 --- a/test/towerops/snmp/poller_test.exs +++ b/test/towerops/snmp/poller_test.exs @@ -88,8 +88,10 @@ defmodule Towerops.Snmp.PollerTest do # Simulate a delayed response expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ -> - Process.sleep(1) - {:ok, {:timeticks, 999}} + receive do + after + 1 -> {:ok, {:timeticks, 999}} + end end) assert {:ok, response_time} = Poller.check_device(client_opts) diff --git a/test/towerops_web/live/mobile_qr_live_test.exs b/test/towerops_web/live/mobile_qr_live_test.exs index e583b3b0..1029877d 100644 --- a/test/towerops_web/live/mobile_qr_live_test.exs +++ b/test/towerops_web/live/mobile_qr_live_test.exs @@ -71,7 +71,6 @@ defmodule ToweropsWeb.MobileQRLiveTest do assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token) send(view.pid, :check_completion) - Process.sleep(50) html = render(view) # The template shows the QR page until @completed switches