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:
parent
89ed385865
commit
3a408a8dc1
86 changed files with 671 additions and 197 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
{content.()}
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -396,6 +396,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
{content.()}
|
||||
</Layouts.authenticated>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<.header>
|
||||
{t_auth("Account Settings")}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ defmodule ToweropsWeb.AccountLive.Activity do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~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">
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
Activity Log
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<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">
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -135,7 +135,11 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~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="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
|
||||
<%= if @show_recovery_codes do %>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="activity"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="agents"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="agents"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="alerts"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<%!-- Compact header --%>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ defmodule ToweropsWeb.ChangelogLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~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">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="coverage"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="coverage"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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. --%>
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="dashboard"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div
|
||||
id="dynamic-favicon"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
|
||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||
|
|
|
|||
|
|
@ -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 --%>
|
||||
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="devices"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="mb-6 -mt-2">
|
||||
<.breadcrumb items={
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
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 gap-3">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
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 gap-3">
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="devices"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="mb-6">
|
||||
<!-- Header with back button -->
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="mb-6">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="maintenance"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="mb-6">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="sites-map"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,11 @@ defmodule ToweropsWeb.MobileQRLive do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~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">
|
||||
<.header>
|
||||
Link Mobile App
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="network-map"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="max-w-2xl mx-auto py-8">
|
||||
<%!-- Step indicator --%>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
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">
|
||||
<.breadcrumb items={[
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
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="mb-4">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
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">
|
||||
<.breadcrumb items={[
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
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">
|
||||
<.breadcrumb items={[
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="pb-5">
|
||||
<.breadcrumb items={[
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="schedules"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
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 items-center gap-3 min-w-0">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="sites"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
{@page_title}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}] ++
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash}>
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
<Layouts.app flash={@flash}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<div class="pb-5">
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="weathermap"
|
||||
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", %{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue