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)
|
Repo.aggregate(Organization, :count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the count of organizations with active subscriptions.
|
||||||
|
"""
|
||||||
|
def count_active_subscriptions do
|
||||||
|
Repo.one(
|
||||||
|
from o in Organization,
|
||||||
|
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
|
||||||
|
select: count(o.id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes an organization and creates an audit log entry.
|
Deletes an organization and creates an audit log entry.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -293,16 +293,31 @@ defmodule Towerops.Alerts do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single alert.
|
Gets a single alert and verifies it belongs to the given organization.
|
||||||
where: s.organization_id == ^organization_id,
|
Preloads device, site, organization, and acknowledged_by for access checking.
|
||||||
order_by: [desc: a.triggered_at],
|
Returns `{:ok, alert}` if found and belongs to the organization,
|
||||||
limit: ^limit,
|
`{:error, :not_found}` if not found, or `{:error, :unauthorized}` if the
|
||||||
preload: [device: {e, site: s}, acknowledged_by: []]
|
alert's device does not belong to the organization.
|
||||||
)
|
"""
|
||||||
)
|
@spec get_verified_alert(binary(), binary()) ::
|
||||||
|
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
|
||||||
|
def get_verified_alert(alert_id, organization_id) do
|
||||||
|
alert =
|
||||||
|
Alert
|
||||||
|
|> Repo.get(alert_id)
|
||||||
|
|> case do
|
||||||
|
nil -> nil
|
||||||
|
alert -> Repo.preload(alert, [:acknowledged_by, device: [site: :organization]])
|
||||||
|
end
|
||||||
|
|
||||||
|
case alert do
|
||||||
|
%Alert{device: %{site: %{organization_id: ^organization_id}}} -> {:ok, alert}
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
%Alert{} -> {:error, :unauthorized}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc \"""
|
@doc """
|
||||||
Gets a single alert.
|
Gets a single alert.
|
||||||
"""
|
"""
|
||||||
def get_alert!(id) do
|
def get_alert!(id) do
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,20 @@ defmodule Towerops.ApiTokens do
|
||||||
"""
|
"""
|
||||||
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
|
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
|
||||||
def create_api_token(attrs \\ %{}) do
|
def create_api_token(attrs \\ %{}) do
|
||||||
|
# Extract required fields from attrs map
|
||||||
|
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
|
||||||
|
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
|
||||||
|
|
||||||
# Generate a random token
|
# Generate a random token
|
||||||
raw_token = generate_token()
|
raw_token = generate_token()
|
||||||
token_hash = hash_token(raw_token)
|
token_hash = hash_token(raw_token)
|
||||||
|
|
||||||
attrs = Map.put(attrs, :token_hash, token_hash)
|
token_attrs = Map.take(attrs, [:name, :expires_at])
|
||||||
|
token_attrs = Map.put(token_attrs, :token_hash, token_hash)
|
||||||
|
|
||||||
with :ok <- validate_membership(attrs[:organization_id], attrs[:user_id]) do
|
with :ok <- validate_membership(org_id, user_id) do
|
||||||
case %ApiToken{}
|
case %ApiToken{}
|
||||||
|> ApiToken.changeset(attrs)
|
|> ApiToken.create_changeset(org_id, user_id, token_attrs)
|
||||||
|> Repo.insert() do
|
|> Repo.insert() do
|
||||||
{:ok, api_token} ->
|
{:ok, api_token} ->
|
||||||
{:ok, {api_token, raw_token}}
|
{:ok, {api_token, raw_token}}
|
||||||
|
|
@ -131,7 +136,7 @@ defmodule Towerops.ApiTokens do
|
||||||
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
||||||
def update_api_token(%ApiToken{} = api_token, attrs) do
|
def update_api_token(%ApiToken{} = api_token, attrs) do
|
||||||
api_token
|
api_token
|
||||||
|> ApiToken.changeset(attrs)
|
|> ApiToken.update_changeset(attrs)
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,31 @@ defmodule Towerops.ApiTokens.ApiToken do
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
@doc """
|
||||||
def changeset(api_token, attrs) do
|
Creates an API token changeset with programmatically-set fields.
|
||||||
|
|
||||||
|
`:organization_id` and `:user_id` are set explicitly, not cast from user input.
|
||||||
|
This prevents creating tokens for unauthorized organizations or users.
|
||||||
|
"""
|
||||||
|
def create_changeset(api_token, organization_id, user_id, attrs) do
|
||||||
api_token
|
api_token
|
||||||
|> cast(attrs, [:organization_id, :user_id, :name, :token_hash, :expires_at, :last_used_at])
|
|> cast(attrs, [:name, :token_hash, :expires_at, :last_used_at])
|
||||||
|
|> put_change(:organization_id, organization_id)
|
||||||
|
|> put_change(:user_id, user_id)
|
||||||
|> validate_required([:organization_id, :name, :token_hash])
|
|> validate_required([:organization_id, :name, :token_hash])
|
||||||
|> validate_length(:name, min: 1, max: 255)
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|> unique_constraint(:token_hash)
|
|> unique_constraint(:token_hash)
|
||||||
|> foreign_key_constraint(:organization_id)
|
|> foreign_key_constraint(:organization_id)
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating an API token. Only allows changing `:name` and `:expires_at`.
|
||||||
|
"""
|
||||||
|
def update_changeset(api_token, attrs) do
|
||||||
|
api_token
|
||||||
|
|> cast(attrs, [:name, :expires_at, :last_used_at])
|
||||||
|
|> validate_required([:name])
|
||||||
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,41 @@ defmodule Towerops.Capacity do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Batch calculates utilization for multiple interfaces in a single query.
|
||||||
|
Returns a map of `%{interface_id => %{utilization_pct: float, in_pct: float, out_pct: float, throughput: map}}`.
|
||||||
|
|
||||||
|
Interfaces without a configured capacity (nil or <= 0) are excluded from the result.
|
||||||
|
"""
|
||||||
|
def get_utilization_for_interfaces(interfaces) do
|
||||||
|
interfaces_with_capacity =
|
||||||
|
Enum.filter(interfaces, fn intf ->
|
||||||
|
cap = intf.configured_capacity_bps
|
||||||
|
is_integer(cap) and cap > 0
|
||||||
|
end)
|
||||||
|
|
||||||
|
if interfaces_with_capacity == [] do
|
||||||
|
%{}
|
||||||
|
else
|
||||||
|
ids = Enum.map(interfaces_with_capacity, & &1.id)
|
||||||
|
since = DateTime.add(DateTime.utc_now(), -900, :second)
|
||||||
|
throughput_by_id = batch_calculate_throughput(ids, since)
|
||||||
|
|
||||||
|
Map.new(interfaces_with_capacity, fn intf ->
|
||||||
|
throughput = Map.get(throughput_by_id, intf.id, zero_throughput())
|
||||||
|
cap = intf.configured_capacity_bps
|
||||||
|
|
||||||
|
{intf.id,
|
||||||
|
%{
|
||||||
|
utilization_pct: throughput.max_bps / cap * 100,
|
||||||
|
in_pct: throughput.in_bps / cap * 100,
|
||||||
|
out_pct: throughput.out_bps / cap * 100,
|
||||||
|
throughput: throughput
|
||||||
|
}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns a capacity summary for all interfaces at a site.
|
Returns a capacity summary for all interfaces at a site.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,19 @@ defmodule Towerops.Coverages do
|
||||||
|> Repo.one!()
|
|> Repo.one!()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches a coverage scoped to an organization with device and site preloaded.
|
||||||
|
Raises if not found.
|
||||||
|
"""
|
||||||
|
@spec get_coverage_with_device!(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t()
|
||||||
|
def get_coverage_with_device!(organization_id, id) do
|
||||||
|
Coverage
|
||||||
|
|> where(organization_id: ^organization_id)
|
||||||
|
|> where(id: ^id)
|
||||||
|
|> preload([:device, :site])
|
||||||
|
|> Repo.one!()
|
||||||
|
end
|
||||||
|
|
||||||
@doc "Fetches a coverage scoped to an organization. Returns nil if not found."
|
@doc "Fetches a coverage scoped to an organization. Returns nil if not found."
|
||||||
@spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil
|
@spec get_coverage(Ecto.UUID.t(), Ecto.UUID.t()) :: Coverage.t() | nil
|
||||||
def get_coverage(organization_id, id) do
|
def get_coverage(organization_id, id) do
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ defmodule Towerops.Devices do
|
||||||
site_id
|
site_id
|
||||||
|> DeviceQuery.for_site()
|
|> DeviceQuery.for_site()
|
||||||
|> DeviceQuery.order_by_display()
|
|> DeviceQuery.order_by_display()
|
||||||
|
|> preload([:site, :organization])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -68,7 +69,7 @@ defmodule Towerops.Devices do
|
||||||
join: o in assoc(e, :organization),
|
join: o in assoc(e, :organization),
|
||||||
where: e.organization_id in ^organization_ids,
|
where: e.organization_id in ^organization_ids,
|
||||||
order_by: [asc: e.organization_id, asc: e.name],
|
order_by: [asc: e.organization_id, asc: e.name],
|
||||||
preload: [site: s, organization: o]
|
preload: [organization: o, site: {s, :organization}]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ defmodule Towerops.OnCall do
|
||||||
|> ScheduleQuery.for_organization()
|
|> ScheduleQuery.for_organization()
|
||||||
|> ScheduleQuery.order_by_name()
|
|> ScheduleQuery.order_by_name()
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|
|> Repo.preload(overrides: :user, layers: [members: :user])
|
||||||
end
|
end
|
||||||
|
|
||||||
def get_schedule!(id) do
|
def get_schedule!(id) do
|
||||||
|
|
|
||||||
|
|
@ -107,12 +107,9 @@ defmodule Towerops.Organizations do
|
||||||
has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id),
|
has_existing = Repo.exists?(from m in Membership, where: m.user_id == ^user_id),
|
||||||
{:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)),
|
{:ok, organization} <- Repo.insert(Organization.changeset(%Organization{}, attrs)),
|
||||||
membership_changeset =
|
membership_changeset =
|
||||||
Membership.changeset(%Membership{}, %{
|
%Membership{}
|
||||||
organization_id: organization.id,
|
|> Membership.create_changeset(organization.id, user_id, :owner)
|
||||||
user_id: user_id,
|
|> Ecto.Changeset.put_change(:is_default, !has_existing),
|
||||||
role: :owner,
|
|
||||||
is_default: !has_existing
|
|
||||||
}),
|
|
||||||
{:ok, _membership} <- Repo.insert(membership_changeset) do
|
{:ok, _membership} <- Repo.insert(membership_changeset) do
|
||||||
organization
|
organization
|
||||||
else
|
else
|
||||||
|
|
@ -267,10 +264,28 @@ defmodule Towerops.Organizations do
|
||||||
Updates a member's role. Owners cannot have their role changed.
|
Updates a member's role. Owners cannot have their role changed.
|
||||||
"""
|
"""
|
||||||
def update_member_role(organization_id, user_id, new_role) do
|
def update_member_role(organization_id, user_id, new_role) do
|
||||||
case get_membership(organization_id, user_id) do
|
role_atom = parse_role(new_role)
|
||||||
%Membership{role: :owner} -> {:error, :cannot_change_owner_role}
|
|
||||||
%Membership{} = membership -> update_membership(membership, %{role: new_role})
|
if role_atom == :invalid do
|
||||||
nil -> {:error, :not_found}
|
changeset =
|
||||||
|
%Membership{}
|
||||||
|
|> Ecto.Changeset.change()
|
||||||
|
|> Ecto.Changeset.add_error(:role, "is invalid")
|
||||||
|
|
||||||
|
{:error, changeset}
|
||||||
|
else
|
||||||
|
case get_membership(organization_id, user_id) do
|
||||||
|
%Membership{role: :owner} ->
|
||||||
|
{:error, :cannot_change_owner_role}
|
||||||
|
|
||||||
|
%Membership{} = membership ->
|
||||||
|
membership
|
||||||
|
|> Membership.role_update_changeset(role_atom)
|
||||||
|
|> Repo.update()
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -290,15 +305,35 @@ defmodule Towerops.Organizations do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a membership (adds a user to an organization).
|
Creates a membership (adds a user to an organization).
|
||||||
|
|
||||||
|
Expects a map with `:organization_id`, `:user_id`, and `:role` keys.
|
||||||
|
These fields are set programmatically, not cast from user input.
|
||||||
"""
|
"""
|
||||||
def create_membership(attrs) do
|
def create_membership(attrs) do
|
||||||
|
org_id = Map.get(attrs, :organization_id) || Map.get(attrs, "organization_id")
|
||||||
|
user_id = Map.get(attrs, :user_id) || Map.get(attrs, "user_id")
|
||||||
|
role = parse_role(Map.get(attrs, :role) || Map.get(attrs, "role") || :member)
|
||||||
|
|
||||||
%Membership{}
|
%Membership{}
|
||||||
|> Membership.changeset(attrs)
|
|> Membership.create_changeset(org_id, user_id, role)
|
||||||
|> Repo.insert()
|
|> Repo.insert()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp parse_role(role) when is_atom(role), do: role
|
||||||
|
|
||||||
|
defp parse_role(role) when is_binary(role) do
|
||||||
|
String.to_existing_atom(role)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> :invalid
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_role(_), do: :invalid
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates a membership (changes a user's role).
|
Updates non-security fields of a membership.
|
||||||
|
|
||||||
|
Currently only allows changing `:is_default`. Use `update_member_role/3`
|
||||||
|
for role changes (which includes authorization checks).
|
||||||
"""
|
"""
|
||||||
def update_membership(%Membership{} = membership, attrs) do
|
def update_membership(%Membership{} = membership, attrs) do
|
||||||
membership
|
membership
|
||||||
|
|
@ -428,11 +463,12 @@ defmodule Towerops.Organizations do
|
||||||
})
|
})
|
||||||
|
|
||||||
membership_changeset =
|
membership_changeset =
|
||||||
Membership.changeset(%Membership{}, %{
|
Membership.create_changeset(
|
||||||
organization_id: invitation.organization_id,
|
%Membership{},
|
||||||
user_id: user_id,
|
invitation.organization_id,
|
||||||
role: invitation.role
|
user_id,
|
||||||
})
|
invitation.role
|
||||||
|
)
|
||||||
|
|
||||||
with {:ok, _} <- Repo.update(invitation_changeset),
|
with {:ok, _} <- Repo.update(invitation_changeset),
|
||||||
{:ok, membership} <- Repo.insert(membership_changeset) do
|
{:ok, membership} <- Repo.insert(membership_changeset) do
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,19 @@ defmodule Towerops.Organizations.Membership do
|
||||||
updated_at: DateTime.t()
|
updated_at: DateTime.t()
|
||||||
}
|
}
|
||||||
|
|
||||||
@doc false
|
@doc """
|
||||||
def changeset(membership, attrs) do
|
Creates a membership changeset with programmatically-set fields.
|
||||||
|
|
||||||
|
`:organization_id`, `:user_id`, and `:role` are set explicitly, not cast from user input.
|
||||||
|
This prevents privilege escalation via mass assignment.
|
||||||
|
"""
|
||||||
|
def create_changeset(membership, organization_id, user_id, role) do
|
||||||
membership
|
membership
|
||||||
|> cast(attrs, [:role, :organization_id, :user_id, :is_default])
|
|> cast(%{}, [])
|
||||||
|
|> put_change(:organization_id, organization_id)
|
||||||
|
|> put_change(:user_id, user_id)
|
||||||
|
|> put_change(:role, role)
|
||||||
|
|> put_change(:is_default, false)
|
||||||
|> validate_required([:role, :organization_id, :user_id])
|
|> validate_required([:role, :organization_id, :user_id])
|
||||||
|> foreign_key_constraint(:organization_id)
|
|> foreign_key_constraint(:organization_id)
|
||||||
|> foreign_key_constraint(:user_id)
|
|> foreign_key_constraint(:user_id)
|
||||||
|
|
@ -50,4 +59,28 @@ defmodule Towerops.Organizations.Membership do
|
||||||
name: :organization_memberships_organization_id_user_id_index
|
name: :organization_memberships_organization_id_user_id_index
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating a membership role.
|
||||||
|
|
||||||
|
Only `:role` can be changed. Other fields are immutable after creation.
|
||||||
|
Roles are validated against the enum type.
|
||||||
|
"""
|
||||||
|
def role_update_changeset(membership, new_role) do
|
||||||
|
membership
|
||||||
|
|> cast(%{}, [])
|
||||||
|
|> put_change(:role, new_role)
|
||||||
|
|> validate_required([:role])
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Changeset for updating non-security fields of a membership.
|
||||||
|
|
||||||
|
Currently only allows changing `:is_default`. Does NOT allow changing `:role`,
|
||||||
|
`:organization_id`, or `:user_id` — use `role_update_changeset/2` or
|
||||||
|
`create_changeset/4` for those respectively.
|
||||||
|
"""
|
||||||
|
def changeset(membership, attrs) do
|
||||||
|
cast(membership, attrs, [:is_default])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,14 @@ defmodule Towerops.Snmp do
|
||||||
defdelegate set_manual_capacity(interface_id, capacity_bps), to: Interfaces
|
defdelegate set_manual_capacity(interface_id, capacity_bps), to: Interfaces
|
||||||
defdelegate clear_manual_capacity(interface_id), to: Interfaces
|
defdelegate clear_manual_capacity(interface_id), to: Interfaces
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks whether an SNMP interface belongs to a device in the given organization.
|
||||||
|
Returns boolean.
|
||||||
|
"""
|
||||||
|
def interface_belongs_to_organization?(interface_id, org_id) do
|
||||||
|
Interfaces.belongs_to_organization?(interface_id, org_id)
|
||||||
|
end
|
||||||
|
|
||||||
## Sensor metadata delegates (implementation in `Towerops.Snmp.Sensors`).
|
## Sensor metadata delegates (implementation in `Towerops.Snmp.Sensors`).
|
||||||
|
|
||||||
defdelegate list_sensors(device_id), to: Sensors
|
defdelegate list_sensors(device_id), to: Sensors
|
||||||
|
|
|
||||||
|
|
@ -78,4 +78,19 @@ defmodule Towerops.Snmp.Interfaces do
|
||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks whether an SNMP interface belongs to a device in the given organization.
|
||||||
|
Returns boolean.
|
||||||
|
"""
|
||||||
|
def belongs_to_organization?(interface_id, org_id) do
|
||||||
|
query =
|
||||||
|
from i in Interface,
|
||||||
|
join: sd in assoc(i, :snmp_device),
|
||||||
|
join: d in assoc(sd, :device),
|
||||||
|
where: i.id == ^interface_id and d.organization_id == ^org_id,
|
||||||
|
select: count(i.id)
|
||||||
|
|
||||||
|
Repo.one(query) > 0
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -121,10 +121,6 @@ defmodule ToweropsWeb.Layouts do
|
||||||
def authenticated(assigns) do
|
def authenticated(assigns) do
|
||||||
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
|
assigns = Map.put_new(assigns, :requires_cookie_consent, false)
|
||||||
|
|
||||||
# Get unresolved alert count from process dictionary (set/updated in on_mount hook)
|
|
||||||
unresolved_alert_count = Process.get(:unresolved_alert_count, 0)
|
|
||||||
assigns = Map.put(assigns, :unresolved_alert_count, unresolved_alert_count)
|
|
||||||
|
|
||||||
# Extract organization and timezone from scope for template convenience
|
# Extract organization and timezone from scope for template convenience
|
||||||
current_organization = assigns[:current_scope] && assigns.current_scope.organization
|
current_organization = assigns[:current_scope] && assigns.current_scope.organization
|
||||||
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
|
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,31 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
|
||||||
organization_id = conn.assigns.current_organization_id
|
organization_id = conn.assigns.current_organization_id
|
||||||
user = conn.assigns[:current_user]
|
user = conn.assigns[:current_user]
|
||||||
|
|
||||||
attrs = %{
|
case authorize_invite(organization_id, user, role) do
|
||||||
organization_id: organization_id,
|
{:ok, _} ->
|
||||||
email: email,
|
attrs = %{
|
||||||
role: role,
|
organization_id: organization_id,
|
||||||
invited_by_id: user && user.id,
|
email: email,
|
||||||
token: Ecto.UUID.generate(),
|
role: role,
|
||||||
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
|
invited_by_id: user.id,
|
||||||
}
|
token: Ecto.UUID.generate(),
|
||||||
|
expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second)
|
||||||
|
}
|
||||||
|
|
||||||
case Organizations.create_invitation(attrs) do
|
case Organizations.create_invitation(attrs) do
|
||||||
{:ok, invitation} ->
|
{:ok, invitation} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(:created)
|
|> put_status(:created)
|
||||||
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
|
|> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))})
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
conn
|
conn
|
||||||
|> put_status(:unprocessable_entity)
|
|> put_status(:unprocessable_entity)
|
||||||
|> json(%{errors: translate_errors(changeset)})
|
|> json(%{errors: translate_errors(changeset)})
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
conn |> put_status(:forbidden) |> json(%{error: reason})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -47,6 +53,23 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do
|
||||||
conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"})
|
conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorize_invite(_org_id, nil, _role), do: {:error, "authentication required"}
|
||||||
|
|
||||||
|
defp authorize_invite(org_id, user, _role) do
|
||||||
|
membership = Organizations.get_membership(org_id, user.id)
|
||||||
|
|
||||||
|
case membership do
|
||||||
|
nil ->
|
||||||
|
{:error, "not a member of this organization"}
|
||||||
|
|
||||||
|
%{role: member_role} when member_role not in [:owner, :admin] ->
|
||||||
|
{:error, "only admins and owners can send invitations"}
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
{:ok, membership}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def delete(conn, %{"id" => id}) do
|
def delete(conn, %{"id" => id}) do
|
||||||
organization_id = conn.assigns.current_organization_id
|
organization_id = conn.assigns.current_organization_id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
{content.()}
|
{content.()}
|
||||||
</Layouts.authenticated>
|
</Layouts.authenticated>
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
{content.()}
|
{content.()}
|
||||||
</Layouts.authenticated>
|
</Layouts.authenticated>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
|
>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<.header>
|
<.header>
|
||||||
{t_auth("Account Settings")}
|
{t_auth("Account Settings")}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
|
||||||
alias ToweropsWeb.GraphQL.Resolvers.Helpers
|
alias ToweropsWeb.GraphQL.Resolvers.Helpers
|
||||||
alias ToweropsWeb.ScopedResource
|
alias ToweropsWeb.ScopedResource
|
||||||
|
|
||||||
|
# Roles that are allowed to send invitations
|
||||||
|
@invite_roles [:owner, :admin]
|
||||||
|
# Role hierarchy for checking if inviter can assign a given role
|
||||||
|
@role_rank %{owner: 0, admin: 1, executive: 2, technician: 3, member: 4, viewer: 5}
|
||||||
|
|
||||||
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
|
def list(_parent, _args, %{context: %{organization_id: org_id}}) do
|
||||||
members = Organizations.list_organization_members(org_id)
|
members = Organizations.list_organization_members(org_id)
|
||||||
{:ok, members}
|
{:ok, members}
|
||||||
|
|
@ -17,19 +22,56 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do
|
||||||
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
|
def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do
|
||||||
role = Map.get(args, :role, "technician")
|
role = Map.get(args, :role, "technician")
|
||||||
|
|
||||||
case Organizations.create_invitation(%{
|
with {:ok, _membership} <- authorize_invite(org_id, user.id, role) do
|
||||||
email: email,
|
case Organizations.create_invitation(%{
|
||||||
role: role,
|
email: email,
|
||||||
organization_id: org_id,
|
role: role,
|
||||||
invited_by_id: user.id
|
organization_id: org_id,
|
||||||
}) do
|
invited_by_id: user.id
|
||||||
{:ok, invitation} -> {:ok, invitation}
|
}) do
|
||||||
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
{:ok, invitation} -> {:ok, invitation}
|
||||||
|
{:error, changeset} -> {:error, Helpers.format_changeset_errors(changeset)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def invite(_parent, _args, _resolution), do: Helpers.authentication_error()
|
def invite(_parent, _args, _resolution), do: Helpers.authentication_error()
|
||||||
|
|
||||||
|
defp authorize_invite(org_id, user_id, requested_role) do
|
||||||
|
case Organizations.get_membership(org_id, user_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, "not a member of this organization"}
|
||||||
|
|
||||||
|
membership when membership.role not in @invite_roles ->
|
||||||
|
{:error, "only admins and owners can send invitations"}
|
||||||
|
|
||||||
|
membership ->
|
||||||
|
authorize_role(membership, requested_role)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp authorize_role(membership, requested_role) do
|
||||||
|
case parse_role(requested_role) do
|
||||||
|
nil ->
|
||||||
|
{:error, "invalid role: #{requested_role}"}
|
||||||
|
|
||||||
|
requested_role_atom ->
|
||||||
|
if @role_rank[membership.role] <= @role_rank[requested_role_atom] do
|
||||||
|
{:ok, membership}
|
||||||
|
else
|
||||||
|
{:error, "cannot assign a role higher than your own"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_role(role) when is_binary(role) do
|
||||||
|
atom = String.to_existing_atom(role)
|
||||||
|
|
||||||
|
if Map.has_key?(@role_rank, atom), do: atom
|
||||||
|
rescue
|
||||||
|
ArgumentError -> nil
|
||||||
|
end
|
||||||
|
|
||||||
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
|
||||||
with {:ok, invitation} <- fetch_org_invitation(id, org_id) do
|
with {:ok, invitation} <- fetch_org_invitation(id, org_id) do
|
||||||
case Repo.delete(invitation) do
|
case Repo.delete(invitation) do
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,12 @@ defmodule ToweropsWeb.AccountLive.Activity do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="settings">
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
active_page="settings"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
Activity Log
|
Activity Log
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="settings"
|
active_page="settings"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
|
@ -628,9 +629,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
||||||
if Enum.empty?(org_ids) do
|
if Enum.empty?(org_ids) do
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
org_ids
|
Devices.list_devices_for_organizations(org_ids)
|
||||||
|> Devices.list_devices_for_organizations()
|
|
||||||
|> Towerops.Repo.preload(site: :organization)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -646,9 +645,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
||||||
else
|
else
|
||||||
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
|
ninety_days_ago = DateTime.add(DateTime.utc_now(), -90, :day)
|
||||||
|
|
||||||
org_ids
|
Alerts.list_alerts_for_organizations(org_ids, since: ninety_days_ago)
|
||||||
|> Alerts.list_alerts_for_organizations(since: ninety_days_ago)
|
|
||||||
|> Towerops.Repo.preload(:device)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,11 @@ defmodule ToweropsWeb.AccountLive.TotpEnrollment do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
|
>
|
||||||
<div class="mx-auto max-w-2xl px-4 py-12">
|
<div class="mx-auto max-w-2xl px-4 py-12">
|
||||||
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
|
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-8">
|
||||||
<%= if @show_recovery_codes do %>
|
<%= if @show_recovery_codes do %>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="activity"
|
active_page="activity"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,9 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
||||||
"""
|
"""
|
||||||
use ToweropsWeb, :live_view
|
use ToweropsWeb, :live_view
|
||||||
|
|
||||||
import Ecto.Query
|
|
||||||
|
|
||||||
alias Towerops.Admin
|
alias Towerops.Admin
|
||||||
alias Towerops.Billing
|
alias Towerops.Billing
|
||||||
alias Towerops.Organizations.Organization
|
alias Towerops.Organizations.Organization
|
||||||
alias Towerops.Repo
|
|
||||||
|
|
||||||
on_mount {ToweropsWeb.UserAuth, :require_superuser}
|
on_mount {ToweropsWeb.UserAuth, :require_superuser}
|
||||||
|
|
||||||
|
|
@ -18,7 +15,7 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
||||||
orgs = Admin.list_all_organizations()
|
orgs = Admin.list_all_organizations()
|
||||||
default_free_devices = Billing.default_free_devices()
|
default_free_devices = Billing.default_free_devices()
|
||||||
default_price = Billing.default_price_per_device()
|
default_price = Billing.default_price_per_device()
|
||||||
active_sub_count = count_active_subscriptions()
|
active_sub_count = Admin.count_active_subscriptions()
|
||||||
|
|
||||||
ip =
|
ip =
|
||||||
case get_connect_info(socket, :peer_data) do
|
case get_connect_info(socket, :peer_data) do
|
||||||
|
|
@ -261,12 +258,4 @@ defmodule ToweropsWeb.Admin.OrgLive.Index do
|
||||||
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
|
[{:default_price_per_device, {"must be between 0.01 and 999.99", []}} | errors]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp count_active_subscriptions do
|
|
||||||
Repo.one(
|
|
||||||
from o in Organization,
|
|
||||||
where: o.subscription_status == "active" and not is_nil(o.stripe_subscription_id),
|
|
||||||
select: count(o.id)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="agents"
|
active_page="agents"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="agents"
|
active_page="agents"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="alerts"
|
active_page="alerts"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<%!-- Compact header --%>
|
<%!-- Compact header --%>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,12 @@ defmodule ToweropsWeb.ChangelogLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="changelog">
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
active_page="changelog"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
|
>
|
||||||
<div class="mx-auto max-w-3xl px-4 py-8">
|
<div class="mx-auto max-w-3xl px-4 py-8">
|
||||||
<h1 class="text-3xl font-bold mb-2">What's New</h1>
|
<h1 class="text-3xl font-bold mb-2">What's New</h1>
|
||||||
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>
|
<p class="text-base-content/60 mb-8">Latest updates and improvements to TowerOps.</p>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="coverage"
|
active_page="coverage"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
%{label: t("Dashboard"), navigate: ~p"/dashboard"},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="coverage"
|
active_page="coverage"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="coverage"
|
active_page="coverage"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
organization = socket.assigns.current_scope.organization
|
organization = socket.assigns.current_scope.organization
|
||||||
coverage = organization.id |> Coverages.get_coverage!(id) |> Towerops.Repo.preload([:device, :site])
|
coverage = Coverages.get_coverage_with_device!(organization.id, id)
|
||||||
antenna = Antenna.get(coverage.antenna_slug)
|
antenna = Antenna.get(coverage.antenna_slug)
|
||||||
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
|
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
|
||||||
|
|
||||||
|
|
@ -95,9 +95,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:coverage_status, _status, _extra}, socket) do
|
def handle_info({:coverage_status, _status, _extra}, socket) do
|
||||||
coverage =
|
coverage =
|
||||||
socket.assigns.organization.id
|
Coverages.get_coverage_with_device!(socket.assigns.organization.id, socket.assigns.coverage.id)
|
||||||
|> Coverages.get_coverage!(socket.assigns.coverage.id)
|
|
||||||
|> Towerops.Repo.preload([:device, :site])
|
|
||||||
|
|
||||||
eirp =
|
eirp =
|
||||||
Coverage.eirp_dbm(
|
Coverage.eirp_dbm(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="coverage"
|
active_page="coverage"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
|
<%!-- Top bar: title, status, actions. Compact so the map can dominate. --%>
|
||||||
<div class="mb-4 flex items-start justify-between gap-4">
|
<div class="mb-4 flex items-start justify-between gap-4">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="dashboard"
|
active_page="dashboard"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
id="dynamic-favicon"
|
id="dynamic-favicon"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
|
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="devices"
|
active_page="devices"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<%!-- Header: title + actions, then tabs below on mobile --%>
|
<%!-- Header: title + actions, then tabs below on mobile --%>
|
||||||
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
|
<div class="mb-4 border-b border-gray-200 dark:border-white/10 pb-3">
|
||||||
|
|
|
||||||
|
|
@ -511,13 +511,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
defp assign_ports_data(socket) do
|
defp assign_ports_data(socket) do
|
||||||
interfaces = socket.assigns.snmp_interfaces
|
interfaces = socket.assigns.snmp_interfaces
|
||||||
|
|
||||||
|
# Batch query utilization for all interfaces in a single query (fixes N+1)
|
||||||
|
utilization_by_id = Towerops.Capacity.get_utilization_for_interfaces(interfaces)
|
||||||
|
|
||||||
interfaces_with_utilization =
|
interfaces_with_utilization =
|
||||||
Enum.map(interfaces, fn interface ->
|
Enum.map(interfaces, fn interface ->
|
||||||
utilization =
|
utilization = Map.get(utilization_by_id, interface.id)
|
||||||
if interface.configured_capacity_bps do
|
|
||||||
Towerops.Capacity.get_utilization(interface)
|
|
||||||
end
|
|
||||||
|
|
||||||
Map.put(interface, :utilization, utilization)
|
Map.put(interface, :utilization, utilization)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -905,22 +904,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp interface_belongs_to_org?(interface_id, org_id) do
|
defp interface_belongs_to_org?(interface_id, org_id) do
|
||||||
{:ok, iid} = Ecto.UUID.dump(interface_id)
|
Snmp.interface_belongs_to_organization?(interface_id, org_id)
|
||||||
{:ok, oid} = Ecto.UUID.dump(org_id)
|
|
||||||
|
|
||||||
result =
|
|
||||||
Towerops.Repo.query!(
|
|
||||||
"""
|
|
||||||
SELECT 1 FROM snmp_interfaces i
|
|
||||||
JOIN snmp_devices sd ON sd.id = i.snmp_device_id
|
|
||||||
JOIN devices d ON d.id = sd.device_id
|
|
||||||
WHERE i.id = $1 AND d.organization_id = $2
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
[iid, oid]
|
|
||||||
)
|
|
||||||
|
|
||||||
result.num_rows > 0
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp reload_snmp_and_ports(socket) do
|
defp reload_snmp_and_ports(socket) do
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="devices"
|
active_page="devices"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="mb-6 -mt-2">
|
<div class="mb-6 -mt-2">
|
||||||
<.breadcrumb items={
|
<.breadcrumb items={
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<.link
|
<.link
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="devices"
|
active_page="devices"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<!-- Header with back button -->
|
<!-- Header with back button -->
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="help"
|
active_page="help"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.help_content
|
<.help_content
|
||||||
active_section={@active_section}
|
active_section={@active_section}
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
|
||||||
(devices, sites, alerts) within their organization scope.
|
(devices, sites, alerts) within their organization scope.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Towerops.Alerts.Alert
|
alias Towerops.Alerts
|
||||||
alias Towerops.Devices
|
alias Towerops.Devices
|
||||||
alias Towerops.Devices.Device
|
alias Towerops.Devices.Device
|
||||||
alias Towerops.Repo
|
|
||||||
alias Towerops.Sites
|
alias Towerops.Sites
|
||||||
alias Towerops.Sites.Site
|
alias Towerops.Sites.Site
|
||||||
|
|
||||||
|
|
@ -52,13 +51,6 @@ defmodule ToweropsWeb.Live.Helpers.AccessControl do
|
||||||
@spec verify_alert_access(binary(), binary()) ::
|
@spec verify_alert_access(binary(), binary()) ::
|
||||||
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
|
{:ok, Alert.t()} | {:error, :not_found | :unauthorized}
|
||||||
def verify_alert_access(alert_id, organization_id) do
|
def verify_alert_access(alert_id, organization_id) do
|
||||||
with %Alert{} = alert <- Repo.get(Alert, alert_id),
|
Alerts.get_verified_alert(alert_id, organization_id)
|
||||||
alert = Repo.preload(alert, [:acknowledged_by, device: [site: :organization]]),
|
|
||||||
%Alert{device: %{site: %{organization_id: ^organization_id}}} <- alert do
|
|
||||||
{:ok, alert}
|
|
||||||
else
|
|
||||||
nil -> {:error, :not_found}
|
|
||||||
%Alert{} -> {:error, :unauthorized}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ defmodule ToweropsWeb.InsightsLive.Index do
|
||||||
|
|
||||||
alias Towerops.Accounts.Scope
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Preseem
|
alias Towerops.Preseem
|
||||||
alias Towerops.Repo
|
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
|
@ -177,10 +176,7 @@ defmodule ToweropsWeb.InsightsLive.Index do
|
||||||
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
|
do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency),
|
||||||
else: opts
|
else: opts
|
||||||
|
|
||||||
insights =
|
insights = Preseem.list_insights(org_id, opts)
|
||||||
org_id
|
|
||||||
|> Preseem.list_insights(opts)
|
|
||||||
|> Repo.preload([:preseem_access_point, :device])
|
|
||||||
|
|
||||||
assign(socket, :insights, insights)
|
assign(socket, :insights, insights)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="insights"
|
active_page="insights"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="flex items-start justify-between gap-4 border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="maintenance"
|
active_page="maintenance"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<.link
|
<.link
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="maintenance"
|
active_page="maintenance"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("Maintenance Windows")}</h1>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="maintenance"
|
active_page="maintenance"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<.link
|
<.link
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="sites-map"
|
active_page="sites-map"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
||||||
alias Towerops.Devices
|
alias Towerops.Devices
|
||||||
alias Towerops.Devices.MikrotikBackups
|
alias Towerops.Devices.MikrotikBackups
|
||||||
alias Towerops.Devices.MikrotikBackups.Differ
|
alias Towerops.Devices.MikrotikBackups.Differ
|
||||||
alias Towerops.Repo
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
@ -25,9 +24,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
||||||
|> push_navigate(to: ~p"/devices")}
|
|> push_navigate(to: ~p"/devices")}
|
||||||
|
|
||||||
device ->
|
device ->
|
||||||
device_with_site = Repo.preload(device, site: :organization)
|
if device.site.organization_id == organization.id do
|
||||||
|
|
||||||
if device_with_site.site.organization_id == organization.id do
|
|
||||||
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
|
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
|
||||||
else
|
else
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,11 @@ defmodule ToweropsWeb.MobileQRLive do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
|
<Layouts.authenticated
|
||||||
|
flash={@flash}
|
||||||
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
|
>
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="mx-auto max-w-4xl">
|
||||||
<.header>
|
<.header>
|
||||||
Link Mobile App
|
Link Mobile App
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="network-map"
|
active_page="network-map"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="max-w-2xl mx-auto py-8">
|
<div class="max-w-2xl mx-auto py-8">
|
||||||
<%!-- Step indicator --%>
|
<%!-- Step indicator --%>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="settings"
|
active_page="settings"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="pb-5">
|
<div class="pb-5">
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="reports"
|
active_page="reports"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="rf-links"
|
active_page="rf-links"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3 mb-6">
|
<div class="flex items-center gap-3 mb-6">
|
||||||
<.link
|
<.link
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,7 @@ defmodule ToweropsWeb.ScheduleLive.Index do
|
||||||
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
|
{:ok, start_at} = DateTime.new(start_date, ~T[00:00:00], "Etc/UTC")
|
||||||
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
|
{:ok, end_at} = DateTime.new(end_date, ~T[00:00:00], "Etc/UTC")
|
||||||
|
|
||||||
preloaded_schedules =
|
Enum.map(schedules, fn preloaded ->
|
||||||
Towerops.Repo.preload(schedules, overrides: :user, layers: [members: :user])
|
|
||||||
|
|
||||||
Enum.map(preloaded_schedules, fn preloaded ->
|
|
||||||
schedule = preloaded
|
schedule = preloaded
|
||||||
on_call = Resolver.resolve(preloaded, DateTime.utc_now())
|
on_call = Resolver.resolve(preloaded, DateTime.utc_now())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{t("On-Call & Schedules")}</h1>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="schedules"
|
active_page="schedules"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3 mb-6">
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-6">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<.link
|
<.link
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="sites"
|
active_page="sites"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
{@page_title}
|
{@page_title}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="sites"
|
active_page="sites"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={[
|
<.breadcrumb items={[
|
||||||
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
%{label: "Dashboard", navigate: ~p"/dashboard"},
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,48 @@ defmodule ToweropsWeb.StatusPageLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, _url, socket) do
|
||||||
|
slug = Map.get(params, "slug", "")
|
||||||
|
|
||||||
|
case slug do
|
||||||
|
"" ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Status page not found")
|
||||||
|
|> assign(:not_found, true)
|
||||||
|
|> assign(:page_title, "Not Found")}
|
||||||
|
|
||||||
|
slug ->
|
||||||
|
case StatusPages.get_config_by_slug(slug) do
|
||||||
|
nil ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Status page not found")
|
||||||
|
|> assign(:not_found, true)
|
||||||
|
|> assign(:page_title, "Not Found")}
|
||||||
|
|
||||||
|
config ->
|
||||||
|
maybe_subscribe_to_status_page(socket, config)
|
||||||
|
|
||||||
|
components = StatusPages.list_components(config.id)
|
||||||
|
active_incidents = StatusPages.list_active_incidents(config.id)
|
||||||
|
recent_incidents = StatusPages.list_incidents(config.id, limit: 10)
|
||||||
|
overall = StatusPages.overall_status(components)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:config, config)
|
||||||
|
|> assign(:components, components)
|
||||||
|
|> assign(:active_incidents, active_incidents)
|
||||||
|
|> assign(:recent_incidents, recent_incidents)
|
||||||
|
|> assign(:overall_status, overall)
|
||||||
|
|> assign(:not_found, false)
|
||||||
|
|> assign(:page_title, "#{config.company_name || "Status"} — System Status")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:status_updated, _}, socket) do
|
def handle_info({:status_updated, _}, socket) do
|
||||||
config = socket.assigns.config
|
config = socket.assigns.config
|
||||||
|
|
@ -104,4 +146,10 @@ defmodule ToweropsWeb.StatusPageLive do
|
||||||
def incident_status_label("monitoring"), do: "Monitoring"
|
def incident_status_label("monitoring"), do: "Monitoring"
|
||||||
def incident_status_label("resolved"), do: "Resolved"
|
def incident_status_label("resolved"), do: "Resolved"
|
||||||
def incident_status_label(s), do: s
|
def incident_status_label(s), do: s
|
||||||
|
|
||||||
|
defp maybe_subscribe_to_status_page(socket, config) do
|
||||||
|
if connected?(socket) do
|
||||||
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "status_page:#{config.id}")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="trace"
|
active_page="trace"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.breadcrumb items={
|
<.breadcrumb items={
|
||||||
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
|
[%{label: "Dashboard", navigate: ~p"/dashboard"}, %{label: "Trace"}] ++
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
|
||||||
alias Towerops.Organizations
|
alias Towerops.Organizations
|
||||||
alias Towerops.Workers.WelcomeEmailWorker
|
alias Towerops.Workers.WelcomeEmailWorker
|
||||||
|
|
||||||
|
@impl true
|
||||||
def mount(params, session, socket) do
|
def mount(params, session, socket) do
|
||||||
# Check if registering via invitation
|
# Check if registering via invitation
|
||||||
invitation_token = params["invitation_token"] || params["token"]
|
invitation_token = params["invitation_token"] || params["token"]
|
||||||
|
|
@ -32,6 +33,18 @@ defmodule ToweropsWeb.UserRegistrationLive do
|
||||||
|> assign(:terms_of_service_consent, false)}
|
|> assign(:terms_of_service_consent, false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, _url, socket) do
|
||||||
|
invitation_token = Map.get(params, "invitation_token") || Map.get(params, "token")
|
||||||
|
invitation = invitation_token && Organizations.get_invitation_by_token(invitation_token)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:invitation, invitation)
|
||||||
|
|> assign(:invitation_token, invitation_token)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("check_password_breach", %{"value" => password}, socket)
|
def handle_event("check_password_breach", %{"value" => password}, socket)
|
||||||
when is_binary(password) and byte_size(password) > 0 do
|
when is_binary(password) and byte_size(password) > 0 do
|
||||||
case HIBP.check_password(password) do
|
case HIBP.check_password(password) do
|
||||||
|
|
@ -151,6 +164,7 @@ defmodule ToweropsWeb.UserRegistrationLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash}>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
|
||||||
alias Towerops.Accounts
|
alias Towerops.Accounts
|
||||||
alias Towerops.Accounts.HIBP
|
alias Towerops.Accounts.HIBP
|
||||||
|
|
||||||
|
@impl true
|
||||||
def mount(%{"token" => token}, _session, socket) do
|
def mount(%{"token" => token}, _session, socket) do
|
||||||
case Accounts.get_user_by_reset_password_token(token) do
|
case Accounts.get_user_by_reset_password_token(token) do
|
||||||
nil ->
|
nil ->
|
||||||
|
|
@ -27,6 +28,37 @@ defmodule ToweropsWeb.UserResetPasswordLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(params, _url, socket) do
|
||||||
|
case Map.get(params, "token", "") do
|
||||||
|
"" ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|
||||||
|
|> redirect(to: ~p"/users/log-in")}
|
||||||
|
|
||||||
|
token ->
|
||||||
|
case Accounts.get_user_by_reset_password_token(token) do
|
||||||
|
nil ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, t("Reset password link is invalid or has expired."))
|
||||||
|
|> redirect(to: ~p"/users/log-in")}
|
||||||
|
|
||||||
|
user ->
|
||||||
|
changeset = Accounts.change_user_password(user)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, t("Reset Password"))
|
||||||
|
|> assign(:form, to_form(changeset))
|
||||||
|
|> assign(:user, user)
|
||||||
|
|> assign(:password_breach_count, nil)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("check_password_breach", %{"value" => password}, socket)
|
def handle_event("check_password_breach", %{"value" => password}, socket)
|
||||||
when is_binary(password) and byte_size(password) > 0 do
|
when is_binary(password) and byte_size(password) > 0 do
|
||||||
case HIBP.check_password(password) do
|
case HIBP.check_password(password) do
|
||||||
|
|
@ -64,6 +96,7 @@ defmodule ToweropsWeb.UserResetPasswordLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash}>
|
<Layouts.app flash={@flash}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<Layouts.authenticated
|
<Layouts.authenticated
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<div class="pb-5">
|
<div class="pb-5">
|
||||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
current_scope={@current_scope}
|
current_scope={@current_scope}
|
||||||
active_page="weathermap"
|
active_page="weathermap"
|
||||||
|
unresolved_alert_count={assigns[:unresolved_alert_count] || 0}
|
||||||
>
|
>
|
||||||
<.header>
|
<.header>
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -607,10 +607,9 @@ defmodule ToweropsWeb.UserAuth do
|
||||||
alert_count =
|
alert_count =
|
||||||
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
|
if organization, do: Towerops.Alerts.count_active_alerts(organization.id), else: 0
|
||||||
|
|
||||||
Process.put(:unresolved_alert_count, alert_count)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|
|> Phoenix.Component.assign(:unresolved_alert_count, alert_count)
|
||||||
|> maybe_subscribe_to_status_updates(organization)
|
|> maybe_subscribe_to_status_updates(organization)
|
||||||
|> attach_status_title_hook(organization)
|
|> attach_status_title_hook(organization)
|
||||||
|> attach_alert_count_hook(organization)
|
|> attach_alert_count_hook(organization)
|
||||||
|
|
@ -1079,8 +1078,11 @@ defmodule ToweropsWeb.UserAuth do
|
||||||
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
|
LiveView.attach_hook(socket, :alert_count_updates, :handle_info, fn
|
||||||
{:alert_changed, org_id}, socket when org_id == organization.id ->
|
{:alert_changed, org_id}, socket when org_id == organization.id ->
|
||||||
count = Towerops.Alerts.count_active_alerts(organization.id)
|
count = Towerops.Alerts.count_active_alerts(organization.id)
|
||||||
Process.put(:unresolved_alert_count, count)
|
|
||||||
{:cont, Phoenix.Component.assign(socket, :_alert_count_tick, count)}
|
{:cont,
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(:_alert_count_tick, count)
|
||||||
|
|> Phoenix.Component.assign(:unresolved_alert_count, count)}
|
||||||
|
|
||||||
_message, socket ->
|
_message, socket ->
|
||||||
{:cont, socket}
|
{:cont, socket}
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,9 @@ defmodule SnmpKit.SnmpLib.ErrorHandlerTest do
|
||||||
# Circuit should be open
|
# Circuit should be open
|
||||||
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
|
assert {:error, :circuit_open} = ErrorHandler.call_through_breaker(breaker, failing_fun)
|
||||||
|
|
||||||
# Wait for recovery timeout
|
# Wait for recovery timeout using a message-based timer
|
||||||
Process.sleep(110)
|
Process.send_after(self(), :recovery_elapsed, 110)
|
||||||
|
assert_receive :recovery_elapsed, 1000
|
||||||
|
|
||||||
# Should allow limited calls in half-open state
|
# Should allow limited calls in half-open state
|
||||||
success_fun = fn -> {:ok, :recovered} end
|
success_fun = fn -> {:ok, :recovered} end
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
|
||||||
|
|
||||||
alias Towerops.ApiTokens.ApiToken
|
alias Towerops.ApiTokens.ApiToken
|
||||||
|
|
||||||
describe "changeset/2" do
|
describe "create_changeset/4 and update_changeset/2" do
|
||||||
setup do
|
setup do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
organization = organization_fixture(user.id)
|
organization = organization_fixture(user.id)
|
||||||
|
|
@ -18,136 +18,124 @@ defmodule Towerops.ApiTokens.ApiTokenTest do
|
||||||
organization: organization
|
organization: organization
|
||||||
} do
|
} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
user_id: user.id,
|
|
||||||
name: "Test Token",
|
name: "Test Token",
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid changeset without user_id", %{organization: organization} do
|
test "valid changeset without user_id", %{organization: organization, user: _user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "Test Token",
|
name: "Test Token",
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, nil, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid changeset with expires_at", %{organization: organization} do
|
test "valid changeset with expires_at", %{organization: organization, user: user} do
|
||||||
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
|
expires_at = DateTime.add(DateTime.utc_now(), 30, :day)
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "Test Token",
|
name: "Test Token",
|
||||||
token_hash: "abc123",
|
token_hash: "abc123",
|
||||||
expires_at: expires_at
|
expires_at: expires_at
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "valid changeset with last_used_at", %{organization: organization} do
|
test "valid changeset with last_used_at", %{organization: organization, user: user} do
|
||||||
last_used_at = DateTime.utc_now()
|
last_used_at = DateTime.utc_now()
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "Test Token",
|
name: "Test Token",
|
||||||
token_hash: "abc123",
|
token_hash: "abc123",
|
||||||
last_used_at: last_used_at
|
last_used_at: last_used_at
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset without organization_id", %{user: user} do
|
test "invalid changeset without organization_id", %{user: user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
user_id: user.id,
|
|
||||||
name: "Test Token",
|
name: "Test Token",
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, nil, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert "can't be blank" in errors_on(changeset).organization_id
|
assert "can't be blank" in errors_on(changeset).organization_id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset without name", %{organization: organization} do
|
test "invalid changeset without name", %{organization: organization, user: user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert "can't be blank" in errors_on(changeset).name
|
assert "can't be blank" in errors_on(changeset).name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset without token_hash", %{organization: organization} do
|
test "invalid changeset without token_hash", %{organization: organization, user: user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "Test Token"
|
name: "Test Token"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert "can't be blank" in errors_on(changeset).token_hash
|
assert "can't be blank" in errors_on(changeset).token_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset with blank name", %{organization: organization} do
|
test "invalid changeset with blank name", %{organization: organization, user: user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "",
|
name: "",
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert "can't be blank" in errors_on(changeset).name
|
assert "can't be blank" in errors_on(changeset).name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset with name too short", %{organization: organization} do
|
test "invalid changeset with name too short", %{organization: organization, user: user} do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: "",
|
name: "",
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "invalid changeset with name too long", %{organization: organization} do
|
test "invalid changeset with name too long", %{organization: organization, user: user} do
|
||||||
long_name = String.duplicate("a", 256)
|
long_name = String.duplicate("a", 256)
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: long_name,
|
name: long_name,
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
refute changeset.valid?
|
refute changeset.valid?
|
||||||
assert "should be at most 255 character(s)" in errors_on(changeset).name
|
assert "should be at most 255 character(s)" in errors_on(changeset).name
|
||||||
end
|
end
|
||||||
|
|
||||||
test "changeset with maximum valid name length", %{organization: organization} do
|
test "changeset with maximum valid name length", %{organization: organization, user: user} do
|
||||||
max_name = String.duplicate("a", 255)
|
max_name = String.duplicate("a", 255)
|
||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
organization_id: organization.id,
|
|
||||||
name: max_name,
|
name: max_name,
|
||||||
token_hash: "abc123"
|
token_hash: "abc123"
|
||||||
}
|
}
|
||||||
|
|
||||||
changeset = ApiToken.changeset(%ApiToken{}, attrs)
|
changeset = ApiToken.create_changeset(%ApiToken{}, organization.id, user.id, attrs)
|
||||||
assert changeset.valid?
|
assert changeset.valid?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,90 @@ defmodule Towerops.CapacityTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "get_utilization_for_interfaces/1" do
|
||||||
|
test "returns empty map when given empty list" do
|
||||||
|
assert Capacity.get_utilization_for_interfaces([]) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates utilization for multiple interfaces in one batch", %{
|
||||||
|
snmp_device: snmp_device,
|
||||||
|
interface: interface
|
||||||
|
} do
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
insert_interface_stat(interface.id, %{
|
||||||
|
if_in_octets: 0,
|
||||||
|
if_out_octets: 0,
|
||||||
|
checked_at: DateTime.add(now, -120, :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
insert_interface_stat(interface.id, %{
|
||||||
|
if_in_octets: 0,
|
||||||
|
if_out_octets: 1_125_000_000,
|
||||||
|
checked_at: DateTime.add(now, -60, :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Second interface
|
||||||
|
interface2 =
|
||||||
|
%Interface{}
|
||||||
|
|> Interface.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
if_index: 2,
|
||||||
|
if_name: "eth1",
|
||||||
|
if_speed: 1_000_000_000,
|
||||||
|
if_type: 6,
|
||||||
|
configured_capacity_bps: 500_000_000,
|
||||||
|
capacity_source: "manual"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
insert_interface_stat(interface2.id, %{
|
||||||
|
if_in_octets: 0,
|
||||||
|
if_out_octets: 0,
|
||||||
|
checked_at: DateTime.add(now, -120, :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 500 Mbps capacity, sending at ~250 Mbps = 50% utilization
|
||||||
|
insert_interface_stat(interface2.id, %{
|
||||||
|
if_in_octets: 0,
|
||||||
|
if_out_octets: 1_875_000_000,
|
||||||
|
checked_at: DateTime.add(now, -60, :second)
|
||||||
|
})
|
||||||
|
|
||||||
|
result = Capacity.get_utilization_for_interfaces([interface, interface2])
|
||||||
|
|
||||||
|
assert %{utilization_pct: pct1} = result[interface.id]
|
||||||
|
assert_in_delta pct1, 50.0, 1.0
|
||||||
|
|
||||||
|
assert %{utilization_pct: pct2} = result[interface2.id]
|
||||||
|
assert_in_delta pct2, 50.0, 1.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "excludes interfaces without configured capacity", %{
|
||||||
|
snmp_device: snmp_device,
|
||||||
|
interface: interface
|
||||||
|
} do
|
||||||
|
no_cap_interface =
|
||||||
|
%Interface{}
|
||||||
|
|> Interface.changeset(%{
|
||||||
|
snmp_device_id: snmp_device.id,
|
||||||
|
if_index: 3,
|
||||||
|
if_name: "no-cap"
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
result = Capacity.get_utilization_for_interfaces([no_cap_interface, interface])
|
||||||
|
|
||||||
|
refute Map.has_key?(result, no_cap_interface.id)
|
||||||
|
assert Map.has_key?(result, interface.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map when no interfaces have capacity" do
|
||||||
|
assert Capacity.get_utilization_for_interfaces([%{id: "1", configured_capacity_bps: nil}]) == %{}
|
||||||
|
assert Capacity.get_utilization_for_interfaces([%{id: "2", configured_capacity_bps: 0}]) == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "get_site_capacity_summary/1" do
|
describe "get_site_capacity_summary/1" do
|
||||||
test "aggregates capacity across interfaces at a site", %{
|
test "aggregates capacity across interfaces at a site", %{
|
||||||
site: site,
|
site: site,
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,18 @@ defmodule Towerops.EquipmentTest do
|
||||||
}
|
}
|
||||||
@invalid_attrs %{name: nil, ip_address: nil}
|
@invalid_attrs %{name: nil, ip_address: nil}
|
||||||
|
|
||||||
test "list_site_devices/1 returns all devices for a site", %{organization: organization, site: site} do
|
test "list_site_devices/1 returns all devices for a site with preloaded associations", %{
|
||||||
|
organization: organization,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
{:ok, device} =
|
{:ok, device} =
|
||||||
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
|
Devices.create_device(Map.merge(@valid_attrs, %{site_id: site.id, organization_id: organization.id}))
|
||||||
|
|
||||||
assert Devices.list_site_devices(site.id) == [device]
|
result = Devices.list_site_devices(site.id)
|
||||||
|
assert length(result) == 1
|
||||||
|
assert hd(result).id == device.id
|
||||||
|
assert hd(result).site.id == site.id
|
||||||
|
assert hd(result).organization.id == organization.id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "list_organization_devices/1 returns all devices for an organization", %{
|
test "list_organization_devices/1 returns all devices for an organization", %{
|
||||||
|
|
|
||||||
|
|
@ -191,10 +191,21 @@ defmodule Towerops.OrganizationsTest do
|
||||||
assert "can't be blank" in errors_on(changeset).organization_id
|
assert "can't be blank" in errors_on(changeset).organization_id
|
||||||
end
|
end
|
||||||
|
|
||||||
test "update_membership/2 changes user role", %{user: user, organization: organization} do
|
test "update_member_role/3 changes user role", %{organization: organization} do
|
||||||
membership = Organizations.get_membership!(organization.id, user.id)
|
new_user = user_fixture()
|
||||||
|
|
||||||
|
{:ok, membership} =
|
||||||
|
Organizations.create_membership(%{
|
||||||
|
organization_id: organization.id,
|
||||||
|
user_id: new_user.id,
|
||||||
|
role: :member
|
||||||
|
})
|
||||||
|
|
||||||
|
assert membership.role == :member
|
||||||
|
|
||||||
|
assert {:ok, updated} =
|
||||||
|
Organizations.update_member_role(organization.id, new_user.id, :admin)
|
||||||
|
|
||||||
assert {:ok, updated} = Organizations.update_membership(membership, %{role: :admin})
|
|
||||||
assert updated.role == :admin
|
assert updated.role == :admin
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -407,8 +418,15 @@ defmodule Towerops.OrganizationsTest do
|
||||||
# Owner can delete organization
|
# Owner can delete organization
|
||||||
assert Organizations.can?(membership, :delete, :organization)
|
assert Organizations.can?(membership, :delete, :organization)
|
||||||
|
|
||||||
# Change to member role
|
# Create a separate member-level membership
|
||||||
{:ok, member_membership} = Organizations.update_membership(membership, %{role: :member})
|
member_user = user_fixture()
|
||||||
|
|
||||||
|
{:ok, member_membership} =
|
||||||
|
Organizations.create_membership(%{
|
||||||
|
organization_id: membership.organization_id,
|
||||||
|
user_id: member_user.id,
|
||||||
|
role: :member
|
||||||
|
})
|
||||||
|
|
||||||
# Member cannot delete organization
|
# Member cannot delete organization
|
||||||
refute Organizations.can?(member_membership, :delete, :organization)
|
refute Organizations.can?(member_membership, :delete, :organization)
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,11 @@ defmodule Towerops.RedisHealthCheckTest do
|
||||||
# Schedule recovery after a short delay — the first attempt will fail,
|
# Schedule recovery after a short delay — the first attempt will fail,
|
||||||
# then we restore the healthy state before the second attempt.
|
# then we restore the healthy state before the second attempt.
|
||||||
Task.async(fn ->
|
Task.async(fn ->
|
||||||
Process.sleep(5)
|
receive do
|
||||||
|
after
|
||||||
|
5 -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
Fake.set_ping_response(Towerops.Redix, :ok)
|
Fake.set_ping_response(Towerops.Redix, :ok)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||||
DeferredDiscovery.fast_check(
|
DeferredDiscovery.fast_check(
|
||||||
client_opts,
|
client_opts,
|
||||||
fn ->
|
fn ->
|
||||||
Process.sleep(30)
|
receive do
|
||||||
{:ok, "too slow"}
|
:never -> {:ok, "too slow"}
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
timeout: 25
|
timeout: 25
|
||||||
)
|
)
|
||||||
|
|
@ -131,8 +132,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||||
DeferredDiscovery.deferred_check(
|
DeferredDiscovery.deferred_check(
|
||||||
client_opts,
|
client_opts,
|
||||||
fn ->
|
fn ->
|
||||||
Process.sleep(30)
|
receive do
|
||||||
{:ok, "too slow"}
|
:never -> {:ok, "too slow"}
|
||||||
|
end
|
||||||
end,
|
end,
|
||||||
timeout: 25,
|
timeout: 25,
|
||||||
name: "neighbors"
|
name: "neighbors"
|
||||||
|
|
@ -177,8 +179,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||||
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
|
{:fast_check, fn -> {:ok, "fast"} end, [timeout: 1000]},
|
||||||
{:slow_check,
|
{:slow_check,
|
||||||
fn ->
|
fn ->
|
||||||
Process.sleep(30)
|
receive do
|
||||||
{:ok, "slow"}
|
:never -> {:ok, "slow"}
|
||||||
|
end
|
||||||
end, [timeout: 25, default: []]}
|
end, [timeout: 25, default: []]}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -229,8 +232,9 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||||
|
|
||||||
test "returns false when check times out" do
|
test "returns false when check times out" do
|
||||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||||
Process.sleep(30)
|
receive do
|
||||||
{:ok, [1, 3, 6, 1, 4, 1, 9]}
|
:never -> {:ok, [1, 3, 6, 1, 4, 1, 9]}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||||
|
|
@ -252,9 +256,11 @@ defmodule Towerops.Snmp.DeferredDiscoveryTest do
|
||||||
|
|
||||||
test "returns :slow for delayed responses" do
|
test "returns :slow for delayed responses" do
|
||||||
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
expect(SnmpMock, :get, fn _target, _oid, _opts ->
|
||||||
# Sleep just past the threshold to be classified as slow
|
# Delay just past the threshold to be classified as slow
|
||||||
Process.sleep(35)
|
receive do
|
||||||
{:ok, "Slow Device"}
|
after
|
||||||
|
35 -> {:ok, "Slow Device"}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
client_opts = [ip: "192.168.1.1", community: "public", version: "2c"]
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,10 @@ defmodule Towerops.Snmp.PollerTest do
|
||||||
|
|
||||||
# Simulate a delayed response
|
# Simulate a delayed response
|
||||||
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
expect(SnmpMock, :get, fn _, "1.3.6.1.2.1.1.3.0", _ ->
|
||||||
Process.sleep(1)
|
receive do
|
||||||
{:ok, {:timeticks, 999}}
|
after
|
||||||
|
1 -> {:ok, {:timeticks, 999}}
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
assert {:ok, response_time} = Poller.check_device(client_opts)
|
assert {:ok, response_time} = Poller.check_device(client_opts)
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ defmodule ToweropsWeb.MobileQRLiveTest do
|
||||||
assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token)
|
assert Towerops.MobileSessions.check_qr_login_completed(qr_token.token)
|
||||||
|
|
||||||
send(view.pid, :check_completion)
|
send(view.pid, :check_completion)
|
||||||
Process.sleep(50)
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
||||||
# The template shows the QR page until @completed switches
|
# The template shows the QR page until @completed switches
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue