From a028695d3b7e593596bc8c966a54d3c8bd769b63 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 14 Feb 2026 13:57:25 -0600 Subject: [PATCH] format --- lib/towerops/gaiia/webhooks.ex | 2 +- lib/towerops/geocoding.ex | 28 ++- lib/towerops/workers/gaiia_insight_worker.ex | 2 +- .../components/marketing_layouts.ex | 8 +- .../api/v1/gaiia_webhook_controller.ex | 7 +- lib/towerops_web/live/help_live/index.ex | 208 +++++++++++++----- .../live/insights_live/index.html.heex | 4 +- lib/towerops_web/live/map_live/index.ex | 30 +-- .../live/map_live/index.html.heex | 30 +-- .../live/site_live/form.html.heex | 8 +- test/towerops/gaiia/webhooks_test.exs | 6 +- 11 files changed, 221 insertions(+), 112 deletions(-) diff --git a/lib/towerops/gaiia/webhooks.ex b/lib/towerops/gaiia/webhooks.ex index 495a2615..cbf457dd 100644 --- a/lib/towerops/gaiia/webhooks.ex +++ b/lib/towerops/gaiia/webhooks.ex @@ -20,7 +20,7 @@ defmodule Towerops.Gaiia.Webhooks do @spec verify_signature(binary(), binary(), binary(), binary()) :: boolean() def verify_signature(body, timestamp, signature, secret) do signed_payload = timestamp <> "." <> body - expected = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) + expected = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) Plug.Crypto.secure_compare(expected, signature) end diff --git a/lib/towerops/geocoding.ex b/lib/towerops/geocoding.ex index ad187da2..79e667f6 100644 --- a/lib/towerops/geocoding.ex +++ b/lib/towerops/geocoding.ex @@ -1,7 +1,7 @@ defmodule Towerops.Geocoding do @moduledoc """ Service for geocoding addresses using Google Maps Geocoding API. - + Supports both system-wide API keys and organization-specific encrypted API keys. """ @@ -11,23 +11,20 @@ defmodule Towerops.Geocoding do @doc """ Geocode an address to latitude/longitude coordinates. - + ## Parameters - + - `address`: The address string to geocode - + ## Returns - + - `{:ok, %{latitude: float, longitude: float, formatted_address: string}}` on success - `{:error, reason}` on failure """ def geocode(address) when is_binary(address) do with {:ok, api_key} <- get_api_key(), - {:ok, response} <- make_geocoding_request(address, api_key), - {:ok, result} <- parse_geocoding_response(response) do - {:ok, result} - else - {:error, reason} -> {:error, reason} + {:ok, response} <- make_geocoding_request(address, api_key) do + parse_geocoding_response(response) end end @@ -69,11 +66,12 @@ defmodule Towerops.Geocoding do defp parse_geocoding_response(%{"status" => "OK", "results" => [result | _]}) do with %{"geometry" => %{"location" => %{"lat" => lat, "lng" => lng}}} <- result, %{"formatted_address" => formatted_address} <- result do - {:ok, %{ - latitude: lat, - longitude: lng, - formatted_address: formatted_address - }} + {:ok, + %{ + latitude: lat, + longitude: lng, + formatted_address: formatted_address + }} else _ -> {:error, "Invalid response format from Google Maps API"} end diff --git a/lib/towerops/workers/gaiia_insight_worker.ex b/lib/towerops/workers/gaiia_insight_worker.ex index 1392f3a2..2c18514b 100644 --- a/lib/towerops/workers/gaiia_insight_worker.ex +++ b/lib/towerops/workers/gaiia_insight_worker.ex @@ -49,7 +49,7 @@ defmodule Towerops.Workers.GaiiaInsightWorker do "dedup_key" => "untracked", "finding_type" => "untracked", "count" => count, - "device_ids" => untracked_devices |> Enum.map(& &1.device.id), + "device_ids" => Enum.map(untracked_devices, & &1.device.id), "device_names" => untracked_devices |> Enum.take(10) |> Enum.map(& &1.device.name) } }) diff --git a/lib/towerops_web/components/marketing_layouts.ex b/lib/towerops_web/components/marketing_layouts.ex index 4c58e3de..ae28d68e 100644 --- a/lib/towerops_web/components/marketing_layouts.ex +++ b/lib/towerops_web/components/marketing_layouts.ex @@ -93,8 +93,8 @@ defmodule ToweropsWeb.MarketingLayouts do - - + +

Developers

- - + +
diff --git a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex index 423e4bbd..c8b25f59 100644 --- a/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex +++ b/lib/towerops_web/controllers/api/v1/gaiia_webhook_controller.ex @@ -56,9 +56,8 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do case get_req_header(conn, "x-gaiia-webhook-signature") do [header] -> with {:ok, timestamp, signature} <- parse_signature_header(header), - :ok <- check_timestamp(timestamp), - :ok <- check_signature(timestamp, raw_body, secret, signature) do - :ok + :ok <- check_timestamp(timestamp) do + check_signature(timestamp, raw_body, secret, signature) end _ -> @@ -101,7 +100,7 @@ defmodule ToweropsWeb.Api.V1.GaiiaWebhookController do defp check_signature(timestamp, raw_body, secret, expected) do signed_payload = timestamp <> "." <> raw_body - computed = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) + computed = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) if Plug.Crypto.secure_compare(computed, expected) do :ok diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index 44352e0f..e7cf7763 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -2321,20 +2321,38 @@ defmodule ToweropsWeb.HelpLive.Index do Network Insights provides proactive observations about your network health, gathered automatically from all connected data sources.

-

How It Works

+

+ How It Works +

Towerops continuously analyzes data from SNMP polling, Preseem, Gaiia, and other integrations to surface actionable findings. Insights are categorized by source, urgency, and type so you can focus on what matters.

-

Insight Types

+

+ Insight Types +

    -
  • Reconciliation Findings — Devices that exist in Towerops but not in Gaiia (or vice versa), data mismatches between systems
  • -
  • Performance Anomalies — Devices with unusual metric patterns detected by Preseem or SNMP baselines
  • -
  • Configuration Drift — Detected changes to device configurations that may affect performance
  • -
  • Capacity Warnings — Access points or links approaching utilization thresholds
  • +
  • + Reconciliation Findings + — Devices that exist in Towerops but not in Gaiia (or vice versa), data mismatches between systems +
  • +
  • + Performance Anomalies + — Devices with unusual metric patterns detected by Preseem or SNMP baselines +
  • +
  • + Configuration Drift + — Detected changes to device configurations that may affect performance +
  • +
  • + Capacity Warnings + — Access points or links approaching utilization thresholds +
-

Filtering & Management

+

+ Filtering & Management +

Use the filter bar to narrow insights by status (active/dismissed), source (Preseem, Gaiia, SNMP, system), and urgency level. Each insight shows affected devices as clickable links so you can drill directly into the device detail page.

@@ -2344,13 +2362,15 @@ defmodule ToweropsWeb.HelpLive.Index do

- Tip: Insights run on a nightly schedule. Connect your Gaiia and Preseem integrations in - <.link navigate={~p"/help?section=integrations"} class="underline">Organization Settings + Tip: + Insights run on a nightly schedule. Connect your Gaiia and Preseem integrations in + <.link navigate={~p"/help?section=integrations"} class="underline"> + Organization Settings + to get the most comprehensive insights.

- <% "network-map" -> %>

@@ -2360,29 +2380,46 @@ defmodule ToweropsWeb.HelpLive.Index do The Network Map provides a geographic view of your sites on an interactive map. Sites with latitude and longitude coordinates are displayed as markers that you can click to view details.

-

Adding Location Data

+

+ Adding Location Data +

Each site can have an address and/or latitude/longitude coordinates. You can set these in two ways:

    -
  • Manual entry — Enter latitude and longitude directly in the site edit form
  • -
  • Geocoding — Enter a street address and click "Geocode" to automatically look up the coordinates using Google Maps
  • +
  • + Manual entry + — Enter latitude and longitude directly in the site edit form +
  • +
  • + Geocoding + — Enter a street address and click "Geocode" to automatically look up the coordinates using Google Maps +
-

Geocoding Setup

+

+ Geocoding Setup +

- Address-to-coordinate conversion requires a Google Maps Geocoding API key. Your administrator sets this as the GOOGLE_MAPS_API_KEY environment variable on the server. The map display itself uses OpenStreetMap and requires no API key. + Address-to-coordinate conversion requires a Google Maps Geocoding API key. Your administrator sets this as the + + GOOGLE_MAPS_API_KEY + + environment variable on the server. The map display itself uses OpenStreetMap and requires no API key.

-

Map Features

+

+ Map Features +

  • Interactive pan and zoom with OpenStreetMap tiles
  • -
  • Click any site marker to see site name, device count, and a link to the site detail page
  • +
  • + Click any site marker to see site name, device count, and a link to the site detail page +
  • Auto-fits the map to show all your sites
  • Summary stats showing total sites, mapped sites, and total devices
- <% "api-tokens" -> %>

@@ -2392,7 +2429,9 @@ defmodule ToweropsWeb.HelpLive.Index do API tokens allow external applications and scripts to authenticate with the Towerops REST and GraphQL APIs. Each token is scoped to an organization and can be managed from your user settings.

-

Creating a Token

+

+ Creating a Token +

  1. Go to User Settings → API Tokens
  2. Click "Create API Token"
  3. @@ -2400,17 +2439,27 @@ defmodule ToweropsWeb.HelpLive.Index do
  4. Copy the token immediately — it will only be shown once
-

Using a Token

+

+ Using a Token +

- Include the token in the Authorization header: + Include the token in the + + Authorization + + header:

Authorization: Bearer your-api-token-here
-

Security Best Practices

+

+ Security Best Practices +

    -
  • Create separate tokens for each integration — don't reuse tokens across services
  • +
  • + Create separate tokens for each integration — don't reuse tokens across services +
  • Revoke tokens you no longer need
  • Never commit tokens to version control
  • Store tokens in environment variables or a secrets manager
  • @@ -2419,41 +2468,68 @@ defmodule ToweropsWeb.HelpLive.Index do

    See also: - <.link navigate={~p"/help?section=rest-api"} class="underline">REST API and - <.link navigate={~p"/help?section=graphql-api"} class="underline">GraphQL API + <.link navigate={~p"/help?section=rest-api"} class="underline">REST API + and + <.link navigate={~p"/help?section=graphql-api"} class="underline"> + GraphQL API + for endpoint documentation.

- <% "rest-api" -> %>

REST API

- The Towerops REST API provides programmatic access to your monitoring data. All endpoints are under /api/v1/ and require a valid API token. + The Towerops REST API provides programmatic access to your monitoring data. All endpoints are under + + /api/v1/ + + and require a valid API token.

-

Available Endpoints

+

+ Available Endpoints +

    -
  • Devices — List, view, create, and update monitored devices
  • -
  • Alerts — Query active and historical alerts, acknowledge, and resolve
  • +
  • + Devices — List, view, create, and update monitored devices +
  • +
  • + Alerts + — Query active and historical alerts, acknowledge, and resolve +
  • Sites — Manage sites and their device assignments
  • Agents — List remote pollers and their status
  • -
  • Check Results — Query monitoring check results and metrics
  • -
  • Organization Settings — Read and update SNMP/MikroTik configuration
  • +
  • + Check Results — Query monitoring check results and metrics +
  • +
  • + Organization Settings + — Read and update SNMP/MikroTik configuration +
  • Members & Invitations — Manage organization membership
  • Integrations — Configure Preseem, Gaiia, and PagerDuty
  • -
  • Activity Feed — Recent events and changes across the organization
  • +
  • + Activity Feed + — Recent events and changes across the organization +
-

Quick Example

+

+ Quick Example +

<%= raw("curl -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  https://app.towerops.net/api/v1/devices") %>

- All responses use <%= raw("{\"data\": ...}") %> format. Results are scoped to the organization associated with your API token. + All responses use + + {raw("{\"data\": ...}")} + + format. Results are scoped to the organization associated with your API token.

@@ -2464,52 +2540,84 @@ defmodule ToweropsWeb.HelpLive.Index do

- <% "graphql-api" -> %>

GraphQL API

- The GraphQL API lets you query exactly the data you need in a single request. The endpoint is POST /api/graphql and uses the same API token authentication as the REST API. + The GraphQL API lets you query exactly the data you need in a single request. The endpoint is + + POST /api/graphql + + and uses the same API token authentication as the REST API.

-

Why GraphQL?

+

+ Why GraphQL? +

    -
  • Fetch related data in one query — Get devices with their sites, alerts, and latest metrics in a single request
  • +
  • + Fetch related data in one query + — Get devices with their sites, alerts, and latest metrics in a single request +
  • No over-fetching — Request only the fields you need
  • -
  • Introspection — The schema is self-documenting; use any GraphQL client to explore available types and fields
  • +
  • + Introspection + — The schema is self-documenting; use any GraphQL client to explore available types and fields +
-

Quick Example

+

+ Quick Example +

<%= raw("curl -X POST https://app.towerops.net/api/graphql \\\n  -H \"Authorization: Bearer YOUR_TOKEN\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"query\": \"{ devices { id name ipAddress status } }\"}'") %>
-

Available Queries

+

+ Available Queries +

    -
  • devices — List devices with filtering and nested site/alert data
  • -
  • device(id) — Single device with full detail including sensors and interfaces
  • +
  • + devices + — List devices with filtering and nested site/alert data +
  • +
  • + device(id) + — Single device with full detail including sensors and interfaces +
  • sites — Sites with nested device lists
  • -
  • alerts — Active and historical alerts with device context
  • +
  • + alerts — Active and historical alerts with device context +
  • agents — Remote poller agents and their assignments
-

Mutations

+

+ Mutations +

    -
  • createDevice / updateDevice — Manage devices
  • -
  • acknowledgeAlert / resolveAlert — Alert lifecycle management
  • +
  • + createDevice / updateDevice — Manage devices +
  • +
  • + acknowledgeAlert + / resolveAlert + — Alert lifecycle management +

Full reference: - GraphQL API Documentation → + + GraphQL API Documentation → + — complete schema reference with query examples and variable usage.

- <% _ -> %>

diff --git a/lib/towerops_web/live/insights_live/index.html.heex b/lib/towerops_web/live/insights_live/index.html.heex index 6466cd99..3928133d 100644 --- a/lib/towerops_web/live/insights_live/index.html.heex +++ b/lib/towerops_web/live/insights_live/index.html.heex @@ -284,7 +284,9 @@ <%= for {name, idx} <- Enum.with_index(insight.metadata["device_names"]) do %> <%= if insight.metadata["device_ids"] && Enum.at(insight.metadata["device_ids"], idx) do %> <.link - navigate={~p"/devices/#{Enum.at(insight.metadata["device_ids"], idx)}"} + navigate={ + ~p"/devices/#{Enum.at(insight.metadata["device_ids"], idx)}" + } class="inline-flex items-center rounded-md bg-white px-2 py-0.5 text-xs font-medium text-indigo-700 ring-1 ring-inset ring-indigo-700/10 hover:bg-indigo-50 dark:bg-indigo-900/20 dark:text-indigo-400 dark:ring-indigo-400/20 dark:hover:bg-indigo-900/40" > {name || "Unnamed"} diff --git a/lib/towerops_web/live/map_live/index.ex b/lib/towerops_web/live/map_live/index.ex index fb91ec50..232e587c 100644 --- a/lib/towerops_web/live/map_live/index.ex +++ b/lib/towerops_web/live/map_live/index.ex @@ -41,22 +41,24 @@ defmodule ToweropsWeb.MapLive.Index do sites = Sites.list_organization_sites(organization.id) # Filter sites that have geographic coordinates - sites_with_coords = Enum.filter(sites, fn site -> - site.latitude != nil && site.longitude != nil - end) + sites_with_coords = + Enum.filter(sites, fn site -> + site.latitude != nil && site.longitude != nil + end) # Prepare sites data for the map - sites_data = Enum.map(sites_with_coords, fn site -> - %{ - id: site.id, - name: site.name, - description: site.description, - address: site.address, - latitude: site.latitude, - longitude: site.longitude, - device_count: length(site.device || []) - } - end) + sites_data = + Enum.map(sites_with_coords, fn site -> + %{ + id: site.id, + name: site.name, + description: site.description, + address: site.address, + latitude: site.latitude, + longitude: site.longitude, + device_count: length(site.device || []) + } + end) assign(socket, :sites, sites_data) end diff --git a/lib/towerops_web/live/map_live/index.html.heex b/lib/towerops_web/live/map_live/index.html.heex index 938ca663..c33061d7 100644 --- a/lib/towerops_web/live/map_live/index.html.heex +++ b/lib/towerops_web/live/map_live/index.html.heex @@ -35,13 +35,13 @@

- Showing <%= length(@sites) %> sites with coordinates + Showing {length(@sites)} sites with coordinates
- +
- - + +
@@ -69,20 +69,20 @@
Total Sites
- <%= Sites.count_organization_sites(@organization.id) %> + {Sites.count_organization_sites(@organization.id)}
Geocoded Sites
- <%= length(@sites) %> + {length(@sites)}
Coverage
<%= if Sites.count_organization_sites(@organization.id) > 0 do %> - <%= round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100) %>% + {round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100)}% <% else %> 0% <% end %> @@ -90,8 +90,8 @@
- - + +

@@ -107,26 +107,26 @@

- <%= site.name %> + {site.name}

<%= if site.device_count > 0 do %> - <%= site.device_count %> devices + {site.device_count} devices <% end %>
<%= if site.address do %>

- <%= site.address %> + {site.address}

<% end %> <%= if site.description do %>

- <%= site.description %> + {site.description}

<% end %>

- <%= Float.round(site.latitude, 4) %>, <%= Float.round(site.longitude, 4) %> + {Float.round(site.latitude, 4)}, {Float.round(site.longitude, 4)}

@@ -184,4 +184,4 @@ document.head.appendChild(js); } }); - \ No newline at end of file + diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex index 11342270..d095644d 100644 --- a/lib/towerops_web/live/site_live/form.html.heex +++ b/lib/towerops_web/live/site_live/form.html.heex @@ -63,8 +63,8 @@ />
- - + +

Geographic Location @@ -148,8 +148,8 @@

- - + + <%= if @form[:latitude].value && @form[:longitude].value do %>
diff --git a/test/towerops/gaiia/webhooks_test.exs b/test/towerops/gaiia/webhooks_test.exs index 7538cbca..88f92190 100644 --- a/test/towerops/gaiia/webhooks_test.exs +++ b/test/towerops/gaiia/webhooks_test.exs @@ -252,7 +252,7 @@ defmodule Towerops.Gaiia.WebhooksTest do body = ~s({"event":"account.created"}) timestamp = "1492774577" signed_payload = timestamp <> "." <> body - signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) + signature = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) assert Webhooks.verify_signature(body, timestamp, signature, secret) == true end @@ -269,7 +269,7 @@ defmodule Towerops.Gaiia.WebhooksTest do body = ~s({"event":"account.created"}) timestamp = "1492774577" signed_payload = timestamp <> "." <> body - signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) + signature = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) tampered_body = ~s({"event":"account.deleted"}) assert Webhooks.verify_signature(tampered_body, timestamp, signature, secret) == false @@ -280,7 +280,7 @@ defmodule Towerops.Gaiia.WebhooksTest do body = ~s({"event":"account.created"}) timestamp = "1492774577" signed_payload = timestamp <> "." <> body - signature = :crypto.mac(:hmac, :sha256, secret, signed_payload) |> Base.encode16(case: :lower) + signature = :hmac |> :crypto.mac(:sha256, secret, signed_payload) |> Base.encode16(case: :lower) assert Webhooks.verify_signature(body, "9999999999", signature, secret) == false end