add gaiia integration stages 1-2: sync engine and entity mapping UI
Stage 1 - Foundation: - GraphQL client with Relay cursor pagination and rate limiting - 4 Ecto schemas: accounts, network sites, inventory items, billing subscriptions - Gaiia context with upsert operations preserving user mappings - Sync module orchestrating full organization sync - Oban cron worker running every 15 minutes - Migration creating 4 tables with org-scoped unique constraints Stage 2 - Entity Mapping: - GaiiaMappingLive with two tabs (Network Sites / Inventory Items) - Match suggestion engine (IP/name matching with confidence levels) - Inline search and link/unlink UI following Preseem devices pattern - Filter by all/mapped/unmapped with URL-driven state - Amber suggestion badges for potential matches
This commit is contained in:
parent
21bbcccc30
commit
3cd2303b6f
25 changed files with 2903 additions and 3 deletions
|
|
@ -83,7 +83,9 @@ config :towerops, Oban,
|
|||
# Sync Preseem data every 10 minutes
|
||||
{"*/10 * * * *", Towerops.Workers.PreseemSyncWorker},
|
||||
# Compute Preseem baselines and fleet profiles nightly at 2:30 AM
|
||||
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker}
|
||||
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
|
||||
# Sync Gaiia data every 15 minutes
|
||||
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker}
|
||||
]},
|
||||
# Automatically delete completed jobs after 60 seconds
|
||||
{Oban.Plugins.Pruner, max_age: 60},
|
||||
|
|
|
|||
|
|
@ -196,7 +196,9 @@ if config_env() == :prod do
|
|||
# Sync Preseem data every 10 minutes
|
||||
{"*/10 * * * *", Towerops.Workers.PreseemSyncWorker},
|
||||
# Compute Preseem baselines and fleet profiles nightly at 2:30 AM
|
||||
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker}
|
||||
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
|
||||
# Sync Gaiia data every 15 minutes
|
||||
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker}
|
||||
]},
|
||||
# Automatically delete completed jobs after 60 seconds
|
||||
{Oban.Plugins.Pruner, max_age: 60},
|
||||
|
|
|
|||
204
lib/towerops/gaiia.ex
Normal file
204
lib/towerops/gaiia.ex
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
defmodule Towerops.Gaiia do
|
||||
@moduledoc """
|
||||
Context for querying Gaiia integration data.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Gaiia.Account
|
||||
alias Towerops.Gaiia.BillingSubscription
|
||||
alias Towerops.Gaiia.InventoryItem
|
||||
alias Towerops.Gaiia.NetworkSite
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
# --- Accounts ---
|
||||
|
||||
def list_accounts(organization_id) do
|
||||
Account
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> order_by(:name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_account(organization_id, gaiia_id) do
|
||||
Repo.get_by(Account, organization_id: organization_id, gaiia_id: gaiia_id)
|
||||
end
|
||||
|
||||
def upsert_account(organization_id, attrs) do
|
||||
attrs = Map.put(attrs, :organization_id, organization_id)
|
||||
|
||||
%Account{}
|
||||
|> Account.changeset(attrs)
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
|
||||
conflict_target: [:organization_id, :gaiia_id],
|
||||
returning: true
|
||||
)
|
||||
end
|
||||
|
||||
# --- Network Sites ---
|
||||
|
||||
def list_network_sites(organization_id) do
|
||||
NetworkSite
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> order_by(:name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_network_site(organization_id, gaiia_id) do
|
||||
Repo.get_by(NetworkSite, organization_id: organization_id, gaiia_id: gaiia_id)
|
||||
end
|
||||
|
||||
def upsert_network_site(organization_id, attrs) do
|
||||
attrs = Map.put(attrs, :organization_id, organization_id)
|
||||
|
||||
%NetworkSite{}
|
||||
|> NetworkSite.changeset(attrs)
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :site_id, :inserted_at]},
|
||||
conflict_target: [:organization_id, :gaiia_id],
|
||||
returning: true
|
||||
)
|
||||
end
|
||||
|
||||
def update_network_site_mapping(%NetworkSite{} = network_site, attrs) do
|
||||
network_site
|
||||
|> NetworkSite.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
# --- Inventory Items ---
|
||||
|
||||
def list_inventory_items(organization_id) do
|
||||
InventoryItem
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> order_by(:name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_inventory_item(organization_id, gaiia_id) do
|
||||
Repo.get_by(InventoryItem, organization_id: organization_id, gaiia_id: gaiia_id)
|
||||
end
|
||||
|
||||
def upsert_inventory_item(organization_id, attrs) do
|
||||
attrs = Map.put(attrs, :organization_id, organization_id)
|
||||
|
||||
%InventoryItem{}
|
||||
|> InventoryItem.changeset(attrs)
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :device_id, :inserted_at]},
|
||||
conflict_target: [:organization_id, :gaiia_id],
|
||||
returning: true
|
||||
)
|
||||
end
|
||||
|
||||
def update_inventory_item_mapping(%InventoryItem{} = inventory_item, attrs) do
|
||||
inventory_item
|
||||
|> InventoryItem.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
# --- Billing Subscriptions ---
|
||||
|
||||
def list_billing_subscriptions(organization_id) do
|
||||
BillingSubscription
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> order_by(:product_name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def list_billing_subscriptions_for_account(organization_id, account_gaiia_id) do
|
||||
BillingSubscription
|
||||
|> where(organization_id: ^organization_id, account_gaiia_id: ^account_gaiia_id)
|
||||
|> order_by(:product_name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_billing_subscription(organization_id, gaiia_id) do
|
||||
Repo.get_by(BillingSubscription, organization_id: organization_id, gaiia_id: gaiia_id)
|
||||
end
|
||||
|
||||
def upsert_billing_subscription(organization_id, attrs) do
|
||||
attrs = Map.put(attrs, :organization_id, organization_id)
|
||||
|
||||
%BillingSubscription{}
|
||||
|> BillingSubscription.changeset(attrs)
|
||||
|> Repo.insert(
|
||||
on_conflict: {:replace_all_except, [:id, :organization_id, :gaiia_id, :inserted_at]},
|
||||
conflict_target: [:organization_id, :gaiia_id],
|
||||
returning: true
|
||||
)
|
||||
end
|
||||
|
||||
# --- Match Suggestions ---
|
||||
|
||||
@doc """
|
||||
Suggest Towerops Sites that might match a Gaiia Network Site.
|
||||
|
||||
Matches by case-insensitive name containment (bidirectional).
|
||||
Returns a list of `%{entity: site, match_type: "name", confidence: :medium}`.
|
||||
"""
|
||||
def suggest_site_matches(_organization_id, %NetworkSite{name: nil}), do: []
|
||||
def suggest_site_matches(_organization_id, %NetworkSite{name: ""}), do: []
|
||||
|
||||
def suggest_site_matches(organization_id, %NetworkSite{name: gaiia_name}) do
|
||||
search_term = "%#{gaiia_name}%"
|
||||
|
||||
# Find sites where either name contains the other (bidirectional)
|
||||
Site
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([s], ilike(s.name, ^search_term) or ilike(^gaiia_name, fragment("'%' || ? || '%'", s.name)))
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn site -> %{entity: site, match_type: "name", confidence: :medium} end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Suggest Towerops Devices that might match a Gaiia Inventory Item.
|
||||
|
||||
Matching strategies (in confidence order):
|
||||
- IP address exact match (:high confidence)
|
||||
- Name containment match (:low confidence)
|
||||
|
||||
Returns a list of `%{entity: device, match_type: type, confidence: level}`,
|
||||
sorted by confidence (high first). Each device appears at most once per match type.
|
||||
"""
|
||||
def suggest_device_matches(organization_id, %InventoryItem{} = item) do
|
||||
ip_matches = find_ip_matches(organization_id, item.ip_address)
|
||||
name_matches = find_name_matches(organization_id, item.name)
|
||||
|
||||
(ip_matches ++ name_matches)
|
||||
|> Enum.uniq_by(fn %{entity: d, match_type: t} -> {d.id, t} end)
|
||||
|> Enum.sort_by(fn %{confidence: c} -> confidence_rank(c) end)
|
||||
end
|
||||
|
||||
defp find_ip_matches(_organization_id, nil), do: []
|
||||
defp find_ip_matches(_organization_id, ""), do: []
|
||||
|
||||
defp find_ip_matches(organization_id, ip_address) do
|
||||
Device
|
||||
|> where(organization_id: ^organization_id, ip_address: ^ip_address)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:site)
|
||||
|> Enum.map(fn device -> %{entity: device, match_type: "ip", confidence: :high} end)
|
||||
end
|
||||
|
||||
defp find_name_matches(_organization_id, nil), do: []
|
||||
defp find_name_matches(_organization_id, ""), do: []
|
||||
|
||||
defp find_name_matches(organization_id, name) do
|
||||
search_term = "%#{name}%"
|
||||
|
||||
Device
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([d], ilike(d.name, ^search_term) or ilike(^name, fragment("'%' || ? || '%'", d.name)))
|
||||
|> Repo.all()
|
||||
|> Repo.preload(:site)
|
||||
|> Enum.map(fn device -> %{entity: device, match_type: "name", confidence: :low} end)
|
||||
end
|
||||
|
||||
defp confidence_rank(:high), do: 0
|
||||
defp confidence_rank(:medium), do: 1
|
||||
defp confidence_rank(:low), do: 2
|
||||
end
|
||||
49
lib/towerops/gaiia/account.ex
Normal file
49
lib/towerops/gaiia/account.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Towerops.Gaiia.Account do
|
||||
@moduledoc """
|
||||
Schema for subscriber accounts synced from Gaiia API.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "gaiia_accounts" do
|
||||
field :gaiia_id, :string
|
||||
field :readable_id, :string
|
||||
field :name, :string
|
||||
field :status, :string
|
||||
field :account_type, :string
|
||||
field :address, :map
|
||||
field :subscription_count, :integer
|
||||
field :mrr, :decimal
|
||||
field :raw_data, :map
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(account, attrs) do
|
||||
account
|
||||
|> cast(attrs, [
|
||||
:organization_id,
|
||||
:gaiia_id,
|
||||
:readable_id,
|
||||
:name,
|
||||
:status,
|
||||
:account_type,
|
||||
:address,
|
||||
:subscription_count,
|
||||
:mrr,
|
||||
:raw_data
|
||||
])
|
||||
|> validate_required([:organization_id, :gaiia_id])
|
||||
|> unique_constraint([:organization_id, :gaiia_id],
|
||||
error_key: :gaiia_id,
|
||||
message: "has already been taken"
|
||||
)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
49
lib/towerops/gaiia/billing_subscription.ex
Normal file
49
lib/towerops/gaiia/billing_subscription.ex
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Towerops.Gaiia.BillingSubscription do
|
||||
@moduledoc """
|
||||
Schema for billing subscriptions synced from Gaiia API.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "gaiia_billing_subscriptions" do
|
||||
field :gaiia_id, :string
|
||||
field :account_gaiia_id, :string
|
||||
field :status, :string
|
||||
field :product_name, :string
|
||||
field :mrr_amount, :decimal
|
||||
field :currency, :string
|
||||
field :speed_download, :integer
|
||||
field :speed_upload, :integer
|
||||
field :raw_data, :map
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(billing_subscription, attrs) do
|
||||
billing_subscription
|
||||
|> cast(attrs, [
|
||||
:organization_id,
|
||||
:gaiia_id,
|
||||
:account_gaiia_id,
|
||||
:status,
|
||||
:product_name,
|
||||
:mrr_amount,
|
||||
:currency,
|
||||
:speed_download,
|
||||
:speed_upload,
|
||||
:raw_data
|
||||
])
|
||||
|> validate_required([:organization_id, :gaiia_id])
|
||||
|> unique_constraint([:organization_id, :gaiia_id],
|
||||
error_key: :gaiia_id,
|
||||
message: "has already been taken"
|
||||
)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
end
|
||||
end
|
||||
267
lib/towerops/gaiia/client.ex
Normal file
267
lib/towerops/gaiia/client.ex
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
defmodule Towerops.Gaiia.Client do
|
||||
@moduledoc """
|
||||
GraphQL client for the Gaiia API.
|
||||
|
||||
Uses Req with built-in test support via `Req.Test`. Handles Relay-style
|
||||
cursor pagination for all list endpoints.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@default_endpoint "https://api.gaiia.com/api/v1"
|
||||
|
||||
@accounts_query """
|
||||
query ListAccounts($first: Int!, $after: String) {
|
||||
accounts(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
readableId
|
||||
name
|
||||
accountStatus
|
||||
accountType
|
||||
physicalAddress {
|
||||
line1
|
||||
city
|
||||
state
|
||||
zip
|
||||
country
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
accountBillingSubscriptions(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
billingSubscriptionStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@network_sites_query """
|
||||
query ListNetworkSites($first: Int!, $after: String) {
|
||||
networkSites(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
address {
|
||||
line1
|
||||
city
|
||||
state
|
||||
zip
|
||||
country
|
||||
latitude
|
||||
longitude
|
||||
}
|
||||
networkSiteIpBlocks(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
subnet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@inventory_items_query """
|
||||
query ListInventoryItems($first: Int!, $after: String) {
|
||||
inventoryItems(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
inventoryItemStatus
|
||||
serialNumber
|
||||
macAddress
|
||||
ipAddress
|
||||
inventoryModel {
|
||||
name
|
||||
manufacturer {
|
||||
name
|
||||
}
|
||||
}
|
||||
inventoryModelCategory {
|
||||
name
|
||||
}
|
||||
account {
|
||||
id
|
||||
}
|
||||
networkSite {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@billing_subscriptions_query """
|
||||
query ListBillingSubscriptions($first: Int!, $after: String) {
|
||||
billingSubscriptions(first: $first, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
billingSubscriptionStatus
|
||||
product {
|
||||
name
|
||||
}
|
||||
recurringAmount
|
||||
currency
|
||||
account {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@doc "Test the connection to Gaiia by running a simple viewer query."
|
||||
def test_connection(api_key, opts \\ []) do
|
||||
case query(api_key, "{ viewer { id } }", %{}, opts) do
|
||||
{:ok, _data} -> {:ok, %{}}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "List all accounts with automatic pagination."
|
||||
def list_accounts(api_key, opts \\ []) do
|
||||
paginate(api_key, @accounts_query, "accounts", opts)
|
||||
end
|
||||
|
||||
@doc "List all network sites with automatic pagination."
|
||||
def list_network_sites(api_key, opts \\ []) do
|
||||
paginate(api_key, @network_sites_query, "networkSites", opts)
|
||||
end
|
||||
|
||||
@doc "List all inventory items with automatic pagination."
|
||||
def list_inventory_items(api_key, opts \\ []) do
|
||||
paginate(api_key, @inventory_items_query, "inventoryItems", opts)
|
||||
end
|
||||
|
||||
@doc "List all billing subscriptions with automatic pagination."
|
||||
def list_billing_subscriptions(api_key, opts \\ []) do
|
||||
paginate(api_key, @billing_subscriptions_query, "billingSubscriptions", opts)
|
||||
end
|
||||
|
||||
@doc "Execute a raw GraphQL query against the Gaiia API."
|
||||
def query(api_key, query_string, variables \\ %{}, opts \\ []) do
|
||||
case request(api_key, query_string, variables, opts) do
|
||||
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
|
||||
{:ok, data}
|
||||
|
||||
{:ok, %{status: status, body: %{"errors" => errors}}} when status in 200..299 ->
|
||||
{:error, {:graphql_errors, errors}}
|
||||
|
||||
{:ok, %{status: 401}} ->
|
||||
{:error, :unauthorized}
|
||||
|
||||
{:ok, %{status: 403}} ->
|
||||
{:error, :forbidden}
|
||||
|
||||
{:ok, %{status: 429, headers: headers}} ->
|
||||
retry_after = get_retry_after(headers)
|
||||
{:error, {:rate_limited, retry_after}}
|
||||
|
||||
{:ok, %{status: status, body: body}} ->
|
||||
Logger.warning("Gaiia API unexpected status #{status}: #{inspect(body)}")
|
||||
{:error, {:unexpected_status, status}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Gaiia API connection error: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp paginate(api_key, query_string, root_key, opts, cursor \\ nil, acc \\ []) do
|
||||
variables = %{"first" => 100, "after" => cursor}
|
||||
|
||||
case query(api_key, query_string, variables, opts) do
|
||||
{:ok, data} ->
|
||||
root = data[root_key]
|
||||
nodes = Enum.map(root["edges"], & &1["node"])
|
||||
all_nodes = acc ++ nodes
|
||||
|
||||
page_info = root["pageInfo"]
|
||||
|
||||
if page_info["hasNextPage"] do
|
||||
paginate(api_key, query_string, root_key, opts, page_info["endCursor"], all_nodes)
|
||||
else
|
||||
{:ok, all_nodes}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp request(api_key, query_string, variables, opts) do
|
||||
endpoint = Keyword.get(opts, :endpoint, @default_endpoint)
|
||||
|
||||
req_opts = [
|
||||
method: :post,
|
||||
url: endpoint,
|
||||
headers: [
|
||||
{"authorization", "Bearer #{api_key}"},
|
||||
{"content-type", "application/json"}
|
||||
],
|
||||
json: %{"query" => query_string, "variables" => variables}
|
||||
]
|
||||
|
||||
req_opts =
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
|
||||
else
|
||||
req_opts
|
||||
end
|
||||
|
||||
Req.request(req_opts)
|
||||
rescue
|
||||
exception ->
|
||||
{:error, Exception.message(exception)}
|
||||
end
|
||||
|
||||
defp get_retry_after(headers) when is_map(headers) do
|
||||
case Map.get(headers, "retry-after") do
|
||||
[value | _] -> parse_retry_after(value)
|
||||
_ -> 60
|
||||
end
|
||||
end
|
||||
|
||||
defp get_retry_after(_headers), do: 60
|
||||
|
||||
defp parse_retry_after(value) when is_binary(value) do
|
||||
case Integer.parse(value) do
|
||||
{seconds, _} -> seconds
|
||||
:error -> 60
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_retry_after(value) when is_integer(value), do: value
|
||||
defp parse_retry_after(_), do: 60
|
||||
end
|
||||
58
lib/towerops/gaiia/inventory_item.ex
Normal file
58
lib/towerops/gaiia/inventory_item.ex
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
defmodule Towerops.Gaiia.InventoryItem do
|
||||
@moduledoc """
|
||||
Schema for inventory items (deployed equipment) synced from Gaiia API.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "gaiia_inventory_items" do
|
||||
field :gaiia_id, :string
|
||||
field :name, :string
|
||||
field :status, :string
|
||||
field :serial_number, :string
|
||||
field :mac_address, :string
|
||||
field :ip_address, :string
|
||||
field :model_name, :string
|
||||
field :manufacturer_name, :string
|
||||
field :category, :string
|
||||
field :assigned_account_gaiia_id, :string
|
||||
field :assigned_network_site_gaiia_id, :string
|
||||
field :raw_data, :map
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :device, Towerops.Devices.Device
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(inventory_item, attrs) do
|
||||
inventory_item
|
||||
|> cast(attrs, [
|
||||
:organization_id,
|
||||
:gaiia_id,
|
||||
:name,
|
||||
:status,
|
||||
:serial_number,
|
||||
:mac_address,
|
||||
:ip_address,
|
||||
:model_name,
|
||||
:manufacturer_name,
|
||||
:category,
|
||||
:assigned_account_gaiia_id,
|
||||
:assigned_network_site_gaiia_id,
|
||||
:device_id,
|
||||
:raw_data
|
||||
])
|
||||
|> validate_required([:organization_id, :gaiia_id])
|
||||
|> unique_constraint([:organization_id, :gaiia_id],
|
||||
error_key: :gaiia_id,
|
||||
message: "has already been taken"
|
||||
)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:device_id)
|
||||
end
|
||||
end
|
||||
48
lib/towerops/gaiia/network_site.ex
Normal file
48
lib/towerops/gaiia/network_site.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule Towerops.Gaiia.NetworkSite do
|
||||
@moduledoc """
|
||||
Schema for network sites (towers/POPs) synced from Gaiia API.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "gaiia_network_sites" do
|
||||
field :gaiia_id, :string
|
||||
field :name, :string
|
||||
field :address, :map
|
||||
field :ip_blocks, {:array, :string}, default: []
|
||||
field :account_count, :integer
|
||||
field :total_mrr, :decimal
|
||||
field :raw_data, :map
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :site, Towerops.Sites.Site
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(network_site, attrs) do
|
||||
network_site
|
||||
|> cast(attrs, [
|
||||
:organization_id,
|
||||
:gaiia_id,
|
||||
:name,
|
||||
:address,
|
||||
:ip_blocks,
|
||||
:account_count,
|
||||
:total_mrr,
|
||||
:site_id,
|
||||
:raw_data
|
||||
])
|
||||
|> validate_required([:organization_id, :gaiia_id])
|
||||
|> unique_constraint([:organization_id, :gaiia_id],
|
||||
error_key: :gaiia_id,
|
||||
message: "has already been taken"
|
||||
)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:site_id)
|
||||
end
|
||||
end
|
||||
158
lib/towerops/gaiia/sync.ex
Normal file
158
lib/towerops/gaiia/sync.ex
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
defmodule Towerops.Gaiia.Sync do
|
||||
@moduledoc """
|
||||
Orchestrates syncing data from the Gaiia GraphQL API into the local database.
|
||||
|
||||
Pulls accounts, network sites, inventory items, and billing subscriptions
|
||||
from Gaiia, upserts them into local cache tables, and updates integration
|
||||
sync status.
|
||||
"""
|
||||
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Gaiia.Client
|
||||
alias Towerops.Integrations
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Main entry point: syncs all entity types for the given integration.
|
||||
|
||||
Returns `{:ok, %{accounts: n, network_sites: n, inventory_items: n, subscriptions: n}}`
|
||||
or `{:error, reason}`.
|
||||
"""
|
||||
def sync_organization(%Integrations.Integration{} = integration) do
|
||||
api_key = integration.credentials["api_key"]
|
||||
org_id = integration.organization_id
|
||||
opts = client_opts(integration)
|
||||
|
||||
with {:ok, accounts} <- Client.list_accounts(api_key, opts),
|
||||
accounts_count = upsert_nodes(org_id, accounts, :account),
|
||||
{:ok, sites} <- Client.list_network_sites(api_key, opts),
|
||||
sites_count = upsert_nodes(org_id, sites, :network_site),
|
||||
{:ok, items} <- Client.list_inventory_items(api_key, opts),
|
||||
items_count = upsert_nodes(org_id, items, :inventory_item),
|
||||
{:ok, subs} <- Client.list_billing_subscriptions(api_key, opts) do
|
||||
subs_count = upsert_nodes(org_id, subs, :billing_subscription)
|
||||
Integrations.update_sync_status(integration, "success")
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
accounts: accounts_count,
|
||||
network_sites: sites_count,
|
||||
inventory_items: items_count,
|
||||
subscriptions: subs_count
|
||||
}}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Integrations.update_sync_status(integration, "failed")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp upsert_nodes(org_id, nodes, type) do
|
||||
Enum.reduce(nodes, 0, fn node, count ->
|
||||
case upsert_node(org_id, node, type) do
|
||||
{:ok, _} ->
|
||||
count + 1
|
||||
|
||||
{:error, changeset} ->
|
||||
Logger.warning("Failed to upsert Gaiia #{type}: #{inspect(changeset.errors)}")
|
||||
count
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp upsert_node(org_id, node, :account) do
|
||||
Gaiia.upsert_account(org_id, map_account(node))
|
||||
end
|
||||
|
||||
defp upsert_node(org_id, node, :network_site) do
|
||||
Gaiia.upsert_network_site(org_id, map_network_site(node))
|
||||
end
|
||||
|
||||
defp upsert_node(org_id, node, :inventory_item) do
|
||||
Gaiia.upsert_inventory_item(org_id, map_inventory_item(node))
|
||||
end
|
||||
|
||||
defp upsert_node(org_id, node, :billing_subscription) do
|
||||
Gaiia.upsert_billing_subscription(org_id, map_billing_subscription(node))
|
||||
end
|
||||
|
||||
defp map_account(node) do
|
||||
subs = get_in(node, ["accountBillingSubscriptions", "edges"]) || []
|
||||
active_count = Enum.count(subs, &(&1["node"]["billingSubscriptionStatus"] == "Active"))
|
||||
|
||||
%{
|
||||
gaiia_id: node["id"],
|
||||
readable_id: node["readableId"],
|
||||
name: node["name"],
|
||||
status: node["accountStatus"],
|
||||
account_type: node["accountType"],
|
||||
address: map_address(node["physicalAddress"]),
|
||||
subscription_count: active_count,
|
||||
raw_data: node
|
||||
}
|
||||
end
|
||||
|
||||
defp map_network_site(node) do
|
||||
ip_block_edges = get_in(node, ["networkSiteIpBlocks", "edges"]) || []
|
||||
ip_blocks = Enum.map(ip_block_edges, & &1["node"]["subnet"])
|
||||
|
||||
%{
|
||||
gaiia_id: node["id"],
|
||||
name: node["name"],
|
||||
address: map_address(node["address"]),
|
||||
ip_blocks: ip_blocks,
|
||||
raw_data: node
|
||||
}
|
||||
end
|
||||
|
||||
defp map_inventory_item(node) do
|
||||
%{
|
||||
gaiia_id: node["id"],
|
||||
name: node["name"],
|
||||
status: node["inventoryItemStatus"],
|
||||
serial_number: node["serialNumber"],
|
||||
mac_address: node["macAddress"],
|
||||
ip_address: node["ipAddress"],
|
||||
model_name: get_in(node, ["inventoryModel", "name"]),
|
||||
manufacturer_name: get_in(node, ["inventoryModel", "manufacturer", "name"]),
|
||||
category: get_in(node, ["inventoryModelCategory", "name"]),
|
||||
assigned_account_gaiia_id: get_in(node, ["account", "id"]),
|
||||
assigned_network_site_gaiia_id: get_in(node, ["networkSite", "id"]),
|
||||
raw_data: node
|
||||
}
|
||||
end
|
||||
|
||||
defp map_billing_subscription(node) do
|
||||
%{
|
||||
gaiia_id: node["id"],
|
||||
account_gaiia_id: get_in(node, ["account", "id"]),
|
||||
status: node["billingSubscriptionStatus"],
|
||||
product_name: get_in(node, ["product", "name"]),
|
||||
mrr_amount: node["recurringAmount"],
|
||||
currency: node["currency"],
|
||||
raw_data: node
|
||||
}
|
||||
end
|
||||
|
||||
defp map_address(nil), do: nil
|
||||
|
||||
defp map_address(addr) do
|
||||
%{
|
||||
"line1" => addr["line1"],
|
||||
"city" => addr["city"],
|
||||
"state" => addr["state"],
|
||||
"zip" => addr["zip"],
|
||||
"country" => addr["country"],
|
||||
"latitude" => addr["latitude"],
|
||||
"longitude" => addr["longitude"]
|
||||
}
|
||||
end
|
||||
|
||||
defp client_opts(integration) do
|
||||
case integration.credentials["endpoint"] do
|
||||
nil -> []
|
||||
endpoint -> [endpoint: endpoint]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
|
|||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
@valid_providers ~w(preseem)
|
||||
@valid_providers ~w(preseem gaiia)
|
||||
@valid_sync_statuses ~w(never success partial failed)
|
||||
|
||||
schema "integrations" do
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ defmodule Towerops.Sites do
|
|||
)
|
||||
end
|
||||
|
||||
@doc "Search sites by name for an organization. Returns up to 10 results."
|
||||
def search_sites(organization_id, query) do
|
||||
search_term = "%#{query}%"
|
||||
|
||||
Site
|
||||
|> where(organization_id: ^organization_id)
|
||||
|> where([s], ilike(s.name, ^search_term))
|
||||
|> order_by(:name)
|
||||
|> limit(10)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the count of sites for an organization.
|
||||
"""
|
||||
|
|
|
|||
62
lib/towerops/workers/gaiia_sync_worker.ex
Normal file
62
lib/towerops/workers/gaiia_sync_worker.ex
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
defmodule Towerops.Workers.GaiiaSyncWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that syncs Gaiia data for all enabled integrations.
|
||||
|
||||
Runs every 15 minutes. For each org with an enabled Gaiia integration,
|
||||
checks if enough time has elapsed since the last sync (based on the
|
||||
integration's sync_interval_minutes), then calls Gaiia.Sync.sync_organization/1.
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
alias Towerops.Gaiia.Sync
|
||||
alias Towerops.Integrations
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
integrations = Integrations.list_enabled_integrations("gaiia")
|
||||
|
||||
results = Enum.map(integrations, &sync_integration/1)
|
||||
|
||||
synced = Enum.count(results, &match?({:ok, _}, &1))
|
||||
failed = Enum.count(results, &match?({:error, _}, &1))
|
||||
skipped = Enum.count(results, &(&1 == :skipped))
|
||||
|
||||
if synced > 0 or failed > 0 do
|
||||
Logger.info("Gaiia sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp sync_integration(integration) do
|
||||
if should_sync?(integration) do
|
||||
case Sync.sync_organization(integration) do
|
||||
{:ok, result} ->
|
||||
Logger.info("Gaiia sync completed for org #{integration.organization_id}: #{inspect(result)}")
|
||||
|
||||
{:ok, result}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Gaiia sync failed for org #{integration.organization_id}: #{inspect(reason)}")
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
else
|
||||
:skipped
|
||||
end
|
||||
end
|
||||
|
||||
defp should_sync?(integration) do
|
||||
case integration.last_synced_at do
|
||||
nil ->
|
||||
true
|
||||
|
||||
last_synced_at ->
|
||||
interval_seconds = (integration.sync_interval_minutes || 15) * 60
|
||||
elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
|
||||
elapsed >= interval_seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
241
lib/towerops_web/live/org/gaiia_mapping_live.ex
Normal file
241
lib/towerops_web/live/org/gaiia_mapping_live.ex
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
defmodule ToweropsWeb.Org.GaiiaMappingLive do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
org = socket.assigns.current_scope.organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:organization, org)
|
||||
|> assign(:tab, "sites")
|
||||
|> assign(:filter, "all")
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> load_data()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
tab =
|
||||
case params["tab"] do
|
||||
t when t in ~w(sites devices) -> t
|
||||
_ -> "sites"
|
||||
end
|
||||
|
||||
filter =
|
||||
case params["filter"] do
|
||||
f when f in ~w(all mapped unmapped) -> f
|
||||
_ -> "all"
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab, tab)
|
||||
|> assign(:filter, filter)
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> load_data()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("start_link", %{"id" => id}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:linking_id, id)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_link", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"query" => query}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
results =
|
||||
if String.length(query) >= 2 do
|
||||
case socket.assigns.tab do
|
||||
"sites" -> search_sites(org.id, query)
|
||||
"devices" -> Devices.search_devices(org.id, query)
|
||||
end
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
{:noreply, socket |> assign(:search_query, query) |> assign(:search_results, results)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("link_site", %{"gaiia-id" => gaiia_id, "site-id" => site_id}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
case Gaiia.get_network_site(org.id, gaiia_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
|
||||
|
||||
network_site ->
|
||||
case Gaiia.update_network_site_mapping(network_site, %{site_id: site_id}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> load_data()
|
||||
|> put_flash(:info, "Site linked successfully")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to link site")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlink_site", %{"gaiia-id" => gaiia_id}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
case Gaiia.get_network_site(org.id, gaiia_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Gaiia network site not found")}
|
||||
|
||||
network_site ->
|
||||
case Gaiia.update_network_site_mapping(network_site, %{site_id: nil}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_data()
|
||||
|> put_flash(:info, "Site unlinked")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlink site")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("link_device", %{"gaiia-id" => gaiia_id, "device-id" => device_id}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
case Gaiia.get_inventory_item(org.id, gaiia_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
|
||||
|
||||
item ->
|
||||
case Gaiia.update_inventory_item_mapping(item, %{device_id: device_id}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:linking_id, nil)
|
||||
|> assign(:search_query, "")
|
||||
|> assign(:search_results, [])
|
||||
|> load_data()
|
||||
|> put_flash(:info, "Device linked successfully")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to link device")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("unlink_device", %{"gaiia-id" => gaiia_id}, socket) do
|
||||
org = socket.assigns.organization
|
||||
|
||||
case Gaiia.get_inventory_item(org.id, gaiia_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Gaiia inventory item not found")}
|
||||
|
||||
item ->
|
||||
case Gaiia.update_inventory_item_mapping(item, %{device_id: nil}) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_data()
|
||||
|> put_flash(:info, "Device unlinked")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to unlink device")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp load_data(socket) do
|
||||
org_id = socket.assigns.organization.id
|
||||
tab = socket.assigns.tab
|
||||
filter = socket.assigns.filter
|
||||
|
||||
case tab do
|
||||
"sites" -> load_network_sites(socket, org_id, filter)
|
||||
"devices" -> load_inventory_items(socket, org_id, filter)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_network_sites(socket, org_id, filter) do
|
||||
sites =
|
||||
org_id
|
||||
|> Gaiia.list_network_sites()
|
||||
|> Repo.preload(:site)
|
||||
|> filter_by_mapping(filter)
|
||||
|
||||
suggestions =
|
||||
Enum.reduce(sites, %{}, fn site, acc ->
|
||||
matches = Gaiia.suggest_site_matches(org_id, site)
|
||||
if matches == [], do: acc, else: Map.put(acc, site.gaiia_id, matches)
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:items, sites)
|
||||
|> assign(:suggestions, suggestions)
|
||||
end
|
||||
|
||||
defp load_inventory_items(socket, org_id, filter) do
|
||||
items =
|
||||
org_id
|
||||
|> Gaiia.list_inventory_items()
|
||||
|> Repo.preload(:device)
|
||||
|> filter_by_mapping(filter)
|
||||
|
||||
suggestions =
|
||||
Enum.reduce(items, %{}, fn item, acc ->
|
||||
matches = Gaiia.suggest_device_matches(org_id, item)
|
||||
if matches == [], do: acc, else: Map.put(acc, item.gaiia_id, matches)
|
||||
end)
|
||||
|
||||
socket
|
||||
|> assign(:items, items)
|
||||
|> assign(:suggestions, suggestions)
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, "mapped") do
|
||||
Enum.filter(items, &mapped?/1)
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, "unmapped") do
|
||||
Enum.reject(items, &mapped?/1)
|
||||
end
|
||||
|
||||
defp filter_by_mapping(items, _), do: items
|
||||
|
||||
defp mapped?(%Gaiia.NetworkSite{site_id: site_id}), do: not is_nil(site_id)
|
||||
defp mapped?(%Gaiia.InventoryItem{device_id: device_id}), do: not is_nil(device_id)
|
||||
|
||||
defp search_sites(organization_id, query) do
|
||||
Sites.search_sites(organization_id, query)
|
||||
end
|
||||
end
|
||||
357
lib/towerops_web/live/org/gaiia_mapping_live.html.heex
Normal file
357
lib/towerops_web/live/org/gaiia_mapping_live.html.heex
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
>
|
||||
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
|
||||
<div class="mb-4">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@organization.slug}/settings/integrations"}
|
||||
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations
|
||||
</.link>
|
||||
</div>
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
Gaiia Entity Mapping
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Link Gaiia entities to your Towerops sites and devices. Match suggestions are shown in amber.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Tab navigation --%>
|
||||
<div class="mt-6 border-b border-gray-200 dark:border-white/10">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<.link
|
||||
id="tab-sites"
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=sites&filter=#{@filter}"
|
||||
}
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
if(@tab == "sites",
|
||||
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
|
||||
else:
|
||||
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Network Sites
|
||||
</.link>
|
||||
<.link
|
||||
id="tab-devices"
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=devices&filter=#{@filter}"
|
||||
}
|
||||
class={[
|
||||
"whitespace-nowrap border-b-2 px-1 py-4 text-sm font-medium",
|
||||
if(@tab == "devices",
|
||||
do: "border-indigo-500 text-indigo-600 dark:border-indigo-400 dark:text-indigo-400",
|
||||
else:
|
||||
"border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:border-gray-600 dark:hover:text-gray-300"
|
||||
)
|
||||
]}
|
||||
>
|
||||
Inventory Items
|
||||
</.link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<%!-- Filter tabs --%>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<.link
|
||||
:for={f <- ~w(all mapped unmapped)}
|
||||
patch={
|
||||
~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping?tab=#{@tab}&filter=#{f}"
|
||||
}
|
||||
class={[
|
||||
"rounded-full px-3 py-1 text-xs font-medium",
|
||||
if(@filter == f,
|
||||
do: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300",
|
||||
else:
|
||||
"bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-white/5 dark:text-gray-400 dark:hover:bg-white/10"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{String.capitalize(f)}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%!-- Content --%>
|
||||
<div class="mt-6">
|
||||
<%= if @items == [] do %>
|
||||
<div
|
||||
id="empty-state"
|
||||
class="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-700"
|
||||
>
|
||||
<.icon
|
||||
name={if(@tab == "sites", do: "hero-map-pin", else: "hero-cpu-chip")}
|
||||
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<h3 class="mt-2 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
<%= if @tab == "sites" do %>
|
||||
No network sites found
|
||||
<% else %>
|
||||
No inventory items found
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= case @filter do %>
|
||||
<% "mapped" -> %>
|
||||
No mapped entities yet. Sync from Gaiia and link entities below.
|
||||
<% "unmapped" -> %>
|
||||
All entities are mapped.
|
||||
<% _ -> %>
|
||||
No Gaiia data has been synced yet. Enable the Gaiia integration and run a sync.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-hidden rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
|
||||
<thead class="bg-gray-50 dark:bg-white/5">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Gaiia ID
|
||||
</th>
|
||||
<%= if @tab == "devices" do %>
|
||||
<th
|
||||
scope="col"
|
||||
class="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400 sm:table-cell"
|
||||
>
|
||||
IP Address
|
||||
</th>
|
||||
<% end %>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{if @tab == "sites", do: "Linked Site", else: "Linked Device"}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-white/5 dark:bg-transparent">
|
||||
<%= for item <- @items do %>
|
||||
<tr id={"row-#{item.id}"} class="hover:bg-gray-50 dark:hover:bg-white/5">
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
{item.name || "Unnamed"}
|
||||
<%= if Map.has_key?(@suggestions, item.gaiia_id) do %>
|
||||
<span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
|
||||
<.icon name="hero-light-bulb-mini" class="mr-0.5 h-3 w-3" /> Suggestion
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{item.gaiia_id}
|
||||
</td>
|
||||
<%= if @tab == "devices" do %>
|
||||
<td class="hidden whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400 sm:table-cell">
|
||||
{item.ip_address || "-"}
|
||||
</td>
|
||||
<% end %>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= if @tab == "sites" and item.site do %>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{item.site.name}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @tab == "devices" and item.device do %>
|
||||
<.link
|
||||
navigate={~p"/devices/#{item.device.id}"}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
{item.device.name || item.device.ip_address}
|
||||
</.link>
|
||||
<% end %>
|
||||
<%= if (@tab == "sites" and is_nil(item.site_id)) or (@tab == "devices" and is_nil(item.device_id)) do %>
|
||||
<span class="text-gray-400 dark:text-gray-500">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
|
||||
<%= if @tab == "sites" do %>
|
||||
<%= if item.site_id do %>
|
||||
<button
|
||||
type="button"
|
||||
id={"unlink-#{item.id}"}
|
||||
phx-click="unlink_site"
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
data-confirm="Unlink this site?"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
id={"link-#{item.id}"}
|
||||
phx-click="start_link"
|
||||
phx-value-id={item.gaiia_id}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= if item.device_id do %>
|
||||
<button
|
||||
type="button"
|
||||
id={"unlink-#{item.id}"}
|
||||
phx-click="unlink_device"
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
data-confirm="Unlink this device?"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Unlink
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
type="button"
|
||||
id={"link-#{item.id}"}
|
||||
phx-click="start_link"
|
||||
phx-value-id={item.gaiia_id}
|
||||
class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<%!-- Inline linking row --%>
|
||||
<%= if item.gaiia_id == @linking_id do %>
|
||||
<tr id={"linking-row-#{item.id}"} class="bg-indigo-50 dark:bg-indigo-900/20">
|
||||
<td colspan={if(@tab == "devices", do: "5", else: "4")} class="px-4 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<p class="mb-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<%= if @tab == "sites" do %>
|
||||
Search for a site to link to "{item.name}"
|
||||
<% else %>
|
||||
Search for a device to link to "{item.name}"
|
||||
<% end %>
|
||||
</p>
|
||||
<%!-- Show suggestions if available --%>
|
||||
<%= if suggestions = @suggestions[item.gaiia_id] do %>
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
Suggested matches:
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<%= for suggestion <- suggestions do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click={
|
||||
if(@tab == "sites", do: "link_site", else: "link_device")
|
||||
}
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
phx-value-site-id={
|
||||
if(@tab == "sites", do: suggestion.entity.id)
|
||||
}
|
||||
phx-value-device-id={
|
||||
if(@tab == "devices", do: suggestion.entity.id)
|
||||
}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300 dark:hover:bg-amber-900/40"
|
||||
>
|
||||
{suggestion.entity.name}
|
||||
<span class="rounded bg-amber-200 px-1 text-amber-700 dark:bg-amber-800 dark:text-amber-300">
|
||||
{suggestion.match_type}
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<form id="search-form" phx-change="search" class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
name="query"
|
||||
value={@search_query}
|
||||
placeholder={
|
||||
if(@tab == "sites",
|
||||
do: "Search by site name...",
|
||||
else: "Search by device name or IP..."
|
||||
)
|
||||
}
|
||||
autocomplete="off"
|
||||
phx-debounce="300"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<%= if @search_results != [] do %>
|
||||
<div class="mt-2 max-h-48 overflow-y-auto rounded-md border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-white/5">
|
||||
<li :for={result <- @search_results}>
|
||||
<button
|
||||
type="button"
|
||||
id={"select-#{result.id}"}
|
||||
phx-click={
|
||||
if(@tab == "sites", do: "link_site", else: "link_device")
|
||||
}
|
||||
phx-value-gaiia-id={item.gaiia_id}
|
||||
phx-value-site-id={if(@tab == "sites", do: result.id)}
|
||||
phx-value-device-id={if(@tab == "devices", do: result.id)}
|
||||
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-white/5"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{result.name || "Unnamed"}
|
||||
</span>
|
||||
<%= if @tab == "devices" do %>
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{Map.get(result, :ip_address, "")}
|
||||
<%= if site = Map.get(result, :site) do %>
|
||||
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
{site.name}
|
||||
</span>
|
||||
<% end %>
|
||||
</span>
|
||||
<% end %>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @search_query != "" and String.length(@search_query) >= 2 and @search_results == [] do %>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No results found matching "{@search_query}"
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-link"
|
||||
phx-click="cancel_link"
|
||||
class="self-start text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
|
@ -312,6 +312,7 @@ defmodule ToweropsWeb.Router do
|
|||
live "/settings/integrations", Org.IntegrationsLive, :index
|
||||
live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index
|
||||
live "/settings/integrations/preseem/insights", Org.PreseemInsightsLive, :index
|
||||
live "/settings/integrations/gaiia/mapping", Org.GaiiaMappingLive, :index
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
99
priv/repo/migrations/20260213160011_create_gaiia_tables.exs
Normal file
99
priv/repo/migrations/20260213160011_create_gaiia_tables.exs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateGaiiaTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:gaiia_accounts, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :gaiia_id, :string, null: false
|
||||
add :readable_id, :string
|
||||
add :name, :string
|
||||
add :status, :string
|
||||
add :account_type, :string
|
||||
add :address, :map
|
||||
add :subscription_count, :integer
|
||||
add :mrr, :decimal
|
||||
add :raw_data, :map
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:gaiia_accounts, [:organization_id, :gaiia_id])
|
||||
create index(:gaiia_accounts, [:organization_id])
|
||||
|
||||
create table(:gaiia_network_sites, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :gaiia_id, :string, null: false
|
||||
add :name, :string
|
||||
add :address, :map
|
||||
add :ip_blocks, {:array, :string}, default: []
|
||||
add :account_count, :integer
|
||||
add :total_mrr, :decimal
|
||||
add :site_id, references(:sites, type: :binary_id, on_delete: :nilify_all)
|
||||
add :raw_data, :map
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:gaiia_network_sites, [:organization_id, :gaiia_id])
|
||||
create index(:gaiia_network_sites, [:organization_id])
|
||||
create index(:gaiia_network_sites, [:site_id])
|
||||
|
||||
create table(:gaiia_inventory_items, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :gaiia_id, :string, null: false
|
||||
add :name, :string
|
||||
add :status, :string
|
||||
add :serial_number, :string
|
||||
add :mac_address, :string
|
||||
add :ip_address, :string
|
||||
add :model_name, :string
|
||||
add :manufacturer_name, :string
|
||||
add :category, :string
|
||||
add :assigned_account_gaiia_id, :string
|
||||
add :assigned_network_site_gaiia_id, :string
|
||||
add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all)
|
||||
add :raw_data, :map
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:gaiia_inventory_items, [:organization_id, :gaiia_id])
|
||||
create index(:gaiia_inventory_items, [:organization_id])
|
||||
create index(:gaiia_inventory_items, [:device_id])
|
||||
|
||||
create table(:gaiia_billing_subscriptions, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :gaiia_id, :string, null: false
|
||||
add :account_gaiia_id, :string
|
||||
add :status, :string
|
||||
add :product_name, :string
|
||||
add :mrr_amount, :decimal
|
||||
add :currency, :string
|
||||
add :speed_download, :integer
|
||||
add :speed_upload, :integer
|
||||
add :raw_data, :map
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:gaiia_billing_subscriptions, [:organization_id, :gaiia_id])
|
||||
create index(:gaiia_billing_subscriptions, [:organization_id])
|
||||
create index(:gaiia_billing_subscriptions, [:account_gaiia_id])
|
||||
end
|
||||
end
|
||||
64
test/towerops/gaiia/account_test.exs
Normal file
64
test/towerops/gaiia/account_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule Towerops.Gaiia.AccountTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia.Account
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields", %{org: org} do
|
||||
changeset =
|
||||
Account.changeset(%Account{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "acct-123"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without gaiia_id", %{org: org} do
|
||||
changeset =
|
||||
Account.changeset(%Account{}, %{
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).gaiia_id
|
||||
end
|
||||
|
||||
test "invalid without organization_id" do
|
||||
changeset =
|
||||
Account.changeset(%Account{}, %{
|
||||
gaiia_id: "acct-123"
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).organization_id
|
||||
end
|
||||
|
||||
test "accepts all optional fields", %{org: org} do
|
||||
changeset =
|
||||
Account.changeset(%Account{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "acct-123",
|
||||
readable_id: "1001",
|
||||
name: "John Doe",
|
||||
status: "active",
|
||||
account_type: "residential",
|
||||
address: %{"city" => "Portland"},
|
||||
subscription_count: 2,
|
||||
mrr: Decimal.new("79.99"),
|
||||
raw_data: %{"extra" => "data"}
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
54
test/towerops/gaiia/billing_subscription_test.exs
Normal file
54
test/towerops/gaiia/billing_subscription_test.exs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
defmodule Towerops.Gaiia.BillingSubscriptionTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia.BillingSubscription
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields", %{org: org} do
|
||||
changeset =
|
||||
BillingSubscription.changeset(%BillingSubscription{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "sub-123"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without gaiia_id", %{org: org} do
|
||||
changeset =
|
||||
BillingSubscription.changeset(%BillingSubscription{}, %{
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).gaiia_id
|
||||
end
|
||||
|
||||
test "accepts all optional fields", %{org: org} do
|
||||
changeset =
|
||||
BillingSubscription.changeset(%BillingSubscription{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "sub-123",
|
||||
account_gaiia_id: "acct-456",
|
||||
status: "active",
|
||||
product_name: "100Mbps Unlimited",
|
||||
mrr_amount: Decimal.new("79.99"),
|
||||
currency: "USD",
|
||||
speed_download: 100,
|
||||
speed_upload: 25,
|
||||
raw_data: %{"extra" => "data"}
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
190
test/towerops/gaiia/client_test.exs
Normal file
190
test/towerops/gaiia/client_test.exs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
defmodule Towerops.Gaiia.ClientTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
alias Towerops.Gaiia.Client
|
||||
|
||||
describe "test_connection/2" do
|
||||
test "returns ok on successful connection" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{"data" => %{"viewer" => %{"id" => "user-1"}}})
|
||||
end)
|
||||
|
||||
assert {:ok, %{}} = Client.test_connection("test-key")
|
||||
end
|
||||
|
||||
test "returns error on unauthorized" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(401)
|
||||
|> Req.Test.json(%{"error" => "unauthorized"})
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Client.test_connection("bad-key")
|
||||
end
|
||||
|
||||
test "returns error on forbidden" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(403)
|
||||
|> Req.Test.json(%{"error" => "forbidden"})
|
||||
end)
|
||||
|
||||
assert {:error, :forbidden} = Client.test_connection("bad-key")
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_accounts/2" do
|
||||
test "returns accounts from single page" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"accounts" => %{
|
||||
"edges" => [
|
||||
%{"node" => %{"id" => "acct-1", "name" => "Alice"}},
|
||||
%{"node" => %{"id" => "acct-2", "name" => "Bob"}}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, accounts} = Client.list_accounts("test-key")
|
||||
assert length(accounts) == 2
|
||||
assert Enum.map(accounts, & &1["id"]) == ["acct-1", "acct-2"]
|
||||
end
|
||||
|
||||
test "handles paginated results" do
|
||||
call_count = :counters.new(1, [:atomics])
|
||||
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
:counters.add(call_count, 1, 1)
|
||||
count = :counters.get(call_count, 1)
|
||||
|
||||
if count == 1 do
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"accounts" => %{
|
||||
"edges" => [%{"node" => %{"id" => "acct-1", "name" => "Alice"}}],
|
||||
"pageInfo" => %{"hasNextPage" => true, "endCursor" => "cursor-1"}
|
||||
}
|
||||
}
|
||||
})
|
||||
else
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"accounts" => %{
|
||||
"edges" => [%{"node" => %{"id" => "acct-2", "name" => "Bob"}}],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, accounts} = Client.list_accounts("test-key")
|
||||
assert length(accounts) == 2
|
||||
end
|
||||
|
||||
test "returns error on API failure" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(401)
|
||||
|> Req.Test.json(%{"error" => "unauthorized"})
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Client.list_accounts("bad-key")
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_network_sites/2" do
|
||||
test "returns network sites" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"networkSites" => %{
|
||||
"edges" => [
|
||||
%{"node" => %{"id" => "site-1", "name" => "Tower 1"}}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, sites} = Client.list_network_sites("test-key")
|
||||
assert length(sites) == 1
|
||||
assert hd(sites)["name"] == "Tower 1"
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_inventory_items/2" do
|
||||
test "returns inventory items" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"inventoryItems" => %{
|
||||
"edges" => [
|
||||
%{"node" => %{"id" => "item-1", "name" => "AF5XHD"}}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, items} = Client.list_inventory_items("test-key")
|
||||
assert length(items) == 1
|
||||
assert hd(items)["name"] == "AF5XHD"
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_billing_subscriptions/2" do
|
||||
test "returns billing subscriptions" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"data" => %{
|
||||
"billingSubscriptions" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "sub-1",
|
||||
"billingSubscriptionStatus" => "Active",
|
||||
"product" => %{"name" => "100Mbps"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:ok, subs} = Client.list_billing_subscriptions("test-key")
|
||||
assert length(subs) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "query/4" do
|
||||
test "handles GraphQL errors" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Req.Test.json(conn, %{
|
||||
"errors" => [%{"message" => "Field not found"}]
|
||||
})
|
||||
end)
|
||||
|
||||
assert {:error, {:graphql_errors, _}} = Client.query("test-key", "{ invalid }")
|
||||
end
|
||||
|
||||
test "handles rate limiting" do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_header("retry-after", "30")
|
||||
|> Plug.Conn.put_status(429)
|
||||
|> Req.Test.json(%{"error" => "rate limited"})
|
||||
end)
|
||||
|
||||
assert {:error, {:rate_limited, 30}} = Client.query("test-key", "{ viewer { id } }")
|
||||
end
|
||||
end
|
||||
end
|
||||
57
test/towerops/gaiia/inventory_item_test.exs
Normal file
57
test/towerops/gaiia/inventory_item_test.exs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
defmodule Towerops.Gaiia.InventoryItemTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia.InventoryItem
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields", %{org: org} do
|
||||
changeset =
|
||||
InventoryItem.changeset(%InventoryItem{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "item-123"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without gaiia_id", %{org: org} do
|
||||
changeset =
|
||||
InventoryItem.changeset(%InventoryItem{}, %{
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).gaiia_id
|
||||
end
|
||||
|
||||
test "accepts all optional fields", %{org: org} do
|
||||
changeset =
|
||||
InventoryItem.changeset(%InventoryItem{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "item-123",
|
||||
name: "AF5XHD",
|
||||
status: "active",
|
||||
serial_number: "SN12345",
|
||||
mac_address: "AA:BB:CC:DD:EE:FF",
|
||||
ip_address: "10.0.0.1",
|
||||
model_name: "airFiber 5XHD",
|
||||
manufacturer_name: "Ubiquiti",
|
||||
category: "AP",
|
||||
assigned_account_gaiia_id: "acct-456",
|
||||
assigned_network_site_gaiia_id: "site-789",
|
||||
raw_data: %{"extra" => "data"}
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
395
test/towerops/gaiia/match_suggestions_test.exs
Normal file
395
test/towerops/gaiia/match_suggestions_test.exs
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
defmodule Towerops.Gaiia.MatchSuggestionsTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Sites
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org, user: user}
|
||||
end
|
||||
|
||||
describe "suggest_site_matches/2" do
|
||||
test "returns empty list when no sites exist", %{org: org} do
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
|
||||
end
|
||||
|
||||
test "returns empty list when no names match", %{org: org} do
|
||||
{:ok, _site} =
|
||||
Sites.create_site(%{
|
||||
name: "Completely Different",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
|
||||
end
|
||||
|
||||
test "matches site by exact name case-insensitively", %{org: org} do
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{
|
||||
name: "Tower Alpha",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "tower alpha"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched_site, match_type: "name", confidence: _}] = results
|
||||
assert matched_site.id == site.id
|
||||
end
|
||||
|
||||
test "matches site by partial name (contains)", %{org: org} do
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{
|
||||
name: "North Tower Alpha Sector",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched_site, match_type: "name"}] = results
|
||||
assert matched_site.id == site.id
|
||||
end
|
||||
|
||||
test "matches when towerops site name is substring of gaiia name", %{org: org} do
|
||||
{:ok, site} =
|
||||
Sites.create_site(%{
|
||||
name: "Alpha",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha Sector"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched_site, match_type: "name"}] = results
|
||||
assert matched_site.id == site.id
|
||||
end
|
||||
|
||||
test "returns multiple matching sites", %{org: org} do
|
||||
{:ok, site1} =
|
||||
Sites.create_site(%{
|
||||
name: "Tower Alpha",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, site2} =
|
||||
Sites.create_site(%{
|
||||
name: "Tower Alpha East",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_site_matches(org.id, gaiia_site)
|
||||
|
||||
assert length(results) == 2
|
||||
matched_ids = Enum.map(results, fn %{entity: s} -> s.id end)
|
||||
assert site1.id in matched_ids
|
||||
assert site2.id in matched_ids
|
||||
end
|
||||
|
||||
test "does not match sites from other organizations", %{org: org, user: _user} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
|
||||
{:ok, _other_site} =
|
||||
Sites.create_site(%{
|
||||
name: "Tower Alpha",
|
||||
organization_id: other_org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: "Tower Alpha"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
|
||||
end
|
||||
|
||||
test "does not match when gaiia site has no name", %{org: org} do
|
||||
{:ok, _site} =
|
||||
Sites.create_site(%{
|
||||
name: "Tower Alpha",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
{:ok, gaiia_site} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "gaiia-site-1",
|
||||
name: nil
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_site_matches(org.id, gaiia_site) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "suggest_device_matches/2" do
|
||||
test "returns empty list when no devices exist", %{org: org} do
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
|
||||
end
|
||||
|
||||
test "returns empty list when nothing matches", %{org: org} do
|
||||
_device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Switch Bravo",
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
|
||||
end
|
||||
|
||||
test "matches device by exact IP address", %{org: org} do
|
||||
device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Switch One",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Totally Different Name",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched, match_type: "ip", confidence: confidence}] = results
|
||||
assert matched.id == device.id
|
||||
assert confidence in [:high, :medium]
|
||||
end
|
||||
|
||||
test "matches device by name case-insensitively", %{org: org} do
|
||||
device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "router alpha",
|
||||
ip_address: "10.99.99.99"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched, match_type: "name", confidence: :low}] = results
|
||||
assert matched.id == device.id
|
||||
end
|
||||
|
||||
test "matches device by partial name (contains)", %{org: org} do
|
||||
device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Core Router Alpha Main",
|
||||
ip_address: "192.168.1.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.99.99.99"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
assert length(results) == 1
|
||||
assert [%{entity: matched, match_type: "name"}] = results
|
||||
assert matched.id == device.id
|
||||
end
|
||||
|
||||
test "returns multiple match types for same device sorted by confidence", %{org: org} do
|
||||
device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
assert length(results) >= 2
|
||||
|
||||
# All matches should reference the same device
|
||||
matched_ids = results |> Enum.map(fn %{entity: d} -> d.id end) |> Enum.uniq()
|
||||
assert matched_ids == [device.id]
|
||||
|
||||
# IP match should come before name match (higher confidence first)
|
||||
match_types = Enum.map(results, fn %{match_type: t} -> t end)
|
||||
ip_index = Enum.find_index(match_types, &(&1 == "ip"))
|
||||
name_index = Enum.find_index(match_types, &(&1 == "name"))
|
||||
assert ip_index < name_index
|
||||
end
|
||||
|
||||
test "does not match devices from other organizations", %{org: org} do
|
||||
other_user = user_fixture()
|
||||
other_org = organization_fixture(other_user.id)
|
||||
|
||||
_other_device =
|
||||
device_fixture(%{
|
||||
organization_id: other_org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
|
||||
end
|
||||
|
||||
test "does not match when gaiia item has neither IP nor name", %{org: org} do
|
||||
_device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1"
|
||||
})
|
||||
|
||||
assert Gaiia.suggest_device_matches(org.id, gaiia_item) == []
|
||||
end
|
||||
|
||||
test "IP match has higher confidence than name match", %{org: org} do
|
||||
device_ip =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Unrelated Name",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
device_name =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "192.168.99.99"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
assert length(results) == 2
|
||||
|
||||
# IP match should appear first (higher confidence)
|
||||
assert [first | _rest] = results
|
||||
assert first.entity.id == device_ip.id
|
||||
assert first.match_type == "ip"
|
||||
|
||||
# Name match should be last
|
||||
name_match = Enum.find(results, fn %{match_type: t} -> t == "name" end)
|
||||
assert name_match.entity.id == device_name.id
|
||||
assert name_match.confidence == :low
|
||||
end
|
||||
|
||||
test "does not produce duplicate matches for same device and match type", %{org: org} do
|
||||
device =
|
||||
device_fixture(%{
|
||||
organization_id: org.id,
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
{:ok, gaiia_item} =
|
||||
Gaiia.upsert_inventory_item(org.id, %{
|
||||
gaiia_id: "gaiia-item-1",
|
||||
name: "Router Alpha",
|
||||
ip_address: "10.0.0.1"
|
||||
})
|
||||
|
||||
results = Gaiia.suggest_device_matches(org.id, gaiia_item)
|
||||
|
||||
# Each match type should appear at most once per device
|
||||
type_device_pairs =
|
||||
Enum.map(results, fn %{entity: d, match_type: t} -> {d.id, t} end)
|
||||
|
||||
assert type_device_pairs == Enum.uniq(type_device_pairs)
|
||||
|
||||
# Verify the device is present
|
||||
assert Enum.any?(results, fn %{entity: d} -> d.id == device.id end)
|
||||
end
|
||||
end
|
||||
end
|
||||
63
test/towerops/gaiia/network_site_test.exs
Normal file
63
test/towerops/gaiia/network_site_test.exs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
defmodule Towerops.Gaiia.NetworkSiteTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia.NetworkSite
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "changeset/2" do
|
||||
test "valid with required fields", %{org: org} do
|
||||
changeset =
|
||||
NetworkSite.changeset(%NetworkSite{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "site-123"
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "invalid without gaiia_id", %{org: org} do
|
||||
changeset =
|
||||
NetworkSite.changeset(%NetworkSite{}, %{
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
refute changeset.valid?
|
||||
assert "can't be blank" in errors_on(changeset).gaiia_id
|
||||
end
|
||||
|
||||
test "accepts ip_blocks array", %{org: org} do
|
||||
changeset =
|
||||
NetworkSite.changeset(%NetworkSite{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "site-123",
|
||||
ip_blocks: ["10.0.0.0/24", "192.168.1.0/24"]
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
||||
test "accepts all optional fields", %{org: org} do
|
||||
changeset =
|
||||
NetworkSite.changeset(%NetworkSite{}, %{
|
||||
organization_id: org.id,
|
||||
gaiia_id: "site-123",
|
||||
name: "Tower 3",
|
||||
address: %{"city" => "Portland"},
|
||||
ip_blocks: ["10.0.0.0/24"],
|
||||
account_count: 147,
|
||||
total_mrr: Decimal.new("17640.00"),
|
||||
raw_data: %{"extra" => "data"}
|
||||
})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
end
|
||||
end
|
||||
254
test/towerops/gaiia/sync_test.exs
Normal file
254
test/towerops/gaiia/sync_test.exs
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
defmodule Towerops.Gaiia.SyncTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.IntegrationsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Gaiia.Client
|
||||
alias Towerops.Gaiia.Sync
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
integration = integration_fixture(org.id, %{provider: "gaiia"})
|
||||
%{org: org, integration: integration}
|
||||
end
|
||||
|
||||
describe "sync_organization/1" do
|
||||
test "syncs all entity types", %{integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
assert {:ok, result} = Sync.sync_organization(integration)
|
||||
assert result.accounts == 1
|
||||
assert result.network_sites == 1
|
||||
assert result.inventory_items == 1
|
||||
assert result.subscriptions == 1
|
||||
|
||||
updated = Repo.reload!(integration)
|
||||
assert updated.last_sync_status == "success"
|
||||
assert updated.last_synced_at
|
||||
end
|
||||
|
||||
test "maps account fields correctly", %{org: org, integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
{:ok, _} = Sync.sync_organization(integration)
|
||||
|
||||
[account] = Gaiia.list_accounts(org.id)
|
||||
assert account.gaiia_id == "acct-1"
|
||||
assert account.readable_id == "1001"
|
||||
assert account.name == "Alice Smith"
|
||||
assert account.status == "Active"
|
||||
assert account.account_type == "Residential"
|
||||
assert account.subscription_count == 1
|
||||
assert account.address["city"] == "Portland"
|
||||
end
|
||||
|
||||
test "maps network site fields correctly", %{org: org, integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
{:ok, _} = Sync.sync_organization(integration)
|
||||
|
||||
[site] = Gaiia.list_network_sites(org.id)
|
||||
assert site.gaiia_id == "site-1"
|
||||
assert site.name == "Tower 3"
|
||||
assert site.ip_blocks == ["10.0.0.0/24"]
|
||||
end
|
||||
|
||||
test "maps inventory item fields correctly", %{org: org, integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
{:ok, _} = Sync.sync_organization(integration)
|
||||
|
||||
[item] = Gaiia.list_inventory_items(org.id)
|
||||
assert item.gaiia_id == "item-1"
|
||||
assert item.name == "AF5XHD"
|
||||
assert item.serial_number == "SN12345"
|
||||
assert item.mac_address == "AA:BB:CC:DD:EE:FF"
|
||||
assert item.model_name == "airFiber 5XHD"
|
||||
assert item.manufacturer_name == "Ubiquiti"
|
||||
assert item.category == "AP"
|
||||
end
|
||||
|
||||
test "maps billing subscription fields correctly", %{org: org, integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
{:ok, _} = Sync.sync_organization(integration)
|
||||
|
||||
[sub] = Gaiia.list_billing_subscriptions(org.id)
|
||||
assert sub.gaiia_id == "sub-1"
|
||||
assert sub.account_gaiia_id == "acct-1"
|
||||
assert sub.status == "Active"
|
||||
assert sub.product_name == "100Mbps Unlimited"
|
||||
end
|
||||
|
||||
test "marks sync as failed on API error", %{integration: integration} do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_status(401)
|
||||
|> Req.Test.json(%{"error" => "unauthorized"})
|
||||
end)
|
||||
|
||||
assert {:error, :unauthorized} = Sync.sync_organization(integration)
|
||||
|
||||
updated = Repo.reload!(integration)
|
||||
assert updated.last_sync_status == "failed"
|
||||
end
|
||||
|
||||
test "upserts existing records without duplicating", %{org: org, integration: integration} do
|
||||
stub_all_endpoints()
|
||||
|
||||
{:ok, _} = Sync.sync_organization(integration)
|
||||
{:ok, _} = Sync.sync_organization(Repo.reload!(integration))
|
||||
|
||||
assert length(Gaiia.list_accounts(org.id)) == 1
|
||||
assert length(Gaiia.list_network_sites(org.id)) == 1
|
||||
assert length(Gaiia.list_inventory_items(org.id)) == 1
|
||||
assert length(Gaiia.list_billing_subscriptions(org.id)) == 1
|
||||
end
|
||||
end
|
||||
|
||||
defp stub_all_endpoints do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
{:ok, body, _conn} = Plug.Conn.read_body(conn)
|
||||
decoded = Jason.decode!(body)
|
||||
query = decoded["query"]
|
||||
|
||||
response =
|
||||
cond do
|
||||
String.contains?(query, "ListAccounts") -> accounts_response()
|
||||
String.contains?(query, "ListNetworkSites") -> network_sites_response()
|
||||
String.contains?(query, "ListInventoryItems") -> inventory_items_response()
|
||||
String.contains?(query, "ListBillingSubscriptions") -> billing_subscriptions_response()
|
||||
true -> %{"data" => %{}}
|
||||
end
|
||||
|
||||
Req.Test.json(conn, response)
|
||||
end)
|
||||
end
|
||||
|
||||
defp accounts_response do
|
||||
%{
|
||||
"data" => %{
|
||||
"accounts" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "acct-1",
|
||||
"readableId" => "1001",
|
||||
"name" => "Alice Smith",
|
||||
"accountStatus" => "Active",
|
||||
"accountType" => "Residential",
|
||||
"physicalAddress" => %{
|
||||
"line1" => "123 Main St",
|
||||
"city" => "Portland",
|
||||
"state" => "OR",
|
||||
"zip" => "97201",
|
||||
"country" => "US",
|
||||
"latitude" => 45.5,
|
||||
"longitude" => -122.6
|
||||
},
|
||||
"accountBillingSubscriptions" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "sub-1",
|
||||
"billingSubscriptionStatus" => "Active"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp network_sites_response do
|
||||
%{
|
||||
"data" => %{
|
||||
"networkSites" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "site-1",
|
||||
"name" => "Tower 3",
|
||||
"address" => %{
|
||||
"line1" => "456 Tower Rd",
|
||||
"city" => "Portland",
|
||||
"state" => "OR",
|
||||
"zip" => "97201",
|
||||
"country" => "US",
|
||||
"latitude" => 45.5,
|
||||
"longitude" => -122.6
|
||||
},
|
||||
"networkSiteIpBlocks" => %{
|
||||
"edges" => [
|
||||
%{"node" => %{"subnet" => "10.0.0.0/24"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp inventory_items_response do
|
||||
%{
|
||||
"data" => %{
|
||||
"inventoryItems" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "item-1",
|
||||
"name" => "AF5XHD",
|
||||
"inventoryItemStatus" => "Active",
|
||||
"serialNumber" => "SN12345",
|
||||
"macAddress" => "AA:BB:CC:DD:EE:FF",
|
||||
"ipAddress" => "10.0.0.1",
|
||||
"inventoryModel" => %{
|
||||
"name" => "airFiber 5XHD",
|
||||
"manufacturer" => %{"name" => "Ubiquiti"}
|
||||
},
|
||||
"inventoryModelCategory" => %{"name" => "AP"},
|
||||
"account" => %{"id" => "acct-1"},
|
||||
"networkSite" => %{"id" => "site-1"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp billing_subscriptions_response do
|
||||
%{
|
||||
"data" => %{
|
||||
"billingSubscriptions" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "sub-1",
|
||||
"billingSubscriptionStatus" => "Active",
|
||||
"product" => %{"name" => "100Mbps Unlimited"},
|
||||
"recurringAmount" => "79.99",
|
||||
"currency" => "USD",
|
||||
"account" => %{"id" => "acct-1"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
128
test/towerops/gaiia_test.exs
Normal file
128
test/towerops/gaiia_test.exs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
defmodule Towerops.GaiiaTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "accounts" do
|
||||
test "upsert_account/2 creates a new account", %{org: org} do
|
||||
attrs = %{gaiia_id: "acct-1", name: "Alice", status: "Active"}
|
||||
assert {:ok, account} = Gaiia.upsert_account(org.id, attrs)
|
||||
assert account.gaiia_id == "acct-1"
|
||||
assert account.name == "Alice"
|
||||
end
|
||||
|
||||
test "upsert_account/2 updates existing account", %{org: org} do
|
||||
attrs = %{gaiia_id: "acct-1", name: "Alice"}
|
||||
{:ok, _} = Gaiia.upsert_account(org.id, attrs)
|
||||
{:ok, updated} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice Smith"})
|
||||
assert updated.name == "Alice Smith"
|
||||
assert length(Gaiia.list_accounts(org.id)) == 1
|
||||
end
|
||||
|
||||
test "list_accounts/1 returns accounts for organization", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice"})
|
||||
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-2", name: "Bob"})
|
||||
assert length(Gaiia.list_accounts(org.id)) == 2
|
||||
end
|
||||
|
||||
test "get_account/2 returns account by gaiia_id", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_account(org.id, %{gaiia_id: "acct-1", name: "Alice"})
|
||||
assert %{name: "Alice"} = Gaiia.get_account(org.id, "acct-1")
|
||||
assert is_nil(Gaiia.get_account(org.id, "nonexistent"))
|
||||
end
|
||||
end
|
||||
|
||||
describe "network_sites" do
|
||||
test "upsert_network_site/2 creates a new site", %{org: org} do
|
||||
attrs = %{gaiia_id: "site-1", name: "Tower 3", ip_blocks: ["10.0.0.0/24"]}
|
||||
assert {:ok, site} = Gaiia.upsert_network_site(org.id, attrs)
|
||||
assert site.gaiia_id == "site-1"
|
||||
assert site.ip_blocks == ["10.0.0.0/24"]
|
||||
end
|
||||
|
||||
test "upsert_network_site/2 preserves site_id mapping on update", %{org: org} do
|
||||
{:ok, site} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 3"})
|
||||
assert is_nil(site.site_id)
|
||||
|
||||
# Simulate user mapping
|
||||
towerops_site = create_site(org.id)
|
||||
Gaiia.update_network_site_mapping(site, %{site_id: towerops_site.id})
|
||||
|
||||
# Re-sync should preserve mapping
|
||||
{:ok, updated} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 3 Updated"})
|
||||
assert updated.name == "Tower 3 Updated"
|
||||
assert updated.site_id == towerops_site.id
|
||||
end
|
||||
|
||||
test "list_network_sites/1 returns sites for organization", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_network_site(org.id, %{gaiia_id: "site-1", name: "Tower 1"})
|
||||
assert length(Gaiia.list_network_sites(org.id)) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "inventory_items" do
|
||||
test "upsert_inventory_item/2 creates a new item", %{org: org} do
|
||||
attrs = %{gaiia_id: "item-1", name: "AF5XHD", serial_number: "SN123"}
|
||||
assert {:ok, item} = Gaiia.upsert_inventory_item(org.id, attrs)
|
||||
assert item.serial_number == "SN123"
|
||||
end
|
||||
|
||||
test "upsert_inventory_item/2 preserves device_id mapping on update", %{org: org} do
|
||||
{:ok, item} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
|
||||
assert is_nil(item.device_id)
|
||||
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
Gaiia.update_inventory_item_mapping(item, %{device_id: device.id})
|
||||
|
||||
{:ok, updated} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD v2"})
|
||||
assert updated.name == "AF5XHD v2"
|
||||
assert updated.device_id == device.id
|
||||
end
|
||||
|
||||
test "list_inventory_items/1 returns items for organization", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_inventory_item(org.id, %{gaiia_id: "item-1", name: "AF5XHD"})
|
||||
assert length(Gaiia.list_inventory_items(org.id)) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "billing_subscriptions" do
|
||||
test "upsert_billing_subscription/2 creates a new subscription", %{org: org} do
|
||||
attrs = %{gaiia_id: "sub-1", account_gaiia_id: "acct-1", product_name: "100Mbps"}
|
||||
assert {:ok, sub} = Gaiia.upsert_billing_subscription(org.id, attrs)
|
||||
assert sub.product_name == "100Mbps"
|
||||
end
|
||||
|
||||
test "list_billing_subscriptions_for_account/2 filters by account", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-1", account_gaiia_id: "acct-1"})
|
||||
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2", account_gaiia_id: "acct-2"})
|
||||
|
||||
assert length(Gaiia.list_billing_subscriptions_for_account(org.id, "acct-1")) == 1
|
||||
end
|
||||
|
||||
test "list_billing_subscriptions/1 returns all subscriptions", %{org: org} do
|
||||
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-1"})
|
||||
{:ok, _} = Gaiia.upsert_billing_subscription(org.id, %{gaiia_id: "sub-2"})
|
||||
assert length(Gaiia.list_billing_subscriptions(org.id)) == 2
|
||||
end
|
||||
end
|
||||
|
||||
defp create_site(organization_id) do
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Site #{System.unique_integer([:positive])}",
|
||||
organization_id: organization_id
|
||||
})
|
||||
|
||||
site
|
||||
end
|
||||
end
|
||||
86
test/towerops/workers/gaiia_sync_worker_test.exs
Normal file
86
test/towerops/workers/gaiia_sync_worker_test.exs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
defmodule Towerops.Workers.GaiiaSyncWorkerTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.IntegrationsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Gaiia.Client
|
||||
alias Towerops.Workers.GaiiaSyncWorker
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
integration = integration_fixture(org.id, %{provider: "gaiia"})
|
||||
%{org: org, integration: integration}
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "syncs enabled gaiia integrations", %{org: org} do
|
||||
stub_all_endpoints()
|
||||
|
||||
assert :ok = GaiiaSyncWorker.perform(%Oban.Job{})
|
||||
|
||||
# Verify data was synced
|
||||
assert length(Towerops.Gaiia.list_accounts(org.id)) == 1
|
||||
end
|
||||
|
||||
test "skips recently synced integrations", %{integration: integration} do
|
||||
# Mark as recently synced
|
||||
Towerops.Integrations.update_sync_status(integration, "success")
|
||||
|
||||
# Should skip since it was just synced
|
||||
assert :ok = GaiiaSyncWorker.perform(%Oban.Job{})
|
||||
end
|
||||
end
|
||||
|
||||
defp stub_all_endpoints do
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
{:ok, body, _conn} = Plug.Conn.read_body(conn)
|
||||
decoded = Jason.decode!(body)
|
||||
query = decoded["query"]
|
||||
|
||||
response =
|
||||
cond do
|
||||
String.contains?(query, "ListAccounts") -> accounts_response()
|
||||
String.contains?(query, "ListNetworkSites") -> empty_response("networkSites")
|
||||
String.contains?(query, "ListInventoryItems") -> empty_response("inventoryItems")
|
||||
String.contains?(query, "ListBillingSubscriptions") -> empty_response("billingSubscriptions")
|
||||
true -> %{"data" => %{}}
|
||||
end
|
||||
|
||||
Req.Test.json(conn, response)
|
||||
end)
|
||||
end
|
||||
|
||||
defp accounts_response do
|
||||
%{
|
||||
"data" => %{
|
||||
"accounts" => %{
|
||||
"edges" => [
|
||||
%{
|
||||
"node" => %{
|
||||
"id" => "acct-1",
|
||||
"name" => "Test Account",
|
||||
"accountStatus" => "Active",
|
||||
"accountBillingSubscriptions" => %{"edges" => []}
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp empty_response(root_key) do
|
||||
%{
|
||||
"data" => %{
|
||||
root_key => %{
|
||||
"edges" => [],
|
||||
"pageInfo" => %{"hasNextPage" => false, "endCursor" => nil}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue