- Fetch MAC address from Gaiia custom fields during inventory sync - Remove name-based device matching (model names caused false matches) - Keep IP-only matching for device suggestions - Make unmapped rows clickable in the Gaiia mapping table - Point authenticated logo link to /dashboard instead of /orgs - Fix flaky monitor test teardown race condition
350 lines
8.4 KiB
Elixir
350 lines
8.4 KiB
Elixir
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
|
|
status {
|
|
id
|
|
name
|
|
type
|
|
}
|
|
type {
|
|
id
|
|
name
|
|
}
|
|
physicalAddress {
|
|
line1
|
|
locality
|
|
region
|
|
postalCode
|
|
country
|
|
latitude
|
|
longitude
|
|
}
|
|
billingSubscriptions(first: 100) {
|
|
edges {
|
|
node {
|
|
id
|
|
status
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
@network_sites_query """
|
|
query ListNetworkSites($first: Int!, $after: String) {
|
|
networkSites(first: $first, after: $after) {
|
|
edges {
|
|
node {
|
|
id
|
|
name
|
|
address {
|
|
line1
|
|
locality
|
|
region
|
|
postalCode
|
|
country
|
|
latitude
|
|
longitude
|
|
}
|
|
ipBlocks(first: 100) {
|
|
edges {
|
|
node {
|
|
id
|
|
block
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
@inventory_items_query """
|
|
query ListInventoryItems($first: Int!, $after: String) {
|
|
inventoryItems(first: $first, after: $after) {
|
|
edges {
|
|
node {
|
|
id
|
|
status
|
|
ipAddressV4
|
|
model {
|
|
name
|
|
manufacturer {
|
|
name
|
|
}
|
|
category {
|
|
name
|
|
}
|
|
}
|
|
assignation {
|
|
assigneeType
|
|
assignee {
|
|
... on Account { id }
|
|
... on NetworkSite { id }
|
|
}
|
|
}
|
|
fields(first: 10) {
|
|
edges {
|
|
node {
|
|
data
|
|
modelField { name }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
@billing_subscriptions_query """
|
|
query ListBillingSubscriptions($first: Int!, $after: String) {
|
|
billingSubscriptions(first: $first, after: $after) {
|
|
edges {
|
|
node {
|
|
id
|
|
status
|
|
productVersion {
|
|
product {
|
|
name
|
|
}
|
|
price
|
|
currency
|
|
}
|
|
entity {
|
|
... on Account { id }
|
|
}
|
|
}
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
# --- Mutations ---
|
|
|
|
@create_ticket_mutation """
|
|
mutation CreateExternalTicket($subject: String!, $description: String!, $entityId: ID, $entityType: String) {
|
|
createExternalTicket(input: {
|
|
subject: $subject,
|
|
description: $description,
|
|
entityId: $entityId,
|
|
entityType: $entityType
|
|
}) {
|
|
id
|
|
subject
|
|
}
|
|
}
|
|
"""
|
|
|
|
@create_note_mutation """
|
|
mutation CreateNote($entityId: ID!, $entityType: String!, $body: String!) {
|
|
createNote(input: {
|
|
entityId: $entityId,
|
|
entityType: $entityType,
|
|
body: $body
|
|
}) {
|
|
id
|
|
}
|
|
}
|
|
"""
|
|
|
|
@update_inventory_item_mutation """
|
|
mutation UpdateInventoryItem($id: ID!, $input: UpdateInventoryItemInput!) {
|
|
updateInventoryItem(id: $id, input: $input) {
|
|
id
|
|
ipAddressV4
|
|
}
|
|
}
|
|
"""
|
|
|
|
@doc "Create an external ticket in Gaiia."
|
|
def create_ticket(api_key, attrs, opts \\ []) do
|
|
query(api_key, @create_ticket_mutation, attrs, opts)
|
|
end
|
|
|
|
@doc "Create a note on a Gaiia entity (account, inventory item, etc)."
|
|
def create_note(api_key, entity_type, entity_id, body, opts \\ []) do
|
|
variables = %{
|
|
"entityId" => entity_id,
|
|
"entityType" => entity_type,
|
|
"body" => body
|
|
}
|
|
|
|
query(api_key, @create_note_mutation, variables, opts)
|
|
end
|
|
|
|
@doc "Update an inventory item in Gaiia."
|
|
def update_inventory_item(api_key, item_id, input, opts \\ []) do
|
|
query(api_key, @update_inventory_item_mutation, %{"id" => item_id, "input" => input}, opts)
|
|
end
|
|
|
|
# --- Queries ---
|
|
|
|
@doc "Test the connection to Gaiia by fetching a single account."
|
|
def test_connection(api_key, opts \\ []) do
|
|
case query(api_key, "{ accounts(first: 1) { edges { node { 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: 400, body: %{"errors" => errors}}} ->
|
|
{: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}
|
|
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|
|
|> Map.new()
|
|
|
|
case query(api_key, query_string, variables, opts) do
|
|
{:ok, data} ->
|
|
root = data[root_key]
|
|
edges = (root && root["edges"]) || []
|
|
nodes = Enum.map(edges, & &1["node"])
|
|
all_nodes = acc ++ nodes
|
|
|
|
page_info = root && root["pageInfo"]
|
|
|
|
if page_info && 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: [
|
|
{"x-gaiia-api-key", 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
|