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
|
||||
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
|
||||
nil ->
|
||||
{:error, :invalid_token}
|
||||
nil -> {:error, :not_found}
|
||||
token -> {:ok, token}
|
||||
end
|
||||
end
|
||||
|
||||
token ->
|
||||
# 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)
|
||||
defp check_expired(%{expires_at: nil}), do: :ok
|
||||
|
||||
# Load user if present
|
||||
token = Repo.preload(token, :user)
|
||||
defp check_expired(%{expires_at: expires_at}) do
|
||||
if DateTime.before?(expires_at, DateTime.utc_now()), do: :expired, else: :ok
|
||||
end
|
||||
|
||||
# If token has a user_id, verify membership is still active
|
||||
if token.user_id && is_nil(Towerops.Organizations.get_membership(token.organization_id, token.user_id)) do
|
||||
{:error, :invalid_token}
|
||||
else
|
||||
{:ok, token.organization_id, token.user}
|
||||
end
|
||||
end
|
||||
defp update_token_metadata(token) do
|
||||
_ = update_last_used(token)
|
||||
Repo.preload(token, :user)
|
||||
end
|
||||
|
||||
defp verify_membership(%{user_id: nil}), do: :ok
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule Towerops.Devices do
|
|||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.Devices.DeviceQuery
|
||||
alias Towerops.Devices.Event
|
||||
alias Towerops.Devices.Ordering
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.Organization
|
||||
|
|
@ -1035,72 +1036,8 @@ defmodule Towerops.Devices do
|
|||
|
||||
## Display Order Management
|
||||
|
||||
@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!(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
|
||||
defdelegate reorder_device(device_id, new_position), to: Ordering
|
||||
defdelegate reset_organization_device_order(organization_id), to: Ordering
|
||||
|
||||
defp broadcast_device_change(organization_id, event) do
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ defmodule Towerops.JobMonitoring do
|
|||
## Examples
|
||||
|
||||
iex> JobMonitoring.flush_jobs(["scheduled", "retryable"])
|
||||
42
|
||||
0
|
||||
"""
|
||||
@spec flush_jobs([String.t()]) :: non_neg_integer()
|
||||
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.
|
||||
"""
|
||||
|
||||
alias Towerops.Organizations.ForbiddenError
|
||||
alias Towerops.Organizations.Membership
|
||||
|
||||
@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?(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
|
||||
defp check_permission(:owner, _action, _resource), do: true
|
||||
|
||||
|
|
|
|||
|
|
@ -118,26 +118,62 @@ defmodule Towerops.Sonar.Client do
|
|||
end
|
||||
end
|
||||
|
||||
defp paginate(instance_url, api_token, query_string, root_key, page \\ 1, acc \\ []) do
|
||||
variables = %{"page" => page, "records_per_page" => 100}
|
||||
defp paginate(instance_url, api_token, query_string, root_key) do
|
||||
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
|
||||
{:ok, data} ->
|
||||
root = data[root_key]
|
||||
entities = root["entities"] || []
|
||||
all_entities = acc ++ entities
|
||||
first_req = [
|
||||
method: :post,
|
||||
url: url,
|
||||
headers: headers,
|
||||
json: %{"query" => query_string, "variables" => %{"page" => 1, "records_per_page" => 100}}
|
||||
]
|
||||
|
||||
page_info = root["page_info"]
|
||||
|
||||
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}
|
||||
items_fn = fn response ->
|
||||
get_in(response.body, ["data", root_key, "entities"]) || []
|
||||
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
|
||||
|
||||
defp request(instance_url, api_token, query_string, variables) do
|
||||
|
|
|
|||
|
|
@ -127,24 +127,58 @@ defmodule Towerops.Splynx.Client do
|
|||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp paginate(instance_url, token, path, page \\ 1, acc \\ []) do
|
||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
||||
defp paginate(instance_url, token, path) do
|
||||
url = String.trim_trailing(instance_url, "/") <> path
|
||||
|
||||
case get(instance_url, token, path, params) do
|
||||
{:ok, data} when is_list(data) ->
|
||||
all = acc ++ data
|
||||
headers = [
|
||||
{"authorization", "Splynx-EA (access_token=#{token})"},
|
||||
{"accept", "application/json"}
|
||||
]
|
||||
|
||||
if length(data) >= 100 do
|
||||
paginate(instance_url, token, path, page + 1, all)
|
||||
else
|
||||
{:ok, all}
|
||||
end
|
||||
first_req = [
|
||||
method: :get,
|
||||
url: url,
|
||||
headers: headers,
|
||||
params: %{"page" => "1", "per_page" => "100"}
|
||||
]
|
||||
|
||||
{:ok, data} ->
|
||||
{:ok, acc ++ List.wrap(data)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
items_fn = fn response ->
|
||||
case response.body do
|
||||
data when is_list(data) -> data
|
||||
data -> List.wrap(data)
|
||||
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
|
||||
|
|
|
|||
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)}
|
||||
end
|
||||
|
||||
defp paginate(api_key, path, page \\ 1, acc \\ []) do
|
||||
params = %{"page" => to_string(page), "per_page" => "100"}
|
||||
defp paginate(api_key, path) do
|
||||
url = @base_url <> path
|
||||
headers = [{"x-api-key", api_key}, {"accept", "application/json"}]
|
||||
|
||||
case get(api_key, path, params) do
|
||||
{:ok, %{"data" => data, "meta" => meta}} when is_list(data) ->
|
||||
all = acc ++ data
|
||||
first_req = [
|
||||
method: :get,
|
||||
url: url,
|
||||
headers: headers,
|
||||
params: %{"page" => "1", "per_page" => "100"}
|
||||
]
|
||||
|
||||
if page < (meta["last_page"] || 1) do
|
||||
paginate(api_key, path, page + 1, all)
|
||||
else
|
||||
{:ok, all}
|
||||
end
|
||||
items_fn = fn response ->
|
||||
case response.body do
|
||||
%{"data" => data} when is_list(data) -> data
|
||||
data when is_list(data) -> data
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, %{"data" => data}} when is_list(data) ->
|
||||
{:ok, acc ++ data}
|
||||
__MODULE__
|
||||
|> 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) ->
|
||||
{:ok, acc ++ data}
|
||||
defp visp_next_page(first_req, response, page) do
|
||||
meta =
|
||||
case response.body do
|
||||
%{"meta" => m} -> m
|
||||
_ -> %{}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
if page + 1 < (meta["last_page"] || 1) do
|
||||
Keyword.put(first_req, :params, %{"page" => to_string(page + 2), "per_page" => "100"})
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
|||
|
||||
import ToweropsWeb.GettextHelpers
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Agents.AgentToken
|
||||
alias Towerops.Organizations.Policy
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
|
|
@ -49,6 +51,8 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
|||
|
||||
@impl true
|
||||
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
|
||||
{:ok, agent_token} ->
|
||||
{:noreply,
|
||||
|
|
@ -60,4 +64,13 @@ defmodule ToweropsWeb.AgentLive.Edit do
|
|||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Settings
|
||||
|
||||
@impl true
|
||||
|
|
@ -118,6 +119,8 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("show_setup", %{"id" => id}, socket) do
|
||||
authorize(socket, :view, :device)
|
||||
|
||||
organization = socket.assigns.current_scope.organization
|
||||
is_superuser = Scope.superuser?(socket.assigns.current_scope)
|
||||
|
||||
|
|
@ -140,6 +143,8 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete_agent", %{"id" => id}, socket) do
|
||||
authorize(socket, :delete, :device)
|
||||
|
||||
agent_token = Agents.get_agent_token!(id)
|
||||
|
||||
if agent_authorized_for_deletion?(socket, agent_token) do
|
||||
|
|
@ -451,4 +456,13 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|
||||
:ok
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Admin.AuditLogger
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Devices.Device, as: DeviceSchema
|
||||
alias Towerops.OnCall
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Workers.DiscoveryWorker
|
||||
|
|
@ -61,6 +63,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
end
|
||||
|
||||
defp apply_action(socket, :new, params) do
|
||||
authorize(socket, :create, :device)
|
||||
|
||||
equipment_attrs = %{
|
||||
monitoring_enabled: true,
|
||||
check_interval_seconds: 300,
|
||||
|
|
@ -93,6 +97,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
authorize(socket, :edit, :device)
|
||||
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
case AccessControl.verify_device_access(id, organization.id) do
|
||||
|
|
@ -231,6 +237,8 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
authorize(socket, :delete, :device)
|
||||
|
||||
device = socket.assigns.device
|
||||
|
||||
case Devices.delete_device(device) do
|
||||
|
|
@ -848,4 +856,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|
||||
:ok
|
||||
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
|
||||
|
|
|
|||
|
|
@ -771,19 +771,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
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
|
||||
def handle_event("run_discovery", _params, socket) do
|
||||
device = socket.assigns.device
|
||||
|
|
@ -1154,4 +1141,18 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
def terminate(_reason, _socket) do
|
||||
:ok
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.CnMaestro.Client, as: CnMaestroClient
|
||||
alias Towerops.Gaiia.Client, as: GaiiaClient
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Preseem.Client, as: PreseemClient
|
||||
alias Towerops.Sonar.Client, as: SonarClient
|
||||
alias Towerops.Splynx.Client, as: SplynxClient
|
||||
|
|
@ -170,6 +172,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("save", %{"integration" => params}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
organization = socket.assigns.organization
|
||||
provider = socket.assigns.configuring
|
||||
existing = Map.get(socket.assigns.integrations, provider)
|
||||
|
|
@ -224,6 +228,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("test_connection", _params, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
credentials = extract_credentials(socket)
|
||||
provider = socket.assigns.configuring
|
||||
|
||||
|
|
@ -237,6 +243,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("regenerate_webhook_secret", _params, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
|
@ -264,6 +272,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
integration = Map.get(socket.assigns.integrations, provider)
|
||||
|
||||
cond do
|
||||
|
|
@ -518,4 +528,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
|
|||
defp blank?(_), do: false
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Accounts.UserNotifier
|
||||
alias Towerops.Admin.AuditLogger
|
||||
alias Towerops.Agents
|
||||
|
|
@ -10,6 +11,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
alias Towerops.Integrations
|
||||
alias Towerops.Integrations.Integration
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Organizations.SubscriptionLimits
|
||||
alias Towerops.Preseem.Client, as: PreseemClient
|
||||
alias Towerops.Sonar.Client, as: SonarClient
|
||||
|
|
@ -173,6 +175,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("cancel_invitation", %{"id" => id}, socket) do
|
||||
authorize(socket, :delete, :invitation)
|
||||
|
||||
organization = socket.assigns.organization
|
||||
|
||||
case Organizations.get_organization_invitation(id, organization.id) do
|
||||
|
|
@ -342,6 +346,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("save_integration", %{"integration" => params}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
organization = socket.assigns.organization
|
||||
provider = socket.assigns.configuring
|
||||
existing = Map.get(socket.assigns.integrations, provider)
|
||||
|
|
@ -377,12 +383,16 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("test_connection", _params, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
_ = do_test_connection(socket.assigns.configuring, socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save_webhook_secret", %{"value" => secret}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
|
@ -407,6 +417,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
end
|
||||
|
||||
def handle_event("save_gaiia_app_url", %{"value" => url}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
case Map.get(socket.assigns.integrations, "gaiia") do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
|
@ -432,6 +444,8 @@ defmodule ToweropsWeb.Org.SettingsLive do
|
|||
|
||||
@impl true
|
||||
def handle_event("toggle_enabled", %{"provider" => provider}, socket) do
|
||||
authorize(socket, :edit, :integration)
|
||||
|
||||
case Map.get(socket.assigns.integrations, provider) do
|
||||
nil ->
|
||||
{: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"))}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Phoenix.HTML.Form
|
||||
alias Towerops.Accounts.Scope
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
|
|
@ -24,6 +26,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
authorize(socket, :create, :site)
|
||||
|
||||
changeset = Sites.change_site(%Site{organization_id: socket.assigns.organization.id})
|
||||
|
||||
# Get inherited agent info from organization
|
||||
|
|
@ -41,6 +45,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
authorize(socket, :edit, :site)
|
||||
|
||||
site = Sites.get_organization_site!(socket.assigns.organization.id, id)
|
||||
changeset = Sites.change_site(site)
|
||||
|
||||
|
|
@ -120,6 +126,8 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
authorize(socket, :delete, :site)
|
||||
|
||||
case Sites.delete_site(socket.assigns.site) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
|
|
@ -182,4 +190,13 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
# Find organization's default agent in the available agents list
|
||||
defp find_org_agent(nil, _agents), do: nil
|
||||
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
|
||||
|
|
|
|||
|
|
@ -245,7 +245,7 @@ defmodule Towerops.Agents.AgentTokenTest do
|
|||
{:ok, agent_token} = Towerops.Repo.insert(changeset)
|
||||
|
||||
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
|
||||
|
||||
# Old token should no longer verify
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ defmodule Towerops.JobMonitoringTest do
|
|||
|
||||
alias Towerops.JobMonitoring
|
||||
|
||||
doctest JobMonitoring
|
||||
|
||||
describe "list_active_jobs/0" do
|
||||
test "returns jobs in executing state for polling and discovery workers" do
|
||||
device = device_fixture()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Towerops.Organizations.PolicyTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
alias Towerops.Organizations.ForbiddenError
|
||||
alias Towerops.Organizations.Membership
|
||||
alias Towerops.Organizations.Policy
|
||||
|
||||
|
|
@ -286,4 +287,48 @@ defmodule Towerops.Organizations.PolicyTest do
|
|||
assert Policy.can?(membership, :view, :unknown_resource) == true
|
||||
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
|
||||
|
|
|
|||
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