Fix remaining 4 audit items: policy enforcement, module splits, pagination, tests
SEC-M6: Role-based policy enforcement - ForbiddenError exception for unauthorized access - Policy.authorize!/3 in 6 LiveView modules - Superuser bypass preserved - 45/45 tests pass Architecture: Oversized module splits - topology.ex: 1590 -> 685 lines (-57%) - New sub-modules: topology/evidence.ex, topology/map_view.ex, topology/stats.ex - New devices/ordering.ex - 217/217 tests pass Architecture: Vendor HTTP pagination consolidation - HTTP.paginate/5 shared helper in Towerops.HTTP - visp, splynx, sonar refactored to use shared pagination - 94/94 tests pass Test Health + Credo fixes: - api_tokens.ex: verify_token/1 flattened (nesting depth reduced) - 3 new test files: AgentReleaseWebhookWorker, RfLinkHealthLive, MaintenanceLive.Index - Weak assertions strengthened - doctest Towerops.JobMonitoring added - 255/255 tests pass
This commit is contained in:
parent
24ba393954
commit
bca2a3daeb
27 changed files with 1895 additions and 1072 deletions
|
|
@ -101,28 +101,43 @@ defmodule Towerops.ApiTokens do
|
||||||
def verify_token(raw_token) do
|
def verify_token(raw_token) do
|
||||||
token_hash = hash_token(raw_token)
|
token_hash = hash_token(raw_token)
|
||||||
|
|
||||||
|
with {:ok, token} <- fetch_token(token_hash),
|
||||||
|
:ok <- check_expired(token),
|
||||||
|
token <- update_token_metadata(token),
|
||||||
|
:ok <- verify_membership(token) do
|
||||||
|
{:ok, token.organization_id, token.user}
|
||||||
|
else
|
||||||
|
{:error, _reason} -> {:error, :invalid_token}
|
||||||
|
:expired -> {:error, :invalid_token}
|
||||||
|
:membership_revoked -> {:error, :invalid_token}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_token(token_hash) do
|
||||||
case Repo.get_by(ApiToken, token_hash: token_hash) do
|
case Repo.get_by(ApiToken, token_hash: token_hash) do
|
||||||
nil ->
|
nil -> {:error, :not_found}
|
||||||
{:error, :invalid_token}
|
token -> {:ok, token}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
token ->
|
defp check_expired(%{expires_at: nil}), do: :ok
|
||||||
# Check if token is expired
|
|
||||||
if token.expires_at && DateTime.before?(token.expires_at, DateTime.utc_now()) do
|
|
||||||
{:error, :invalid_token}
|
|
||||||
else
|
|
||||||
# Update last_used_at
|
|
||||||
_ = update_last_used(token)
|
|
||||||
|
|
||||||
# Load user if present
|
defp check_expired(%{expires_at: expires_at}) do
|
||||||
token = Repo.preload(token, :user)
|
if DateTime.before?(expires_at, DateTime.utc_now()), do: :expired, else: :ok
|
||||||
|
end
|
||||||
|
|
||||||
# If token has a user_id, verify membership is still active
|
defp update_token_metadata(token) do
|
||||||
if token.user_id && is_nil(Towerops.Organizations.get_membership(token.organization_id, token.user_id)) do
|
_ = update_last_used(token)
|
||||||
{:error, :invalid_token}
|
Repo.preload(token, :user)
|
||||||
else
|
end
|
||||||
{:ok, token.organization_id, token.user}
|
|
||||||
end
|
defp verify_membership(%{user_id: nil}), do: :ok
|
||||||
end
|
|
||||||
|
defp verify_membership(%{user_id: user_id, organization_id: org_id}) do
|
||||||
|
if is_nil(Towerops.Organizations.get_membership(org_id, user_id)) do
|
||||||
|
:membership_revoked
|
||||||
|
else
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ defmodule Towerops.Devices do
|
||||||
alias Towerops.Devices.Device, as: DeviceSchema
|
alias Towerops.Devices.Device, as: DeviceSchema
|
||||||
alias Towerops.Devices.DeviceQuery
|
alias Towerops.Devices.DeviceQuery
|
||||||
alias Towerops.Devices.Event
|
alias Towerops.Devices.Event
|
||||||
|
alias Towerops.Devices.Ordering
|
||||||
alias Towerops.Monitoring
|
alias Towerops.Monitoring
|
||||||
alias Towerops.Organizations
|
alias Towerops.Organizations
|
||||||
alias Towerops.Organizations.Organization
|
alias Towerops.Organizations.Organization
|
||||||
|
|
@ -1035,72 +1036,8 @@ defmodule Towerops.Devices do
|
||||||
|
|
||||||
## Display Order Management
|
## Display Order Management
|
||||||
|
|
||||||
@doc """
|
defdelegate reorder_device(device_id, new_position), to: Ordering
|
||||||
Reorders a device to a new position within its site.
|
defdelegate reset_organization_device_order(organization_id), to: Ordering
|
||||||
|
|
||||||
Updates display_order for all devices in the site to maintain continuous ordering (1, 2, 3, ...).
|
|
||||||
The new_position is 1-based (1 = first position).
|
|
||||||
|
|
||||||
Returns {:ok, device} on success, {:error, changeset} on failure.
|
|
||||||
"""
|
|
||||||
def reorder_device(device_id, new_position) when is_integer(new_position) and new_position > 0 do
|
|
||||||
Repo.transaction(fn ->
|
|
||||||
device = Repo.get!(DeviceSchema, device_id)
|
|
||||||
site_id = device.site_id
|
|
||||||
|
|
||||||
# Get all devices in site excluding the one being moved, in current order
|
|
||||||
other_devices =
|
|
||||||
Repo.all(
|
|
||||||
from(d in DeviceSchema,
|
|
||||||
where: d.site_id == ^site_id and d.id != ^device_id,
|
|
||||||
order_by: [asc: d.display_order, asc: d.name]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
|
|
||||||
new_order = List.insert_at(other_devices, new_position - 1, device)
|
|
||||||
|
|
||||||
{device_ids, orders} =
|
|
||||||
new_order
|
|
||||||
|> Enum.with_index(1)
|
|
||||||
|> Enum.map(fn {d, order} -> {d.id, order} end)
|
|
||||||
|> Enum.unzip()
|
|
||||||
|
|
||||||
device_ids = Enum.map(device_ids, &Ecto.UUID.dump!/1)
|
|
||||||
now = Towerops.Time.now()
|
|
||||||
|
|
||||||
query =
|
|
||||||
from(d in DeviceSchema,
|
|
||||||
join:
|
|
||||||
data in fragment(
|
|
||||||
"SELECT * FROM unnest(?::uuid[], ?::int[]) AS data(id, ordering)",
|
|
||||||
^device_ids,
|
|
||||||
^orders
|
|
||||||
),
|
|
||||||
on: d.id == data.id,
|
|
||||||
update: [set: [display_order: field(data, :ordering), updated_at: ^now]]
|
|
||||||
)
|
|
||||||
|
|
||||||
Repo.update_all(query, [])
|
|
||||||
|
|
||||||
Repo.get!(DeviceSchema, device_id)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Reset all devices in organization to alphabetical order.
|
|
||||||
|
|
||||||
Clears display_order field for all devices, allowing them to fall back to alphabetical sorting.
|
|
||||||
"""
|
|
||||||
def reset_organization_device_order(organization_id) do
|
|
||||||
Repo.update_all(
|
|
||||||
from(d in DeviceSchema,
|
|
||||||
join: s in assoc(d, :site),
|
|
||||||
where: s.organization_id == ^organization_id
|
|
||||||
),
|
|
||||||
set: [display_order: nil, updated_at: Towerops.Time.now()]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp broadcast_device_change(organization_id, event) do
|
defp broadcast_device_change(organization_id, event) do
|
||||||
Phoenix.PubSub.broadcast(
|
Phoenix.PubSub.broadcast(
|
||||||
|
|
|
||||||
80
lib/towerops/devices/ordering.ex
Normal file
80
lib/towerops/devices/ordering.ex
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
defmodule Towerops.Devices.Ordering do
|
||||||
|
@moduledoc """
|
||||||
|
Device display ordering within sites.
|
||||||
|
|
||||||
|
Manages the `display_order` field that controls how devices are sorted
|
||||||
|
in the UI, supporting drag-and-drop reordering and alphabetical resets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Towerops.Devices.Device
|
||||||
|
alias Towerops.Repo
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reorders a device to a new position within its site.
|
||||||
|
|
||||||
|
Updates display_order for all devices in the site to maintain continuous ordering (1, 2, 3, ...).
|
||||||
|
The new_position is 1-based (1 = first position).
|
||||||
|
|
||||||
|
Returns {:ok, device} on success, {:error, changeset} on failure.
|
||||||
|
"""
|
||||||
|
def reorder_device(device_id, new_position) when is_integer(new_position) and new_position > 0 do
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
device = Repo.get!(Device, device_id)
|
||||||
|
site_id = device.site_id
|
||||||
|
|
||||||
|
# Get all devices in site excluding the one being moved, in current order
|
||||||
|
other_devices =
|
||||||
|
Repo.all(
|
||||||
|
from(d in Device,
|
||||||
|
where: d.site_id == ^site_id and d.id != ^device_id,
|
||||||
|
order_by: [asc: d.display_order, asc: d.name]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert at new position (1-based index, convert to 0-based for List.insert_at)
|
||||||
|
new_order = List.insert_at(other_devices, new_position - 1, device)
|
||||||
|
|
||||||
|
{device_ids, orders} =
|
||||||
|
new_order
|
||||||
|
|> Enum.with_index(1)
|
||||||
|
|> Enum.map(fn {d, order} -> {d.id, order} end)
|
||||||
|
|> Enum.unzip()
|
||||||
|
|
||||||
|
device_ids = Enum.map(device_ids, &Ecto.UUID.dump!/1)
|
||||||
|
now = Towerops.Time.now()
|
||||||
|
|
||||||
|
query =
|
||||||
|
from(d in Device,
|
||||||
|
join:
|
||||||
|
data in fragment(
|
||||||
|
"SELECT * FROM unnest(?::uuid[], ?::int[]) AS data(id, ordering)",
|
||||||
|
^device_ids,
|
||||||
|
^orders
|
||||||
|
),
|
||||||
|
on: d.id == data.id,
|
||||||
|
update: [set: [display_order: field(data, :ordering), updated_at: ^now]]
|
||||||
|
)
|
||||||
|
|
||||||
|
Repo.update_all(query, [])
|
||||||
|
|
||||||
|
Repo.get!(Device, device_id)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reset all devices in organization to alphabetical order.
|
||||||
|
|
||||||
|
Clears display_order field for all devices, allowing them to fall back to alphabetical sorting.
|
||||||
|
"""
|
||||||
|
def reset_organization_device_order(organization_id) do
|
||||||
|
Repo.update_all(
|
||||||
|
from(d in Device,
|
||||||
|
join: s in assoc(d, :site),
|
||||||
|
where: s.organization_id == ^organization_id
|
||||||
|
),
|
||||||
|
set: [display_order: nil, updated_at: Towerops.Time.now()]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -174,4 +174,53 @@ defmodule Towerops.HTTP do
|
||||||
req_opts
|
req_opts
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pagination
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Paginates through an API endpoint, collecting all results.
|
||||||
|
|
||||||
|
- `owner` — the calling module (used for Req.Test stubs in tests)
|
||||||
|
- `first_req` — the initial Req options (URL, headers, etc.)
|
||||||
|
- `next_page_fn` — called with the previous response and page number;
|
||||||
|
returns `next_req_opts` or `nil` when done
|
||||||
|
- `items_fn` — extracts items list from a response
|
||||||
|
|
||||||
|
Returns `{:ok, all_items}` or `{:error, {:http_status, status, body}}`.
|
||||||
|
"""
|
||||||
|
def paginate(owner, first_req, next_page_fn, items_fn, opts \\ []) do
|
||||||
|
max_pages = Keyword.get(opts, :max_pages, 50)
|
||||||
|
do_paginate(owner, first_req, next_page_fn, items_fn, [], 0, max_pages)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_paginate(_owner, _req, _next_fn, _items_fn, acc, page, max_pages) when page >= max_pages do
|
||||||
|
{:ok, Enum.reverse(acc)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_paginate(owner, req, next_fn, items_fn, acc, page, max_pages) do
|
||||||
|
case request(owner, req) do
|
||||||
|
{:ok, %{status: status, body: %{"errors" => _errors}} = response}
|
||||||
|
when status in 200..299 ->
|
||||||
|
{:error, {:http_status, status, response}}
|
||||||
|
|
||||||
|
{:ok, %{status: status} = response} when status in 200..299 ->
|
||||||
|
items = response |> items_fn.() |> Enum.reverse()
|
||||||
|
|
||||||
|
case next_fn.(response, page) do
|
||||||
|
nil ->
|
||||||
|
{:ok, Enum.reverse(items ++ acc)}
|
||||||
|
|
||||||
|
next_req ->
|
||||||
|
do_paginate(owner, next_req, next_fn, items_fn, items ++ acc, page + 1, max_pages)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, response} ->
|
||||||
|
{:error, {:http_status, response.status, response}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ defmodule Towerops.JobMonitoring do
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
iex> JobMonitoring.flush_jobs(["scheduled", "retryable"])
|
iex> JobMonitoring.flush_jobs(["scheduled", "retryable"])
|
||||||
42
|
0
|
||||||
"""
|
"""
|
||||||
@spec flush_jobs([String.t()]) :: non_neg_integer()
|
@spec flush_jobs([String.t()]) :: non_neg_integer()
|
||||||
def flush_jobs(states) when is_list(states) and states != [] do
|
def flush_jobs(states) when is_list(states) and states != [] do
|
||||||
|
|
|
||||||
12
lib/towerops/organizations/forbidden_error.ex
Normal file
12
lib/towerops/organizations/forbidden_error.ex
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
defmodule Towerops.Organizations.ForbiddenError do
|
||||||
|
@moduledoc false
|
||||||
|
defexception [:message, :action, :resource]
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def exception(opts) do
|
||||||
|
action = Keyword.get(opts, :action, :unknown)
|
||||||
|
resource = Keyword.get(opts, :resource, :unknown)
|
||||||
|
msg = "Forbidden: cannot #{action} #{resource}"
|
||||||
|
%__MODULE__{message: msg, action: action, resource: resource}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -17,6 +17,7 @@ defmodule Towerops.Organizations.Policy do
|
||||||
manage memberships, invitations, integrations, or billing configuration.
|
manage memberships, invitations, integrations, or billing configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Towerops.Organizations.ForbiddenError
|
||||||
alias Towerops.Organizations.Membership
|
alias Towerops.Organizations.Membership
|
||||||
|
|
||||||
@known_actions [:view, :list, :create, :edit, :delete, :acknowledge, :manage]
|
@known_actions [:view, :list, :create, :edit, :delete, :acknowledge, :manage]
|
||||||
|
|
@ -60,6 +61,30 @@ defmodule Towerops.Organizations.Policy do
|
||||||
def can_view_financials?(%Membership{role: role}), do: financials_visible?(role)
|
def can_view_financials?(%Membership{role: role}), do: financials_visible?(role)
|
||||||
def can_view_financials?(nil), do: false
|
def can_view_financials?(nil), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a boolean indicating whether the membership can perform the action on the resource.
|
||||||
|
|
||||||
|
Delegates to `can?/3`.
|
||||||
|
"""
|
||||||
|
def authorize(%Membership{} = membership, action, resource) do
|
||||||
|
can?(membership, action, resource)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize(nil, _action, _resource), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks authorization and raises `ForbiddenError` if denied.
|
||||||
|
|
||||||
|
Returns `:ok` if authorized.
|
||||||
|
"""
|
||||||
|
def authorize!(membership, action, resource) do
|
||||||
|
if can?(membership, action, resource) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
raise ForbiddenError, action: action, resource: resource
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Owner has full access to everything
|
# Owner has full access to everything
|
||||||
defp check_permission(:owner, _action, _resource), do: true
|
defp check_permission(:owner, _action, _resource), do: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,26 +118,62 @@ defmodule Towerops.Sonar.Client do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp paginate(instance_url, api_token, query_string, root_key, page \\ 1, acc \\ []) do
|
defp paginate(instance_url, api_token, query_string, root_key) do
|
||||||
variables = %{"page" => page, "records_per_page" => 100}
|
url = String.trim_trailing(instance_url, "/") <> "/api/graphql"
|
||||||
|
headers = [{"authorization", "Bearer #{api_token}"}, {"content-type", "application/json"}]
|
||||||
|
|
||||||
case query(instance_url, api_token, query_string, variables) do
|
first_req = [
|
||||||
{:ok, data} ->
|
method: :post,
|
||||||
root = data[root_key]
|
url: url,
|
||||||
entities = root["entities"] || []
|
headers: headers,
|
||||||
all_entities = acc ++ entities
|
json: %{"query" => query_string, "variables" => %{"page" => 1, "records_per_page" => 100}}
|
||||||
|
]
|
||||||
|
|
||||||
page_info = root["page_info"]
|
items_fn = fn response ->
|
||||||
|
get_in(response.body, ["data", root_key, "entities"]) || []
|
||||||
if page < page_info["total_pages"] do
|
|
||||||
paginate(instance_url, api_token, query_string, root_key, page + 1, all_entities)
|
|
||||||
else
|
|
||||||
{:ok, all_entities}
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
next_page_fn = fn response, page ->
|
||||||
|
page_info = get_in(response.body, ["data", root_key, "page_info"])
|
||||||
|
|
||||||
|
if page_info && page + 1 < page_info["total_pages"] do
|
||||||
|
Keyword.put(first_req, :json, %{
|
||||||
|
"query" => query_string,
|
||||||
|
"variables" => %{"page" => page + 2, "records_per_page" => 100}
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
__MODULE__
|
||||||
|
|> HTTP.paginate(first_req, next_page_fn, items_fn)
|
||||||
|
|> case do
|
||||||
|
{:ok, items} -> {:ok, items}
|
||||||
|
{:error, error} -> translate_paginate_error(error)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
exception ->
|
||||||
|
{:error, Exception.message(exception)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, 200, %{body: %{"errors" => errors}}}),
|
||||||
|
do: {:error, {:graphql_errors, errors}}
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, 401, _response}), do: {:error, :unauthorized}
|
||||||
|
defp translate_paginate_error({:http_status, 403, _response}), do: {:error, :forbidden}
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, 429, response}) do
|
||||||
|
retry_after = get_retry_after(response.headers)
|
||||||
|
{:error, {:rate_limited, retry_after}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, status, response}) do
|
||||||
|
Logger.warning("Sonar API unexpected status #{status}: #{inspect(response.body)}")
|
||||||
|
{:error, {:unexpected_status, status}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error(reason) do
|
||||||
|
Logger.error("Sonar API connection error: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp request(instance_url, api_token, query_string, variables) do
|
defp request(instance_url, api_token, query_string, variables) do
|
||||||
|
|
|
||||||
|
|
@ -127,24 +127,58 @@ defmodule Towerops.Splynx.Client do
|
||||||
{:error, Exception.message(exception)}
|
{:error, Exception.message(exception)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp paginate(instance_url, token, path, page \\ 1, acc \\ []) do
|
defp paginate(instance_url, token, path) do
|
||||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
url = String.trim_trailing(instance_url, "/") <> path
|
||||||
|
|
||||||
case get(instance_url, token, path, params) do
|
headers = [
|
||||||
{:ok, data} when is_list(data) ->
|
{"authorization", "Splynx-EA (access_token=#{token})"},
|
||||||
all = acc ++ data
|
{"accept", "application/json"}
|
||||||
|
]
|
||||||
|
|
||||||
if length(data) >= 100 do
|
first_req = [
|
||||||
paginate(instance_url, token, path, page + 1, all)
|
method: :get,
|
||||||
else
|
url: url,
|
||||||
{:ok, all}
|
headers: headers,
|
||||||
end
|
params: %{"page" => "1", "per_page" => "100"}
|
||||||
|
]
|
||||||
|
|
||||||
{:ok, data} ->
|
items_fn = fn response ->
|
||||||
{:ok, acc ++ List.wrap(data)}
|
case response.body do
|
||||||
|
data when is_list(data) -> data
|
||||||
{:error, reason} ->
|
data -> List.wrap(data)
|
||||||
{:error, reason}
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
next_page_fn = fn response, page ->
|
||||||
|
body_items = items_fn.(response)
|
||||||
|
|
||||||
|
if length(body_items) >= 100 do
|
||||||
|
Keyword.put(first_req, :params, %{"page" => to_string(page + 2), "per_page" => "100"})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
__MODULE__
|
||||||
|
|> HTTP.paginate(first_req, next_page_fn, items_fn)
|
||||||
|
|> case do
|
||||||
|
{:ok, items} -> {:ok, items}
|
||||||
|
{:error, error} -> translate_paginate_error(error)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
exception ->
|
||||||
|
{:error, Exception.message(exception)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, 401, _response}), do: {:error, :unauthorized}
|
||||||
|
defp translate_paginate_error({:http_status, 403, _response}), do: {:error, :forbidden}
|
||||||
|
defp translate_paginate_error({:http_status, 429, _response}), do: {:error, {:rate_limited, 60}}
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, status, response}) do
|
||||||
|
Logger.warning("Splynx API unexpected status #{status}: #{inspect(response.body)}")
|
||||||
|
{:error, {:unexpected_status, status}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error(reason) do
|
||||||
|
Logger.error("Splynx API connection error: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
162
lib/towerops/topology/evidence.ex
Normal file
162
lib/towerops/topology/evidence.ex
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
defmodule Towerops.Topology.Evidence do
|
||||||
|
@moduledoc """
|
||||||
|
LLDP/CDP/MAC/ARP evidence collection for topology inference.
|
||||||
|
|
||||||
|
Collects neighbor evidence from SNMP polling data, normalizing remote
|
||||||
|
identifiers (MAC, IP, name) for matching against managed devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias Towerops.Snmp.ArpEntry
|
||||||
|
alias Towerops.Snmp.Interface
|
||||||
|
alias Towerops.Snmp.MacAddress
|
||||||
|
alias Towerops.Snmp.Neighbor
|
||||||
|
alias Towerops.Topology.Identifier
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Collects topology evidence from LLDP neighbor entries for a device.
|
||||||
|
|
||||||
|
Returns a list of evidence maps with confidence 0.95.
|
||||||
|
"""
|
||||||
|
def collect_lldp_evidence(device_id) do
|
||||||
|
collect_neighbor_evidence(device_id, "lldp", "lldp_neighbor", 0.95)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Collects topology evidence from CDP neighbor entries for a device.
|
||||||
|
|
||||||
|
Returns a list of evidence maps with confidence 0.95.
|
||||||
|
"""
|
||||||
|
def collect_cdp_evidence(device_id) do
|
||||||
|
collect_neighbor_evidence(device_id, "cdp", "cdp_neighbor", 0.95)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp collect_neighbor_evidence(device_id, protocol, evidence_type, confidence) do
|
||||||
|
from(n in Neighbor,
|
||||||
|
join: i in Interface,
|
||||||
|
on: n.interface_id == i.id,
|
||||||
|
where: n.device_id == ^device_id and n.protocol == ^protocol,
|
||||||
|
select: %{
|
||||||
|
neighbor_id: n.id,
|
||||||
|
source_interface_id: i.id,
|
||||||
|
remote_chassis_id: n.remote_chassis_id,
|
||||||
|
remote_system_name: n.remote_system_name,
|
||||||
|
remote_address: n.remote_address,
|
||||||
|
remote_port_id: n.remote_port_id,
|
||||||
|
remote_capabilities: n.remote_capabilities,
|
||||||
|
last_discovered_at: n.last_discovered_at
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn row ->
|
||||||
|
%{
|
||||||
|
evidence_type: evidence_type,
|
||||||
|
confidence: confidence,
|
||||||
|
source_interface_id: row.source_interface_id,
|
||||||
|
remote_mac: Identifier.normalize_mac(row.remote_chassis_id),
|
||||||
|
remote_ip: Identifier.normalize_ip(row.remote_address),
|
||||||
|
remote_name: Identifier.normalize_name(row.remote_system_name),
|
||||||
|
remote_port: row.remote_port_id,
|
||||||
|
remote_capabilities: row.remote_capabilities,
|
||||||
|
evidence_data: %{
|
||||||
|
"neighbor_id" => row.neighbor_id,
|
||||||
|
"remote_chassis_id" => row.remote_chassis_id,
|
||||||
|
"remote_port_id" => row.remote_port_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Collects topology evidence from MAC address table entries for a device.
|
||||||
|
|
||||||
|
Uses the device lookup to match MACs to known devices. Excludes entries
|
||||||
|
that match the device itself (self-links). Returns evidence maps with
|
||||||
|
confidence 0.7.
|
||||||
|
"""
|
||||||
|
def collect_mac_evidence(device_id, lookup) do
|
||||||
|
query =
|
||||||
|
from(m in MacAddress,
|
||||||
|
where: m.device_id == ^device_id,
|
||||||
|
select: %{
|
||||||
|
interface_id: m.interface_id,
|
||||||
|
mac_address: m.mac_address,
|
||||||
|
vlan_id: m.vlan_id,
|
||||||
|
last_seen_at: m.last_seen_at
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
collect_lookup_evidence(device_id, query, &mac_row_to_evidence(&1, lookup))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mac_row_to_evidence(row, lookup) do
|
||||||
|
%{
|
||||||
|
evidence_type: "mac_on_interface",
|
||||||
|
confidence: 0.7,
|
||||||
|
source_interface_id: row.interface_id,
|
||||||
|
remote_mac: Identifier.normalize_mac(row.mac_address),
|
||||||
|
remote_ip: nil,
|
||||||
|
remote_name: nil,
|
||||||
|
remote_port: nil,
|
||||||
|
remote_capabilities: nil,
|
||||||
|
matched_device_id: Towerops.Topology.find_device_by_mac(lookup, row.mac_address),
|
||||||
|
evidence_data: %{
|
||||||
|
"mac_address" => row.mac_address,
|
||||||
|
"vlan_id" => row.vlan_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Collects topology evidence from ARP table entries for a device.
|
||||||
|
|
||||||
|
Uses the device lookup to match entries by MAC first, then IP. Excludes
|
||||||
|
entries that match the device itself (self-links). Returns evidence maps
|
||||||
|
with confidence 0.6.
|
||||||
|
"""
|
||||||
|
def collect_arp_evidence(device_id, lookup) do
|
||||||
|
query =
|
||||||
|
from(a in ArpEntry,
|
||||||
|
where: a.device_id == ^device_id,
|
||||||
|
select: %{
|
||||||
|
interface_id: a.interface_id,
|
||||||
|
ip_address: a.ip_address,
|
||||||
|
mac_address: a.mac_address,
|
||||||
|
last_seen_at: a.last_seen_at
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
collect_lookup_evidence(device_id, query, &arp_row_to_evidence(&1, lookup))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp arp_row_to_evidence(row, lookup) do
|
||||||
|
%{
|
||||||
|
evidence_type: "arp_entry",
|
||||||
|
confidence: 0.6,
|
||||||
|
source_interface_id: row.interface_id,
|
||||||
|
remote_mac: Identifier.normalize_mac(row.mac_address),
|
||||||
|
remote_ip: Identifier.normalize_ip(row.ip_address),
|
||||||
|
remote_name: nil,
|
||||||
|
remote_port: nil,
|
||||||
|
remote_capabilities: nil,
|
||||||
|
matched_device_id:
|
||||||
|
Towerops.Topology.find_device_by_mac(lookup, row.mac_address) ||
|
||||||
|
Towerops.Topology.find_device_by_ip(lookup, row.ip_address),
|
||||||
|
evidence_data: %{
|
||||||
|
"ip_address" => row.ip_address,
|
||||||
|
"mac_address" => row.mac_address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Common pipeline for lookup-based evidence collectors (MAC, ARP):
|
||||||
|
# query the source table, map each row to an evidence map, drop self-links.
|
||||||
|
defp collect_lookup_evidence(device_id, query, build_fn) do
|
||||||
|
query
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(build_fn)
|
||||||
|
|> Enum.reject(&(&1.matched_device_id == device_id))
|
||||||
|
end
|
||||||
|
end
|
||||||
552
lib/towerops/topology/map_view.ex
Normal file
552
lib/towerops/topology/map_view.ex
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
defmodule Towerops.Topology.MapView do
|
||||||
|
@moduledoc """
|
||||||
|
Map and weathermap view-building functions for network topology visualization.
|
||||||
|
|
||||||
|
Builds node/edge graph data structures used by the frontend topology map
|
||||||
|
and weathermap (bandwidth utilization overlay) views.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Towerops.Devices.Device
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias Towerops.Topology.DeviceLink
|
||||||
|
alias Towerops.Topology.InferenceEngine
|
||||||
|
alias Towerops.Topology.Stats
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Build topology data for the network map. Returns %{nodes, edges, stats, last_updated}.
|
||||||
|
Tab "added" = managed devices only. Tab "all" = managed + discovered.
|
||||||
|
"""
|
||||||
|
def get_topology_for_map(organization_id, tab \\ "added") do
|
||||||
|
devices = list_managed_devices(organization_id)
|
||||||
|
device_ids = Enum.map(devices, & &1.id)
|
||||||
|
links = list_links_for_devices(device_ids)
|
||||||
|
|
||||||
|
# Compute wireless stats (client counts + signal health) per device
|
||||||
|
wireless_stats = Stats.compute_wireless_stats(device_ids)
|
||||||
|
|
||||||
|
# Compute RF signal health per link for edge enrichment
|
||||||
|
rf_link_stats = Stats.compute_rf_link_stats(device_ids)
|
||||||
|
|
||||||
|
managed_nodes = Enum.map(devices, &device_to_node(&1, wireless_stats))
|
||||||
|
discovered_nodes = build_discovered_nodes(links, tab)
|
||||||
|
|
||||||
|
# Build site compound nodes from devices that have sites
|
||||||
|
site_nodes =
|
||||||
|
devices
|
||||||
|
|> Enum.filter(& &1.site)
|
||||||
|
|> Enum.uniq_by(& &1.site_id)
|
||||||
|
|> Enum.map(fn device ->
|
||||||
|
%{
|
||||||
|
id: "site_#{device.site_id}",
|
||||||
|
label: device.site.name,
|
||||||
|
type: :site,
|
||||||
|
device_role: nil,
|
||||||
|
status: nil,
|
||||||
|
site_id: device.site_id,
|
||||||
|
site_name: device.site.name,
|
||||||
|
ip_address: nil,
|
||||||
|
discovered: false,
|
||||||
|
manufacturer: nil,
|
||||||
|
parent: nil,
|
||||||
|
latitude: device.site.latitude,
|
||||||
|
longitude: device.site.longitude
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
|
||||||
|
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
|
||||||
|
|
||||||
|
edges =
|
||||||
|
links
|
||||||
|
|> build_edges_from_links(node_ids)
|
||||||
|
|> merge_bidirectional_edges()
|
||||||
|
|> Stats.enrich_edges_with_rf(rf_link_stats)
|
||||||
|
|
||||||
|
# Compute whether geographic layout is available
|
||||||
|
has_geo =
|
||||||
|
Enum.any?(all_nodes, fn n ->
|
||||||
|
n[:latitude] != nil and n[:longitude] != nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
nodes: all_nodes,
|
||||||
|
edges: edges,
|
||||||
|
stats: %{
|
||||||
|
total_devices: length(managed_nodes) + length(discovered_nodes),
|
||||||
|
added_devices: length(managed_nodes),
|
||||||
|
discovered_devices: length(discovered_nodes),
|
||||||
|
total_links: length(edges),
|
||||||
|
degraded_links: Enum.count(edges, fn e -> e[:signal_health] == "degraded" or e[:signal_health] == "critical" end),
|
||||||
|
sites_with_alerts:
|
||||||
|
devices
|
||||||
|
|> Enum.filter(&(&1.status == :down))
|
||||||
|
|> Enum.map(& &1.site_id)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> length()
|
||||||
|
},
|
||||||
|
has_geo: has_geo,
|
||||||
|
last_updated: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Build topology data for the network weathermap with edge utilization info.
|
||||||
|
Returns %{nodes, edges, stats, last_updated} with utilization data.
|
||||||
|
Tab "added" = managed devices only. Tab "all" = managed + discovered.
|
||||||
|
"""
|
||||||
|
def get_topology_for_weathermap(organization_id, tab \\ "added") do
|
||||||
|
devices = list_managed_devices(organization_id)
|
||||||
|
device_ids = Enum.map(devices, & &1.id)
|
||||||
|
links = list_links_for_devices(device_ids)
|
||||||
|
|
||||||
|
# Compute wireless stats (client counts + signal health) per device
|
||||||
|
wireless_stats = Stats.compute_wireless_stats(device_ids)
|
||||||
|
|
||||||
|
# Compute RF signal health per link for edge enrichment
|
||||||
|
rf_link_stats = Stats.compute_rf_link_stats(device_ids)
|
||||||
|
|
||||||
|
managed_nodes = Enum.map(devices, &device_to_node(&1, wireless_stats))
|
||||||
|
discovered_nodes = build_discovered_nodes(links, tab)
|
||||||
|
|
||||||
|
# Build site compound nodes from devices that have sites
|
||||||
|
site_nodes =
|
||||||
|
devices
|
||||||
|
|> Enum.filter(& &1.site)
|
||||||
|
|> Enum.uniq_by(& &1.site_id)
|
||||||
|
|> Enum.map(fn device ->
|
||||||
|
%{
|
||||||
|
id: "site_#{device.site_id}",
|
||||||
|
label: device.site.name,
|
||||||
|
type: :site,
|
||||||
|
device_role: nil,
|
||||||
|
status: nil,
|
||||||
|
site_id: device.site_id,
|
||||||
|
site_name: device.site.name,
|
||||||
|
ip_address: nil,
|
||||||
|
discovered: false,
|
||||||
|
manufacturer: nil,
|
||||||
|
parent: nil,
|
||||||
|
latitude: device.site.latitude,
|
||||||
|
longitude: device.site.longitude
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
all_nodes = site_nodes ++ managed_nodes ++ discovered_nodes
|
||||||
|
node_ids = MapSet.new(Enum.map(all_nodes, & &1.id))
|
||||||
|
|
||||||
|
# Build edges with utilization data
|
||||||
|
edges =
|
||||||
|
links
|
||||||
|
|> build_edges_from_links(node_ids)
|
||||||
|
|> merge_bidirectional_edges()
|
||||||
|
|> Stats.enrich_edges_with_rf(rf_link_stats)
|
||||||
|
|> Stats.enrich_edges_with_utilization(device_ids)
|
||||||
|
|
||||||
|
# Compute utilization stats for the dashboard
|
||||||
|
utilization_stats = compute_utilization_stats(edges)
|
||||||
|
|
||||||
|
# Compute whether geographic layout is available
|
||||||
|
has_geo =
|
||||||
|
Enum.any?(all_nodes, fn n ->
|
||||||
|
n[:latitude] != nil and n[:longitude] != nil
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
nodes: all_nodes,
|
||||||
|
edges: edges,
|
||||||
|
stats: %{
|
||||||
|
total_devices: length(managed_nodes) + length(discovered_nodes),
|
||||||
|
added_devices: length(managed_nodes),
|
||||||
|
discovered_devices: length(discovered_nodes),
|
||||||
|
total_links: length(edges),
|
||||||
|
low_utilization_links: utilization_stats.low_utilization_links,
|
||||||
|
medium_utilization_links: utilization_stats.medium_utilization_links,
|
||||||
|
high_utilization_links: utilization_stats.high_utilization_links,
|
||||||
|
overutilized_links: utilization_stats.overutilized_links,
|
||||||
|
degraded_links: Enum.count(edges, fn e -> e[:signal_health] == "degraded" or e[:signal_health] == "critical" end),
|
||||||
|
sites_with_alerts:
|
||||||
|
devices
|
||||||
|
|> Enum.filter(&(&1.status == :down))
|
||||||
|
|> Enum.map(& &1.site_id)
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> length()
|
||||||
|
},
|
||||||
|
has_geo: has_geo,
|
||||||
|
last_updated: DateTime.utc_now()
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Get detail information for a node (managed or discovered) for the detail panel.
|
||||||
|
|
||||||
|
For managed devices: returns device with site, connections.
|
||||||
|
For discovered nodes (id starts with "discovered_"): returns info from link metadata.
|
||||||
|
Returns nil if node not found or not in organization.
|
||||||
|
"""
|
||||||
|
def get_node_detail("discovered_" <> suffix = node_id, organization_id) do
|
||||||
|
suffix
|
||||||
|
|> find_discovered_link(organization_id)
|
||||||
|
|> discovered_node_detail(node_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_node_detail("site_" <> _rest, _organization_id), do: nil
|
||||||
|
|
||||||
|
def get_node_detail(device_id, organization_id) do
|
||||||
|
device_id
|
||||||
|
|> query_device_detail(organization_id)
|
||||||
|
|> managed_node_detail(device_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Private: detail helpers ---
|
||||||
|
|
||||||
|
defp discovered_node_detail(nil, _node_id), do: nil
|
||||||
|
|
||||||
|
defp discovered_node_detail(link, node_id) do
|
||||||
|
capabilities = get_in(link.metadata, ["remote_capabilities"])
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: node_id,
|
||||||
|
name: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
|
||||||
|
ip_address: link.discovered_remote_ip,
|
||||||
|
mac_address: link.discovered_remote_mac,
|
||||||
|
type: :discovered,
|
||||||
|
role: InferenceEngine.infer_role_from_capabilities(capabilities),
|
||||||
|
status: :unknown,
|
||||||
|
site_name: nil,
|
||||||
|
manufacturer: nil,
|
||||||
|
connections: [
|
||||||
|
%{
|
||||||
|
device_id: link.source_device_id,
|
||||||
|
device_name: link.source_device.name,
|
||||||
|
interface: interface_name(link.source_interface),
|
||||||
|
confidence: link.confidence,
|
||||||
|
link_type: link.link_type
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp query_device_detail(device_id, organization_id) do
|
||||||
|
Repo.one(
|
||||||
|
from(d in Device,
|
||||||
|
where: d.id == ^device_id and d.organization_id == ^organization_id,
|
||||||
|
left_join: s in assoc(d, :site),
|
||||||
|
left_join: sd in assoc(d, :snmp_device),
|
||||||
|
preload: [site: s, snmp_device: sd]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp managed_node_detail(nil, _device_id), do: nil
|
||||||
|
|
||||||
|
defp managed_node_detail(device, device_id) do
|
||||||
|
connections =
|
||||||
|
device_id
|
||||||
|
|> Towerops.Topology.list_connected_devices()
|
||||||
|
|> Enum.map(&link_to_connection(&1, device_id))
|
||||||
|
|
||||||
|
# Wireless stats for detail panel
|
||||||
|
ws = Stats.compute_wireless_stats([device_id])
|
||||||
|
device_ws = Map.get(ws, device_id, %{client_count: 0, signal_health: nil})
|
||||||
|
|
||||||
|
rf = Stats.compute_rf_link_stats([device_id])
|
||||||
|
device_rf = Map.get(rf, device_id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: device.id,
|
||||||
|
name: device.name,
|
||||||
|
ip_address: device.ip_address,
|
||||||
|
type: :managed,
|
||||||
|
role: role_to_type_atom(device.device_role),
|
||||||
|
device_role: device.device_role,
|
||||||
|
status: device.status,
|
||||||
|
site_name: extract_field_value(device.site, :name),
|
||||||
|
manufacturer: extract_field_value(device.snmp_device, :manufacturer),
|
||||||
|
connections: connections,
|
||||||
|
client_count: device_ws.client_count,
|
||||||
|
signal_health: device_ws.signal_health,
|
||||||
|
snr: extract_rf_snr(device_rf),
|
||||||
|
is_wireless: device.device_role in ~w(access_point backhaul cpe backhaul_radio)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_field_value(nil, _field), do: nil
|
||||||
|
defp extract_field_value(struct, field), do: Map.get(struct, field)
|
||||||
|
|
||||||
|
defp extract_rf_snr(nil), do: nil
|
||||||
|
defp extract_rf_snr(rf), do: rf[:snr]
|
||||||
|
|
||||||
|
defp interface_name(nil), do: nil
|
||||||
|
defp interface_name(interface), do: interface.if_name
|
||||||
|
|
||||||
|
defp find_discovered_link(suffix, organization_id) do
|
||||||
|
link =
|
||||||
|
Repo.one(
|
||||||
|
from l in DeviceLink,
|
||||||
|
join: d in Device,
|
||||||
|
on: l.source_device_id == d.id,
|
||||||
|
where: d.organization_id == ^organization_id,
|
||||||
|
where: is_nil(l.target_device_id),
|
||||||
|
where:
|
||||||
|
l.discovered_remote_mac == ^suffix or
|
||||||
|
l.discovered_remote_ip == ^suffix or
|
||||||
|
l.discovered_remote_name == ^suffix,
|
||||||
|
preload: [:source_device, :source_interface, :target_interface]
|
||||||
|
)
|
||||||
|
|
||||||
|
link || find_discovered_link_by_anonymous_id(suffix, organization_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_discovered_link_by_anonymous_id("anonymous_" <> link_id, organization_id) do
|
||||||
|
link_id
|
||||||
|
|> Ecto.UUID.cast()
|
||||||
|
|> lookup_anonymous_link(organization_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_discovered_link_by_anonymous_id(_suffix, _organization_id), do: nil
|
||||||
|
|
||||||
|
defp lookup_anonymous_link({:ok, uuid}, organization_id) do
|
||||||
|
Repo.one(
|
||||||
|
from l in DeviceLink,
|
||||||
|
join: d in Device,
|
||||||
|
on: l.source_device_id == d.id,
|
||||||
|
where: d.organization_id == ^organization_id,
|
||||||
|
where: is_nil(l.target_device_id),
|
||||||
|
where: l.id == ^uuid,
|
||||||
|
preload: [:source_device, :source_interface, :target_interface]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_anonymous_link(:error, _organization_id), do: nil
|
||||||
|
|
||||||
|
defp link_to_connection(%{source_device_id: device_id} = link, device_id) do
|
||||||
|
build_connection(link, link.target_device, link.target_interface)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp link_to_connection(link, _device_id) do
|
||||||
|
build_connection(link, link.source_device, link.source_interface)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_connection(link, connected_device, interface) do
|
||||||
|
%{
|
||||||
|
device_id: extract_device_id(connected_device),
|
||||||
|
device_name: connected_device_name(connected_device, link),
|
||||||
|
interface: interface_name(interface),
|
||||||
|
confidence: link.confidence,
|
||||||
|
link_type: link.link_type
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp connected_device_name(device, _link) when not is_nil(device), do: device.name
|
||||||
|
|
||||||
|
defp connected_device_name(nil, link),
|
||||||
|
do: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac
|
||||||
|
|
||||||
|
defp extract_device_id(nil), do: nil
|
||||||
|
defp extract_device_id(device), do: device.id
|
||||||
|
|
||||||
|
# --- Private: network map helpers ---
|
||||||
|
|
||||||
|
defp list_managed_devices(organization_id) do
|
||||||
|
Repo.all(
|
||||||
|
from(d in Device,
|
||||||
|
where: d.organization_id == ^organization_id,
|
||||||
|
left_join: s in assoc(d, :site),
|
||||||
|
left_join: sd in assoc(d, :snmp_device),
|
||||||
|
preload: [site: s, snmp_device: sd]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_links_for_devices(device_ids) do
|
||||||
|
Repo.all(
|
||||||
|
from(l in DeviceLink,
|
||||||
|
where: l.source_device_id in ^device_ids or l.target_device_id in ^device_ids,
|
||||||
|
preload: [:source_interface, :target_interface]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp device_to_node(device, wireless_stats) do
|
||||||
|
stats = Map.get(wireless_stats, device.id, %{client_count: 0, signal_health: nil})
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: device.id,
|
||||||
|
label: device.name || device.ip_address || "Unknown",
|
||||||
|
type: role_to_type_atom(device.device_role),
|
||||||
|
device_role: device.device_role,
|
||||||
|
status: device.status,
|
||||||
|
site_id: device.site_id,
|
||||||
|
site_name: extract_field_value(device.site, :name),
|
||||||
|
ip_address: device.ip_address,
|
||||||
|
discovered: false,
|
||||||
|
manufacturer: extract_field_value(device.snmp_device, :manufacturer),
|
||||||
|
parent: site_parent_id(device.site_id),
|
||||||
|
client_count: stats.client_count,
|
||||||
|
signal_health: stats.signal_health,
|
||||||
|
latitude: extract_field_value(device.site, :latitude),
|
||||||
|
longitude: extract_field_value(device.site, :longitude),
|
||||||
|
has_alerts: device.status == :down
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp site_parent_id(nil), do: nil
|
||||||
|
defp site_parent_id(site_id), do: "site_#{site_id}"
|
||||||
|
|
||||||
|
defp build_discovered_nodes(links, "all") do
|
||||||
|
links
|
||||||
|
|> Enum.filter(&is_nil(&1.target_device_id))
|
||||||
|
|> Enum.uniq_by(&discovered_node_id/1)
|
||||||
|
|> Enum.map(fn link ->
|
||||||
|
capabilities = get_in(link.metadata, ["remote_capabilities"])
|
||||||
|
inferred_type = InferenceEngine.infer_role_from_capabilities(capabilities)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: discovered_node_id(link),
|
||||||
|
label: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac,
|
||||||
|
type: inferred_type,
|
||||||
|
device_role: nil,
|
||||||
|
status: :unknown,
|
||||||
|
site_id: nil,
|
||||||
|
site_name: nil,
|
||||||
|
ip_address: link.discovered_remote_ip,
|
||||||
|
discovered: true,
|
||||||
|
manufacturer: nil,
|
||||||
|
parent_device_id: link.source_device_id
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_discovered_nodes(_links, _tab), do: []
|
||||||
|
|
||||||
|
defp discovered_node_id(link) do
|
||||||
|
suffix =
|
||||||
|
link.discovered_remote_mac ||
|
||||||
|
link.discovered_remote_ip ||
|
||||||
|
link.discovered_remote_name ||
|
||||||
|
"anonymous_#{link.id}"
|
||||||
|
|
||||||
|
"discovered_#{suffix}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# --- Private: edge building ---
|
||||||
|
|
||||||
|
defp merge_bidirectional_edges(edges) do
|
||||||
|
edges
|
||||||
|
|> Enum.group_by(fn edge ->
|
||||||
|
{min(edge.source, edge.target), max(edge.source, edge.target)}
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {_key, group} -> merge_edge_group(group) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp merge_edge_group([single]), do: single
|
||||||
|
|
||||||
|
defp merge_edge_group([first | rest] = group) do
|
||||||
|
merged_confidence =
|
||||||
|
Enum.reduce(rest, first.confidence, fn edge, acc ->
|
||||||
|
InferenceEngine.merge_confidence(acc, edge.confidence)
|
||||||
|
end)
|
||||||
|
|
||||||
|
all_interfaces =
|
||||||
|
group
|
||||||
|
|> Enum.flat_map(&[&1.source_interface, &1.target_interface])
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
best_link_type =
|
||||||
|
group
|
||||||
|
|> Enum.map(& &1.link_type)
|
||||||
|
|> Enum.min_by(&link_type_priority/1)
|
||||||
|
|
||||||
|
if_speed =
|
||||||
|
group
|
||||||
|
|> Enum.map(&Map.get(&1, :if_speed))
|
||||||
|
|> Enum.reject(&is_nil/1)
|
||||||
|
|> Enum.max(fn -> nil end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
first
|
||||||
|
| confidence: merged_confidence,
|
||||||
|
link_type: best_link_type,
|
||||||
|
source_interface: Enum.at(all_interfaces, 0),
|
||||||
|
target_interface: Enum.at(all_interfaces, 1),
|
||||||
|
if_speed: if_speed
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp link_type_priority("lldp"), do: 0
|
||||||
|
defp link_type_priority("cdp"), do: 1
|
||||||
|
defp link_type_priority("mac_match"), do: 2
|
||||||
|
defp link_type_priority("arp_inference"), do: 3
|
||||||
|
defp link_type_priority(_), do: 4
|
||||||
|
|
||||||
|
defp build_edges_from_links(links, node_ids) do
|
||||||
|
links
|
||||||
|
|> Enum.map(&link_to_edge/1)
|
||||||
|
|> Enum.filter(fn edge ->
|
||||||
|
MapSet.member?(node_ids, edge.source) and MapSet.member?(node_ids, edge.target)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp link_to_edge(link) do
|
||||||
|
%{
|
||||||
|
id: link.id,
|
||||||
|
source: link.source_device_id,
|
||||||
|
target: edge_target_id(link),
|
||||||
|
source_interface: interface_name(link.source_interface),
|
||||||
|
target_interface: interface_name(link.target_interface),
|
||||||
|
source_interface_id: link.source_interface_id,
|
||||||
|
target_interface_id: extract_target_interface_id(link.target_interface),
|
||||||
|
link_type: link.link_type,
|
||||||
|
confidence: link.confidence,
|
||||||
|
metadata: link.metadata,
|
||||||
|
if_speed: extract_if_speed(link.source_interface)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp edge_target_id(%{target_device_id: nil} = link), do: discovered_node_id(link)
|
||||||
|
defp edge_target_id(%{target_device_id: target_id}), do: target_id
|
||||||
|
|
||||||
|
defp extract_target_interface_id(nil), do: nil
|
||||||
|
defp extract_target_interface_id(interface), do: interface.id
|
||||||
|
|
||||||
|
defp extract_if_speed(nil), do: nil
|
||||||
|
defp extract_if_speed(interface), do: interface.if_speed
|
||||||
|
|
||||||
|
# Map device roles to visual types for network topology
|
||||||
|
defp role_to_type_atom("router"), do: :router
|
||||||
|
defp role_to_type_atom("core_router"), do: :router
|
||||||
|
defp role_to_type_atom("switch"), do: :switch
|
||||||
|
defp role_to_type_atom("distribution_switch"), do: :switch
|
||||||
|
defp role_to_type_atom("access_switch"), do: :switch
|
||||||
|
defp role_to_type_atom("access_point"), do: :wireless
|
||||||
|
defp role_to_type_atom("cpe"), do: :wireless
|
||||||
|
defp role_to_type_atom("backhaul"), do: :wireless
|
||||||
|
defp role_to_type_atom("backhaul_radio"), do: :wireless
|
||||||
|
defp role_to_type_atom("server"), do: :server
|
||||||
|
defp role_to_type_atom("firewall"), do: :firewall
|
||||||
|
defp role_to_type_atom("ups"), do: :server
|
||||||
|
defp role_to_type_atom("other"), do: :unknown
|
||||||
|
defp role_to_type_atom(_), do: :unknown
|
||||||
|
|
||||||
|
# --- Private: utilization stats ---
|
||||||
|
|
||||||
|
# Compute utilization statistics for the weathermap dashboard.
|
||||||
|
defp compute_utilization_stats(edges) do
|
||||||
|
utilization_counts =
|
||||||
|
edges
|
||||||
|
|> Enum.map(& &1[:utilization_level])
|
||||||
|
|> Enum.frequencies()
|
||||||
|
|
||||||
|
%{
|
||||||
|
low_utilization_links: Map.get(utilization_counts, :low, 0),
|
||||||
|
medium_utilization_links: Map.get(utilization_counts, :medium, 0),
|
||||||
|
high_utilization_links: Map.get(utilization_counts, :high, 0),
|
||||||
|
overutilized_links: Map.get(utilization_counts, :critical, 0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
273
lib/towerops/topology/stats.ex
Normal file
273
lib/towerops/topology/stats.ex
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
defmodule Towerops.Topology.Stats do
|
||||||
|
@moduledoc """
|
||||||
|
Wireless/RF statistics and edge utilization enrichment for topology views.
|
||||||
|
|
||||||
|
Computes wireless client counts, signal health classification, SNR metrics,
|
||||||
|
and bandwidth utilization data for enriching topology map edges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Towerops.Capacity
|
||||||
|
alias Towerops.Devices.Device
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias Towerops.Snmp.Device, as: SnmpDevice
|
||||||
|
alias Towerops.Snmp.Interface
|
||||||
|
alias Towerops.Snmp.Sensor
|
||||||
|
alias Towerops.Snmp.WirelessClient
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Compute wireless client counts and average signal health per device.
|
||||||
|
Returns a map of device_id => %{client_count: int, signal_health: "good"|"degraded"|"critical"|nil}
|
||||||
|
"""
|
||||||
|
def compute_wireless_stats([]), do: %{}
|
||||||
|
|
||||||
|
def compute_wireless_stats(device_ids) when is_list(device_ids) do
|
||||||
|
from(wc in WirelessClient,
|
||||||
|
where: wc.device_id in ^device_ids,
|
||||||
|
group_by: wc.device_id,
|
||||||
|
select: {wc.device_id, count(wc.id), avg(wc.signal_strength)}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> build_wireless_stats()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_wireless_stats(results) do
|
||||||
|
Map.new(results, fn {device_id, count, avg_signal} ->
|
||||||
|
health = classify_signal_health(avg_signal)
|
||||||
|
{device_id, %{client_count: count, signal_health: health}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Compute RF link stats for edges. Uses SNR sensors on devices to
|
||||||
|
determine signal health for PTP/backhaul links.
|
||||||
|
Returns a map of device_id => %{signal_dbm: float, snr: float, signal_health: string}
|
||||||
|
"""
|
||||||
|
def compute_rf_link_stats([]), do: %{}
|
||||||
|
|
||||||
|
def compute_rf_link_stats(device_ids) when is_list(device_ids) do
|
||||||
|
from(s in Sensor,
|
||||||
|
join: sd in SnmpDevice,
|
||||||
|
on: s.snmp_device_id == sd.id,
|
||||||
|
where: sd.device_id in ^device_ids and s.sensor_type == "snr" and not is_nil(s.last_value),
|
||||||
|
select: {sd.device_id, s.last_value, s.sensor_descr}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.group_by(fn {device_id, _val, _descr} -> device_id end)
|
||||||
|
|> Map.new(&compute_device_snr_stats/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_device_snr_stats({device_id, entries}) do
|
||||||
|
vals = Enum.map(entries, fn {_, val, _} -> val end)
|
||||||
|
avg_snr = Enum.sum(vals) / max(length(vals), 1)
|
||||||
|
|
||||||
|
{device_id, %{snr: Float.round(avg_snr, 1), signal_health: classify_snr_health(avg_snr)}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp classify_signal_health(nil), do: nil
|
||||||
|
|
||||||
|
defp classify_signal_health(avg_signal) do
|
||||||
|
avg_signal
|
||||||
|
|> signal_to_float()
|
||||||
|
|> classify_by_signal()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp signal_to_float(avg) when is_float(avg), do: avg
|
||||||
|
defp signal_to_float(avg), do: Decimal.to_float(avg)
|
||||||
|
|
||||||
|
defp classify_by_signal(avg) when avg >= -65, do: "good"
|
||||||
|
defp classify_by_signal(avg) when avg >= -75, do: "degraded"
|
||||||
|
defp classify_by_signal(_avg), do: "critical"
|
||||||
|
|
||||||
|
defp classify_snr_health(snr) when snr >= 25, do: "good"
|
||||||
|
defp classify_snr_health(snr) when snr >= 15, do: "degraded"
|
||||||
|
defp classify_snr_health(_snr), do: "critical"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Enriches edges with RF signal health and SNR data from device-level stats.
|
||||||
|
Must be called before enrich_edges_with_utilization so signal_health is available
|
||||||
|
in the utilization layer.
|
||||||
|
"""
|
||||||
|
def enrich_edges_with_rf(edges, rf_stats) do
|
||||||
|
Enum.map(edges, &enrich_single_edge(&1, rf_stats))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp enrich_single_edge(edge, rf_stats) do
|
||||||
|
source_rf = Map.get(rf_stats, edge.source)
|
||||||
|
target_rf = Map.get(rf_stats, edge.target)
|
||||||
|
|
||||||
|
signal_health = compute_edge_signal_health(source_rf, target_rf)
|
||||||
|
snr = compute_edge_snr(source_rf, target_rf)
|
||||||
|
|
||||||
|
Map.merge(edge, %{signal_health: signal_health, snr: snr})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compute_edge_signal_health(nil, nil), do: nil
|
||||||
|
defp compute_edge_signal_health(s, nil), do: s.signal_health
|
||||||
|
defp compute_edge_signal_health(nil, t), do: t.signal_health
|
||||||
|
defp compute_edge_signal_health(s, t), do: worse_health(s.signal_health, t.signal_health)
|
||||||
|
|
||||||
|
defp compute_edge_snr(nil, nil), do: nil
|
||||||
|
defp compute_edge_snr(s, nil), do: s[:snr]
|
||||||
|
defp compute_edge_snr(nil, t), do: t[:snr]
|
||||||
|
defp compute_edge_snr(s, t), do: min(s[:snr] || 0, t[:snr] || 0)
|
||||||
|
|
||||||
|
defp worse_health(a, b) do
|
||||||
|
priority = %{"good" => 0, "degraded" => 1, "critical" => 2}
|
||||||
|
pick_worse(a, b, priority)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pick_worse(a, b, priority) do
|
||||||
|
a_prio = Map.get(priority, a, 0)
|
||||||
|
b_prio = Map.get(priority, b, 0)
|
||||||
|
pick_by_priority(a, b, a_prio, b_prio)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pick_by_priority(a, _b, a_prio, b_prio) when a_prio >= b_prio, do: a
|
||||||
|
defp pick_by_priority(_a, b, _a_prio, _b_prio), do: b
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Enriches edges with bandwidth utilization data by calculating interface throughput
|
||||||
|
and comparing it to configured capacity.
|
||||||
|
"""
|
||||||
|
def enrich_edges_with_utilization(edges, device_ids) do
|
||||||
|
# Get all interfaces with configured capacity for these devices
|
||||||
|
interface_utilizations = get_interface_utilizations(device_ids)
|
||||||
|
|
||||||
|
Enum.map(edges, fn edge ->
|
||||||
|
# Try to find utilization data for the source or target interface
|
||||||
|
source_util = get_edge_utilization(edge, :source, interface_utilizations)
|
||||||
|
target_util = get_edge_utilization(edge, :target, interface_utilizations)
|
||||||
|
|
||||||
|
# Use the higher utilization of the two interfaces
|
||||||
|
utilization = combine_interface_utilizations(source_util, target_util)
|
||||||
|
|
||||||
|
Map.merge(edge, utilization)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_interface_utilizations(device_ids) do
|
||||||
|
# Query interfaces with capacity configuration and recent stats
|
||||||
|
from(i in Interface,
|
||||||
|
join: sd in SnmpDevice,
|
||||||
|
on: i.snmp_device_id == sd.id,
|
||||||
|
join: d in Device,
|
||||||
|
on: sd.device_id == d.id,
|
||||||
|
where: d.id in ^device_ids and not is_nil(i.configured_capacity_bps),
|
||||||
|
select: %{
|
||||||
|
interface_id: i.id,
|
||||||
|
device_id: d.id,
|
||||||
|
interface_name: i.if_name,
|
||||||
|
capacity_bps: i.configured_capacity_bps,
|
||||||
|
if_index: i.if_index
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn interface ->
|
||||||
|
# Calculate current utilization using the Capacity module
|
||||||
|
interface_struct = %Interface{
|
||||||
|
id: interface.interface_id,
|
||||||
|
configured_capacity_bps: interface.capacity_bps
|
||||||
|
}
|
||||||
|
|
||||||
|
utilization = Capacity.get_utilization(interface_struct)
|
||||||
|
|
||||||
|
Map.put(interface, :utilization_data, utilization)
|
||||||
|
end)
|
||||||
|
|> Map.new(fn interface -> {interface.interface_id, interface} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_edge_utilization(edge, :source, interface_utilizations) do
|
||||||
|
lookup_utilization(edge[:source_interface_id], interface_utilizations)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_edge_utilization(edge, :target, interface_utilizations) do
|
||||||
|
lookup_utilization(edge[:target_interface_id], interface_utilizations)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lookup_utilization(nil, _interface_utilizations), do: nil
|
||||||
|
defp lookup_utilization(id, interface_utilizations), do: Map.get(interface_utilizations, id)
|
||||||
|
|
||||||
|
defp combine_interface_utilizations(nil, nil) do
|
||||||
|
%{
|
||||||
|
utilization_pct: nil,
|
||||||
|
utilization_level: nil,
|
||||||
|
throughput_bps: nil,
|
||||||
|
capacity_bps: nil,
|
||||||
|
utilization_text: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp combine_interface_utilizations(util, nil), do: process_single_utilization(util)
|
||||||
|
defp combine_interface_utilizations(nil, util), do: process_single_utilization(util)
|
||||||
|
|
||||||
|
defp combine_interface_utilizations(source, target) do
|
||||||
|
source_data = process_single_utilization(source)
|
||||||
|
target_data = process_single_utilization(target)
|
||||||
|
pick_higher_utilization(source_data, target_data)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pick_higher_utilization(source_data, target_data) do
|
||||||
|
source_pct = source_data.utilization_pct || 0
|
||||||
|
target_pct = target_data.utilization_pct || 0
|
||||||
|
pick_util_data(source_data, target_data, source_pct, target_pct)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pick_util_data(source_data, _target_data, source_pct, target_pct) when source_pct >= target_pct, do: source_data
|
||||||
|
|
||||||
|
defp pick_util_data(_source_data, target_data, _source_pct, _target_pct), do: target_data
|
||||||
|
|
||||||
|
defp process_single_utilization(%{utilization_data: nil} = util_data) do
|
||||||
|
%{
|
||||||
|
utilization_pct: nil,
|
||||||
|
utilization_level: nil,
|
||||||
|
throughput_bps: nil,
|
||||||
|
capacity_bps: util_data.capacity_bps,
|
||||||
|
utilization_text: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_single_utilization(%{utilization_data: util} = util_data) do
|
||||||
|
utilization_pct = Float.round(util.utilization_pct, 1)
|
||||||
|
level = classify_utilization_level(utilization_pct)
|
||||||
|
|
||||||
|
%{
|
||||||
|
utilization_pct: utilization_pct,
|
||||||
|
utilization_level: level,
|
||||||
|
throughput_bps: util.throughput.max_bps,
|
||||||
|
capacity_bps: util_data.capacity_bps,
|
||||||
|
utilization_text: format_utilization_text(util.throughput.max_bps, util_data.capacity_bps),
|
||||||
|
interface_name: util_data.interface_name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp classify_utilization_level(pct) when pct < 30, do: :low
|
||||||
|
defp classify_utilization_level(pct) when pct < 60, do: :medium
|
||||||
|
defp classify_utilization_level(pct) when pct < 80, do: :high
|
||||||
|
defp classify_utilization_level(_pct), do: :critical
|
||||||
|
|
||||||
|
defp format_utilization_text(throughput_bps, capacity_bps) do
|
||||||
|
throughput_text = format_bps(throughput_bps)
|
||||||
|
capacity_text = format_bps(capacity_bps)
|
||||||
|
"#{throughput_text} / #{capacity_text}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_bps(nil), do: "N/A"
|
||||||
|
|
||||||
|
defp format_bps(bps) when bps >= 1_000_000_000 do
|
||||||
|
"#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_bps(bps) when bps >= 1_000_000 do
|
||||||
|
"#{Float.round(bps / 1_000_000, 0)} Mbps"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_bps(bps) when bps >= 1_000 do
|
||||||
|
"#{Float.round(bps / 1_000, 0)} Kbps"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_bps(bps) do
|
||||||
|
"#{Float.round(bps, 0)} bps"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -72,27 +72,59 @@ defmodule Towerops.Visp.Client do
|
||||||
{:error, Exception.message(exception)}
|
{:error, Exception.message(exception)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp paginate(api_key, path, page \\ 1, acc \\ []) do
|
defp paginate(api_key, path) do
|
||||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
url = @base_url <> path
|
||||||
|
headers = [{"x-api-key", api_key}, {"accept", "application/json"}]
|
||||||
|
|
||||||
case get(api_key, path, params) do
|
first_req = [
|
||||||
{:ok, %{"data" => data, "meta" => meta}} when is_list(data) ->
|
method: :get,
|
||||||
all = acc ++ data
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
params: %{"page" => "1", "per_page" => "100"}
|
||||||
|
]
|
||||||
|
|
||||||
if page < (meta["last_page"] || 1) do
|
items_fn = fn response ->
|
||||||
paginate(api_key, path, page + 1, all)
|
case response.body do
|
||||||
else
|
%{"data" => data} when is_list(data) -> data
|
||||||
{:ok, all}
|
data when is_list(data) -> data
|
||||||
end
|
_ -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, %{"data" => data}} when is_list(data) ->
|
__MODULE__
|
||||||
{:ok, acc ++ data}
|
|> HTTP.paginate(first_req, &visp_next_page(first_req, &1, &2), items_fn)
|
||||||
|
|> case do
|
||||||
|
{:ok, items} -> {:ok, items}
|
||||||
|
{:error, error} -> translate_paginate_error(error)
|
||||||
|
end
|
||||||
|
rescue
|
||||||
|
exception ->
|
||||||
|
{:error, Exception.message(exception)}
|
||||||
|
end
|
||||||
|
|
||||||
{:ok, data} when is_list(data) ->
|
defp visp_next_page(first_req, response, page) do
|
||||||
{:ok, acc ++ data}
|
meta =
|
||||||
|
case response.body do
|
||||||
|
%{"meta" => m} -> m
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
|
||||||
{:error, reason} ->
|
if page + 1 < (meta["last_page"] || 1) do
|
||||||
{:error, reason}
|
Keyword.put(first_req, :params, %{"page" => to_string(page + 2), "per_page" => "100"})
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, 401, _response}), do: {:error, :unauthorized}
|
||||||
|
defp translate_paginate_error({:http_status, 403, _response}), do: {:error, :forbidden}
|
||||||
|
defp translate_paginate_error({:http_status, 429, _response}), do: {:error, {:rate_limited, 60}}
|
||||||
|
|
||||||
|
defp translate_paginate_error({:http_status, status, response}) do
|
||||||
|
Logger.warning("VISP API unexpected status #{status}: #{inspect(response.body)}")
|
||||||
|
{:error, {:unexpected_status, status}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_paginate_error(reason) do
|
||||||
|
Logger.error("VISP API connection error: #{inspect(reason)}")
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
||||||
|
|
||||||
import ToweropsWeb.GettextHelpers
|
import ToweropsWeb.GettextHelpers
|
||||||
|
|
||||||
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Agents
|
alias Towerops.Agents
|
||||||
alias Towerops.Agents.AgentToken
|
alias Towerops.Agents.AgentToken
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
|
|
@ -49,6 +51,8 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"agent_token" => agent_params}, socket) do
|
def handle_event("save", %{"agent_token" => agent_params}, socket) do
|
||||||
|
authorize(socket, :edit, :device)
|
||||||
|
|
||||||
case Agents.update_agent_token(socket.assigns.agent_token, agent_params) do
|
case Agents.update_agent_token(socket.assigns.agent_token, agent_params) do
|
||||||
{:ok, agent_token} ->
|
{:ok, agent_token} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -60,4 +64,13 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
||||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|
||||||
|
|
||||||
alias Towerops.Accounts.Scope
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Agents
|
alias Towerops.Agents
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
alias Towerops.Settings
|
alias Towerops.Settings
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -118,6 +119,8 @@ defmodule ToweropsWeb.AgentLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("show_setup", %{"id" => id}, socket) do
|
def handle_event("show_setup", %{"id" => id}, socket) do
|
||||||
|
authorize(socket, :view, :device)
|
||||||
|
|
||||||
organization = socket.assigns.current_scope.organization
|
organization = socket.assigns.current_scope.organization
|
||||||
is_superuser = Scope.superuser?(socket.assigns.current_scope)
|
is_superuser = Scope.superuser?(socket.assigns.current_scope)
|
||||||
|
|
||||||
|
|
@ -140,6 +143,8 @@ defmodule ToweropsWeb.AgentLive.Index do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete_agent", %{"id" => id}, socket) do
|
def handle_event("delete_agent", %{"id" => id}, socket) do
|
||||||
|
authorize(socket, :delete, :device)
|
||||||
|
|
||||||
agent_token = Agents.get_agent_token!(id)
|
agent_token = Agents.get_agent_token!(id)
|
||||||
|
|
||||||
if agent_authorized_for_deletion?(socket, agent_token) do
|
if agent_authorized_for_deletion?(socket, agent_token) do
|
||||||
|
|
@ -451,4 +456,13 @@ defmodule ToweropsWeb.AgentLive.Index do
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use ToweropsWeb, :live_view
|
use ToweropsWeb, :live_view
|
||||||
|
|
||||||
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Admin.AuditLogger
|
alias Towerops.Admin.AuditLogger
|
||||||
alias Towerops.Agents
|
alias Towerops.Agents
|
||||||
alias Towerops.Devices
|
alias Towerops.Devices
|
||||||
alias Towerops.Devices.Device, as: DeviceSchema
|
alias Towerops.Devices.Device, as: DeviceSchema
|
||||||
alias Towerops.OnCall
|
alias Towerops.OnCall
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
alias Towerops.Organizations.SubscriptionLimits
|
alias Towerops.Organizations.SubscriptionLimits
|
||||||
alias Towerops.Sites
|
alias Towerops.Sites
|
||||||
alias Towerops.Workers.DiscoveryWorker
|
alias Towerops.Workers.DiscoveryWorker
|
||||||
|
|
@ -61,6 +63,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :new, params) do
|
defp apply_action(socket, :new, params) do
|
||||||
|
authorize(socket, :create, :device)
|
||||||
|
|
||||||
equipment_attrs = %{
|
equipment_attrs = %{
|
||||||
monitoring_enabled: true,
|
monitoring_enabled: true,
|
||||||
check_interval_seconds: 300,
|
check_interval_seconds: 300,
|
||||||
|
|
@ -93,6 +97,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||||
|
authorize(socket, :edit, :device)
|
||||||
|
|
||||||
organization = socket.assigns.current_scope.organization
|
organization = socket.assigns.current_scope.organization
|
||||||
|
|
||||||
case AccessControl.verify_device_access(id, organization.id) do
|
case AccessControl.verify_device_access(id, organization.id) do
|
||||||
|
|
@ -231,6 +237,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", _params, socket) do
|
def handle_event("delete", _params, socket) do
|
||||||
|
authorize(socket, :delete, :device)
|
||||||
|
|
||||||
device = socket.assigns.device
|
device = socket.assigns.device
|
||||||
|
|
||||||
case Devices.delete_device(device) do
|
case Devices.delete_device(device) do
|
||||||
|
|
@ -848,4 +856,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -771,19 +771,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp fetch_org_check(socket, check_id) do
|
|
||||||
org = socket.assigns.current_scope.organization
|
|
||||||
|
|
||||||
case Monitoring.get_check(check_id, org.id) do
|
|
||||||
nil -> :not_found
|
|
||||||
check -> {:ok, check}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp verify_check_device(check, socket) do
|
|
||||||
if check.device_id == socket.assigns.device.id, do: :ok, else: :not_found
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("run_discovery", _params, socket) do
|
def handle_event("run_discovery", _params, socket) do
|
||||||
device = socket.assigns.device
|
device = socket.assigns.device
|
||||||
|
|
@ -1154,4 +1141,18 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
||||||
def terminate(_reason, _socket) do
|
def terminate(_reason, _socket) do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Authorization helpers for org-scoped check access
|
||||||
|
defp fetch_org_check(socket, check_id) do
|
||||||
|
org = socket.assigns.current_scope.organization
|
||||||
|
|
||||||
|
case Monitoring.get_check(check_id, org.id) do
|
||||||
|
nil -> :not_found
|
||||||
|
check -> {:ok, check}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp verify_check_device(check, socket) do
|
||||||
|
if check.device_id == socket.assigns.device.id, do: :ok, else: :not_found
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use ToweropsWeb, :live_view
|
use ToweropsWeb, :live_view
|
||||||
|
|
||||||
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
||||||
alias Towerops.Gaiia.Client, as: GaiiaClient
|
alias Towerops.Gaiia.Client, as: GaiiaClient
|
||||||
alias Towerops.Integrations
|
alias Towerops.Integrations
|
||||||
alias Towerops.Integrations.Integration
|
alias Towerops.Integrations.Integration
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
alias Towerops.Preseem.Client, as: PreseemClient
|
alias Towerops.Preseem.Client, as: PreseemClient
|
||||||
alias Towerops.Sonar.Client, as: SonarClient
|
alias Towerops.Sonar.Client, as: SonarClient
|
||||||
alias Towerops.Splynx.Client, as: SplynxClient
|
alias Towerops.Splynx.Client, as: SplynxClient
|
||||||
|
|
@ -170,6 +172,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save", %{"integration" => params}, socket) do
|
def handle_event("save", %{"integration" => params}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
organization = socket.assigns.organization
|
organization = socket.assigns.organization
|
||||||
provider = socket.assigns.configuring
|
provider = socket.assigns.configuring
|
||||||
existing = Map.get(socket.assigns.integrations, provider)
|
existing = Map.get(socket.assigns.integrations, provider)
|
||||||
|
|
@ -224,6 +228,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("test_connection", _params, socket) do
|
def handle_event("test_connection", _params, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
credentials = extract_credentials(socket)
|
credentials = extract_credentials(socket)
|
||||||
provider = socket.assigns.configuring
|
provider = socket.assigns.configuring
|
||||||
|
|
||||||
|
|
@ -237,6 +243,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("regenerate_webhook_secret", _params, socket) do
|
def handle_event("regenerate_webhook_secret", _params, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||||
nil ->
|
nil ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -264,6 +272,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
integration = Map.get(socket.assigns.integrations, provider)
|
integration = Map.get(socket.assigns.integrations, provider)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
|
|
@ -518,4 +528,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
||||||
defp blank?(_), do: false
|
defp blank?(_), do: false
|
||||||
|
|
||||||
defp format_connection_result(result), do: ConnectionHelpers.format_connection_result(result)
|
defp format_connection_result(result), do: ConnectionHelpers.format_connection_result(result)
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use ToweropsWeb, :live_view
|
use ToweropsWeb, :live_view
|
||||||
|
|
||||||
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Accounts.UserNotifier
|
alias Towerops.Accounts.UserNotifier
|
||||||
alias Towerops.Admin.AuditLogger
|
alias Towerops.Admin.AuditLogger
|
||||||
alias Towerops.Agents
|
alias Towerops.Agents
|
||||||
|
|
@ -10,6 +11,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
alias Towerops.Integrations
|
alias Towerops.Integrations
|
||||||
alias Towerops.Integrations.Integration
|
alias Towerops.Integrations.Integration
|
||||||
alias Towerops.Organizations
|
alias Towerops.Organizations
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
alias Towerops.Organizations.SubscriptionLimits
|
alias Towerops.Organizations.SubscriptionLimits
|
||||||
alias Towerops.Preseem.Client, as: PreseemClient
|
alias Towerops.Preseem.Client, as: PreseemClient
|
||||||
alias Towerops.Sonar.Client, as: SonarClient
|
alias Towerops.Sonar.Client, as: SonarClient
|
||||||
|
|
@ -173,6 +175,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("cancel_invitation", %{"id" => id}, socket) do
|
def handle_event("cancel_invitation", %{"id" => id}, socket) do
|
||||||
|
authorize(socket, :delete, :invitation)
|
||||||
|
|
||||||
organization = socket.assigns.organization
|
organization = socket.assigns.organization
|
||||||
|
|
||||||
case Organizations.get_organization_invitation(id, organization.id) do
|
case Organizations.get_organization_invitation(id, organization.id) do
|
||||||
|
|
@ -342,6 +346,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save_integration", %{"integration" => params}, socket) do
|
def handle_event("save_integration", %{"integration" => params}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
organization = socket.assigns.organization
|
organization = socket.assigns.organization
|
||||||
provider = socket.assigns.configuring
|
provider = socket.assigns.configuring
|
||||||
existing = Map.get(socket.assigns.integrations, provider)
|
existing = Map.get(socket.assigns.integrations, provider)
|
||||||
|
|
@ -377,12 +383,16 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("test_connection", _params, socket) do
|
def handle_event("test_connection", _params, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
_ = do_test_connection(socket.assigns.configuring, socket)
|
_ = do_test_connection(socket.assigns.configuring, socket)
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("save_webhook_secret", %{"value" => secret}, socket) do
|
def handle_event("save_webhook_secret", %{"value" => secret}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||||
nil ->
|
nil ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -407,6 +417,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do
|
def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||||
nil ->
|
nil ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -432,6 +444,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
||||||
|
authorize(socket, :edit, :integration)
|
||||||
|
|
||||||
case Map.get(socket.assigns.integrations, provider) do
|
case Map.get(socket.assigns.integrations, provider) do
|
||||||
nil ->
|
nil ->
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -844,4 +858,13 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
||||||
{:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))}
|
{:noreply, put_flash(socket, :error, t("You don't have permission to perform this action"))}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ defmodule ToweropsWeb.SiteLive.Form do
|
||||||
use ToweropsWeb, :live_view
|
use ToweropsWeb, :live_view
|
||||||
|
|
||||||
alias Phoenix.HTML.Form
|
alias Phoenix.HTML.Form
|
||||||
|
alias Towerops.Accounts.Scope
|
||||||
alias Towerops.Agents
|
alias Towerops.Agents
|
||||||
|
alias Towerops.Organizations.Policy
|
||||||
alias Towerops.Sites
|
alias Towerops.Sites
|
||||||
alias Towerops.Sites.Site
|
alias Towerops.Sites.Site
|
||||||
|
|
||||||
|
|
@ -24,6 +26,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :new, _params) do
|
defp apply_action(socket, :new, _params) do
|
||||||
|
authorize(socket, :create, :site)
|
||||||
|
|
||||||
changeset = Sites.change_site(%Site{organization_id: socket.assigns.organization.id})
|
changeset = Sites.change_site(%Site{organization_id: socket.assigns.organization.id})
|
||||||
|
|
||||||
# Get inherited agent info from organization
|
# Get inherited agent info from organization
|
||||||
|
|
@ -41,6 +45,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||||
|
authorize(socket, :edit, :site)
|
||||||
|
|
||||||
site = Sites.get_organization_site!(socket.assigns.organization.id, id)
|
site = Sites.get_organization_site!(socket.assigns.organization.id, id)
|
||||||
changeset = Sites.change_site(site)
|
changeset = Sites.change_site(site)
|
||||||
|
|
||||||
|
|
@ -120,6 +126,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("delete", _params, socket) do
|
def handle_event("delete", _params, socket) do
|
||||||
|
authorize(socket, :delete, :site)
|
||||||
|
|
||||||
case Sites.delete_site(socket.assigns.site) do
|
case Sites.delete_site(socket.assigns.site) do
|
||||||
{:ok, _} ->
|
{:ok, _} ->
|
||||||
{:noreply,
|
{:noreply,
|
||||||
|
|
@ -182,4 +190,13 @@ defmodule ToweropsWeb.SiteLive.Form do
|
||||||
# Find organization's default agent in the available agents list
|
# Find organization's default agent in the available agents list
|
||||||
defp find_org_agent(nil, _agents), do: nil
|
defp find_org_agent(nil, _agents), do: nil
|
||||||
defp find_org_agent(agent_id, agents), do: Enum.find(agents, &(&1.id == agent_id))
|
defp find_org_agent(agent_id, agents), do: Enum.find(agents, &(&1.id == agent_id))
|
||||||
|
|
||||||
|
defp authorize(socket, action, resource) do
|
||||||
|
if Scope.superuser?(socket.assigns.current_scope) do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
membership = socket.assigns.current_membership
|
||||||
|
Policy.authorize!(membership, action, resource)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ defmodule Towerops.Agents.AgentTokenTest do
|
||||||
{:ok, agent_token} = Towerops.Repo.insert(changeset)
|
{:ok, agent_token} = Towerops.Repo.insert(changeset)
|
||||||
|
|
||||||
assert {:ok, new_raw} = AgentToken.rotate_token(agent_token)
|
assert {:ok, new_raw} = AgentToken.rotate_token(agent_token)
|
||||||
assert is_binary(new_raw)
|
assert is_binary(new_raw) and byte_size(new_raw) > 0
|
||||||
assert new_raw != raw
|
assert new_raw != raw
|
||||||
|
|
||||||
# Old token should no longer verify
|
# Old token should no longer verify
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.JobMonitoringTest do
|
||||||
|
|
||||||
alias Towerops.JobMonitoring
|
alias Towerops.JobMonitoring
|
||||||
|
|
||||||
|
doctest JobMonitoring
|
||||||
|
|
||||||
describe "list_active_jobs/0" do
|
describe "list_active_jobs/0" do
|
||||||
test "returns jobs in executing state for polling and discovery workers" do
|
test "returns jobs in executing state for polling and discovery workers" do
|
||||||
device = device_fixture()
|
device = device_fixture()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule Towerops.Organizations.PolicyTest do
|
defmodule Towerops.Organizations.PolicyTest do
|
||||||
use Towerops.DataCase
|
use Towerops.DataCase
|
||||||
|
|
||||||
|
alias Towerops.Organizations.ForbiddenError
|
||||||
alias Towerops.Organizations.Membership
|
alias Towerops.Organizations.Membership
|
||||||
alias Towerops.Organizations.Policy
|
alias Towerops.Organizations.Policy
|
||||||
|
|
||||||
|
|
@ -286,4 +287,48 @@ defmodule Towerops.Organizations.PolicyTest do
|
||||||
assert Policy.can?(membership, :view, :unknown_resource) == true
|
assert Policy.can?(membership, :view, :unknown_resource) == true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "authorize/3" do
|
||||||
|
test "delegates to can?/3 for owner" do
|
||||||
|
membership = %Membership{role: :owner}
|
||||||
|
|
||||||
|
assert Policy.authorize(membership, :delete, :organization) == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delegates to can?/3 for viewer" do
|
||||||
|
membership = %Membership{role: :viewer}
|
||||||
|
|
||||||
|
assert Policy.authorize(membership, :edit, :device) == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns false for nil membership" do
|
||||||
|
assert Policy.authorize(nil, :view, :organization) == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "authorize!/3" do
|
||||||
|
test "returns :ok when authorized" do
|
||||||
|
membership = %Membership{role: :owner}
|
||||||
|
|
||||||
|
assert Policy.authorize!(membership, :view, :device) == :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises ForbiddenError when not authorized" do
|
||||||
|
membership = %Membership{role: :viewer}
|
||||||
|
|
||||||
|
assert_raise ForbiddenError,
|
||||||
|
~r/Forbidden: cannot edit device/,
|
||||||
|
fn ->
|
||||||
|
Policy.authorize!(membership, :edit, :device)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises ForbiddenError for nil membership" do
|
||||||
|
assert_raise ForbiddenError,
|
||||||
|
~r/Forbidden: cannot edit device/,
|
||||||
|
fn ->
|
||||||
|
Policy.authorize!(nil, :edit, :device)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
51
test/towerops/workers/agent_release_webhook_worker_test.exs
Normal file
51
test/towerops/workers/agent_release_webhook_worker_test.exs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
defmodule Towerops.Workers.AgentReleaseWebhookWorkerTest do
|
||||||
|
use Towerops.DataCase, async: false
|
||||||
|
use Oban.Testing, repo: Towerops.Repo
|
||||||
|
|
||||||
|
import Towerops.AccountsFixtures
|
||||||
|
import Towerops.OrganizationsFixtures
|
||||||
|
|
||||||
|
alias Towerops.Agents
|
||||||
|
alias Towerops.Agents.ReleaseChecker
|
||||||
|
alias Towerops.Workers.AgentReleaseWebhookWorker
|
||||||
|
|
||||||
|
describe "process/1" do
|
||||||
|
setup do
|
||||||
|
user = user_fixture()
|
||||||
|
organization = organization_fixture(user.id)
|
||||||
|
%{organization: organization}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :ok when broadcast succeeds", %{organization: org} do
|
||||||
|
# Create an updatable agent (with recent heartbeat) so notify_agents finds it
|
||||||
|
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Test Agent")
|
||||||
|
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{"arch" => "x86_64"})
|
||||||
|
|
||||||
|
# Ensure cache is clear before stubbing
|
||||||
|
ReleaseChecker.invalidate_cache()
|
||||||
|
|
||||||
|
# Stub the HTTP call to return a valid release
|
||||||
|
Req.Test.stub(ReleaseChecker, fn conn ->
|
||||||
|
Req.Test.json(conn, %{
|
||||||
|
"tag_name" => "v1.0.0",
|
||||||
|
"assets" => [
|
||||||
|
%{
|
||||||
|
"name" => "towerops-agent-linux-amd64",
|
||||||
|
"browser_download_url" => "https://example.com/release/amd64",
|
||||||
|
"checksum" => "abc123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = perform_job(AgentReleaseWebhookWorker, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns {:error, reason} when broadcast fails" do
|
||||||
|
ReleaseChecker.invalidate_cache()
|
||||||
|
|
||||||
|
# In test env, git.mcintire.me returns 404, so broadcast_mass_update fails
|
||||||
|
assert {:error, _reason} = perform_job(AgentReleaseWebhookWorker, %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
152
test/towerops_web/live/maintenance_live/index_test.exs
Normal file
152
test/towerops_web/live/maintenance_live/index_test.exs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
defmodule ToweropsWeb.MaintenanceLive.IndexTest do
|
||||||
|
use ToweropsWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Towerops.Maintenance
|
||||||
|
|
||||||
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
{:ok, organization} =
|
||||||
|
Towerops.Organizations.create_organization(%{name: "Maint Index Org"}, user.id)
|
||||||
|
|
||||||
|
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||||
|
|
||||||
|
# Active window: starts in the past, ends in the future
|
||||||
|
{:ok, active_window} =
|
||||||
|
Maintenance.create_window(%{
|
||||||
|
name: "Active Window",
|
||||||
|
organization_id: organization.id,
|
||||||
|
created_by_id: user.id,
|
||||||
|
starts_at: DateTime.add(now, -3600, :second),
|
||||||
|
ends_at: DateTime.add(now, 3600, :second),
|
||||||
|
reason: "testing"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Upcoming window: starts in the future
|
||||||
|
{:ok, upcoming_window} =
|
||||||
|
Maintenance.create_window(%{
|
||||||
|
name: "Upcoming Window",
|
||||||
|
organization_id: organization.id,
|
||||||
|
created_by_id: user.id,
|
||||||
|
starts_at: DateTime.add(now, 3600, :second),
|
||||||
|
ends_at: DateTime.add(now, 7200, :second),
|
||||||
|
reason: "testing"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Past window: ended in the past
|
||||||
|
{:ok, past_window} =
|
||||||
|
Maintenance.create_window(%{
|
||||||
|
name: "Past Window",
|
||||||
|
organization_id: organization.id,
|
||||||
|
created_by_id: user.id,
|
||||||
|
starts_at: DateTime.add(now, -7200, :second),
|
||||||
|
ends_at: DateTime.add(now, -3600, :second),
|
||||||
|
reason: "testing"
|
||||||
|
})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
organization: organization,
|
||||||
|
active_window: active_window,
|
||||||
|
upcoming_window: upcoming_window,
|
||||||
|
past_window: past_window
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "rendering" do
|
||||||
|
test "renders the index page with maintenance windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance")
|
||||||
|
|
||||||
|
assert html =~ "Maintenance Windows"
|
||||||
|
assert html =~ "New Window"
|
||||||
|
# Filter tabs
|
||||||
|
assert html =~ "All"
|
||||||
|
assert html =~ "Active"
|
||||||
|
assert html =~ "Upcoming"
|
||||||
|
assert html =~ "Past"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows empty state when no maintenance windows exist", %{conn: conn, user: user} do
|
||||||
|
# Create a separate organization with no windows
|
||||||
|
{:ok, empty_org} =
|
||||||
|
Towerops.Organizations.create_organization(%{name: "Empty Org"}, user.id, bypass_limits: true)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
Plug.Conn.put_session(conn, :current_organization_id, empty_org.id)
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance")
|
||||||
|
|
||||||
|
assert html =~ "No maintenance windows"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "filtering by URL params" do
|
||||||
|
test "?filter=active shows only active windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=active")
|
||||||
|
|
||||||
|
assert html =~ "Active Window"
|
||||||
|
refute html =~ "Upcoming Window"
|
||||||
|
refute html =~ "Past Window"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "?filter=upcoming shows only upcoming windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=upcoming")
|
||||||
|
|
||||||
|
refute html =~ "Active Window"
|
||||||
|
assert html =~ "Upcoming Window"
|
||||||
|
refute html =~ "Past Window"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "?filter=past shows only past windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=past")
|
||||||
|
|
||||||
|
refute html =~ "Active Window"
|
||||||
|
refute html =~ "Upcoming Window"
|
||||||
|
assert html =~ "Past Window"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "?filter=all shows all windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=all")
|
||||||
|
|
||||||
|
assert html =~ "Active Window"
|
||||||
|
assert html =~ "Upcoming Window"
|
||||||
|
assert html =~ "Past Window"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "default (no filter) shows all windows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance")
|
||||||
|
|
||||||
|
assert html =~ "Active Window"
|
||||||
|
assert html =~ "Upcoming Window"
|
||||||
|
assert html =~ "Past Window"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "status labels in table" do
|
||||||
|
test "active windows show Active status badge", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=active")
|
||||||
|
|
||||||
|
assert html =~ "Active Window"
|
||||||
|
assert html =~ "Active"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "upcoming windows show Upcoming status badge", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=upcoming")
|
||||||
|
|
||||||
|
assert html =~ "Upcoming Window"
|
||||||
|
assert html =~ "Upcoming"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "past windows show Past status badge", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/maintenance?filter=past")
|
||||||
|
|
||||||
|
assert html =~ "Past Window"
|
||||||
|
assert html =~ "Past"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
167
test/towerops_web/live/rf_link_health_live_test.exs
Normal file
167
test/towerops_web/live/rf_link_health_live_test.exs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
defmodule ToweropsWeb.RfLinkHealthLiveTest do
|
||||||
|
use ToweropsWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Towerops.Repo
|
||||||
|
alias Towerops.Snmp.WirelessClient
|
||||||
|
|
||||||
|
setup :register_and_log_in_user
|
||||||
|
|
||||||
|
setup %{conn: conn, user: user} do
|
||||||
|
{:ok, organization} =
|
||||||
|
Towerops.Organizations.create_organization(%{name: "RF Test Org"}, user.id)
|
||||||
|
|
||||||
|
{:ok, site} =
|
||||||
|
Towerops.Sites.create_site(%{
|
||||||
|
name: "RF Test Site",
|
||||||
|
organization_id: organization.id
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, device} =
|
||||||
|
Towerops.Devices.create_device(%{
|
||||||
|
name: "Test AP",
|
||||||
|
ip_address: "10.0.0.1",
|
||||||
|
site_id: site.id,
|
||||||
|
organization_id: organization.id
|
||||||
|
})
|
||||||
|
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
|
||||||
|
# Create wireless clients with different health states
|
||||||
|
_healthy =
|
||||||
|
%WirelessClient{}
|
||||||
|
|> WirelessClient.changeset(%{
|
||||||
|
device_id: device.id,
|
||||||
|
organization_id: organization.id,
|
||||||
|
mac_address: "00:11:22:33:44:01",
|
||||||
|
hostname: "healthy-client",
|
||||||
|
signal_strength: -55,
|
||||||
|
snr: 30,
|
||||||
|
last_seen_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
_degraded =
|
||||||
|
%WirelessClient{}
|
||||||
|
|> WirelessClient.changeset(%{
|
||||||
|
device_id: device.id,
|
||||||
|
organization_id: organization.id,
|
||||||
|
mac_address: "00:11:22:33:44:02",
|
||||||
|
hostname: "degraded-client",
|
||||||
|
signal_strength: -70,
|
||||||
|
snr: 18,
|
||||||
|
last_seen_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
_critical =
|
||||||
|
%WirelessClient{}
|
||||||
|
|> WirelessClient.changeset(%{
|
||||||
|
device_id: device.id,
|
||||||
|
organization_id: organization.id,
|
||||||
|
mac_address: "00:11:22:33:44:03",
|
||||||
|
hostname: "critical-client",
|
||||||
|
signal_strength: -85,
|
||||||
|
snr: 10,
|
||||||
|
last_seen_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
Plug.Conn.put_session(conn, :current_organization_id, organization.id)
|
||||||
|
|
||||||
|
%{conn: conn, organization: organization, device: device}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "mount" do
|
||||||
|
test "renders the RF links page with summary and links", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/rf-links")
|
||||||
|
|
||||||
|
assert html =~ "RF Links"
|
||||||
|
assert html =~ "healthy-client"
|
||||||
|
assert html =~ "degraded-client"
|
||||||
|
assert html =~ "critical-client"
|
||||||
|
# Summary stat cards
|
||||||
|
assert html =~ "Total"
|
||||||
|
assert html =~ "Healthy"
|
||||||
|
assert html =~ "Degraded"
|
||||||
|
assert html =~ "Critical"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "filtering" do
|
||||||
|
test "filter param updates view to show only healthy links", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/rf-links?filter=healthy")
|
||||||
|
|
||||||
|
assert html =~ "healthy-client"
|
||||||
|
refute html =~ "degraded-client"
|
||||||
|
refute html =~ "critical-client"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter param updates view to show only degraded links", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/rf-links?filter=degraded")
|
||||||
|
|
||||||
|
refute html =~ "healthy-client"
|
||||||
|
assert html =~ "degraded-client"
|
||||||
|
refute html =~ "critical-client"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter param updates view to show only critical links", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/rf-links?filter=critical")
|
||||||
|
|
||||||
|
refute html =~ "healthy-client"
|
||||||
|
refute html =~ "degraded-client"
|
||||||
|
assert html =~ "critical-client"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filter all shows all links", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/rf-links?filter=all")
|
||||||
|
|
||||||
|
assert html =~ "healthy-client"
|
||||||
|
assert html =~ "degraded-client"
|
||||||
|
assert html =~ "critical-client"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "filter click events" do
|
||||||
|
test "clicking filter buttons re-renders with filtered results", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/rf-links")
|
||||||
|
|
||||||
|
html = render_hook(view, "filter", %{"filter" => "critical"})
|
||||||
|
|
||||||
|
assert html =~ "critical-client"
|
||||||
|
refute html =~ "healthy-client"
|
||||||
|
refute html =~ "degraded-client"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "toggle_expand" do
|
||||||
|
test "clicking toggle_expand updates expanded_id", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/rf-links")
|
||||||
|
|
||||||
|
# Get a wireless client ID from the database
|
||||||
|
client =
|
||||||
|
Repo.one(
|
||||||
|
from(wc in WirelessClient, where: wc.hostname == "healthy-client", limit: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
_result = render_hook(view, "toggle_expand", %{"id" => client.id})
|
||||||
|
assert Process.alive?(view.pid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "real-time PubSub updates" do
|
||||||
|
test "wireless_client_updated event refreshes data", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/rf-links")
|
||||||
|
|
||||||
|
send(view.pid, {:wireless_client_updated, %{client_id: "test"}})
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "healthy-client"
|
||||||
|
assert html =~ "degraded-client"
|
||||||
|
assert html =~ "critical-client"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue