diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 668205f4..4a75c4ca 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -122,9 +122,7 @@ defmodule Towerops.Accounts do """ @spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def register_user(attrs) do - user_changeset = - %User{} - |> User.registration_changeset(attrs) + user_changeset = User.registration_changeset(%User{}, attrs) multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :user, user_changeset) @@ -154,9 +152,7 @@ defmodule Towerops.Accounts do @dialyzer {:nowarn_function, register_user_with_organization: 1} @spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def register_user_with_organization(attrs) do - user_changeset = - %User{} - |> User.registration_changeset(attrs) + user_changeset = User.registration_changeset(%User{}, attrs) multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :user, user_changeset) @@ -1614,14 +1610,13 @@ defmodule Towerops.Accounts do with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), %User{} = user <- Repo.one(query), {:ok, %{user: user}} <- - Repo.transaction( - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.confirm_changeset(user)) - |> Ecto.Multi.delete_all( - :tokens, - UserToken.by_user_and_contexts_query(user, ["confirm"]) - ) - ) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all( + :tokens, + UserToken.by_user_and_contexts_query(user, ["confirm"]) + ) + |> Repo.transaction() do {:ok, user} else _ -> :error diff --git a/lib/towerops/activity_feed.ex b/lib/towerops/activity_feed.ex index 642dea43..6050865f 100644 --- a/lib/towerops/activity_feed.ex +++ b/lib/towerops/activity_feed.ex @@ -18,8 +18,8 @@ defmodule Towerops.ActivityFeed do alias Towerops.Devices.Device alias Towerops.Devices.Event, as: DeviceEvent alias Towerops.Preseem.SyncLog - alias Towerops.Sites.Site alias Towerops.Repo + alias Towerops.Sites.Site @type activity_item :: %{ type: atom(), diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index b2f4d717..2fe32ab0 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -7,6 +7,7 @@ defmodule Towerops.Alerts do alias Towerops.Alerts.Alert alias Towerops.Gaiia.ImpactAnalysis + alias Towerops.PagerDuty.Notifier alias Towerops.Repo @doc """ @@ -225,7 +226,7 @@ defmodule Towerops.Alerts do case result do {:ok, updated_alert} -> - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_acknowledge(updated_alert) end) + Task.start(fn -> Notifier.notify_acknowledge(updated_alert) end) {:ok, updated_alert} error -> @@ -244,7 +245,7 @@ defmodule Towerops.Alerts do case result do {:ok, updated_alert} -> - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_resolve(updated_alert) end) + Task.start(fn -> Notifier.notify_resolve(updated_alert) end) {:ok, updated_alert} error -> diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 6dd67176..bb604189 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -224,7 +224,10 @@ defmodule Towerops.Organizations do join: u in assoc(m, :user), preload: [user: u], order_by: [ - fragment("CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END", m.role), + fragment( + "CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END", + m.role + ), asc: u.email ] ) diff --git a/lib/towerops/pagerduty/notifier.ex b/lib/towerops/pagerduty/notifier.ex index 4909dccb..7ebeeb3c 100644 --- a/lib/towerops/pagerduty/notifier.ex +++ b/lib/towerops/pagerduty/notifier.ex @@ -34,9 +34,7 @@ defmodule Towerops.PagerDuty.Notifier do :ok {:error, reason} -> - Logger.warning( - "PagerDuty acknowledge failed for alert #{alert.id}: #{inspect(reason)}" - ) + Logger.warning("PagerDuty acknowledge failed for alert #{alert.id}: #{inspect(reason)}") :error end diff --git a/lib/towerops/security/brute_force.ex b/lib/towerops/security/brute_force.ex index a1cc8084..ee446761 100644 --- a/lib/towerops/security/brute_force.ex +++ b/lib/towerops/security/brute_force.ex @@ -366,9 +366,15 @@ defmodule Towerops.Security.BruteForce do load_whitelist() _ -> - case :ets.lookup(@whitelist_cache_table, :whitelist) do - [{:whitelist, entries}] -> entries - [] -> load_whitelist() + try do + case :ets.lookup(@whitelist_cache_table, :whitelist) do + [{:whitelist, entries}] -> entries + [] -> load_whitelist() + end + rescue + ArgumentError -> + create_cache_table() + load_whitelist() end end end @@ -377,13 +383,18 @@ defmodule Towerops.Security.BruteForce do :ets.new(@whitelist_cache_table, [:set, :public, :named_table, read_concurrency: true]) rescue ArgumentError -> - # Table already exists due to race condition in async tests - this is fine :ets.whereis(@whitelist_cache_table) end defp load_whitelist do entries = Repo.all(IpWhitelist) - :ets.insert(@whitelist_cache_table, {:whitelist, entries}) + + try do + :ets.insert(@whitelist_cache_table, {:whitelist, entries}) + rescue + ArgumentError -> :ok + end + entries end @@ -392,5 +403,7 @@ defmodule Towerops.Security.BruteForce do :undefined -> :ok _ -> :ets.delete(@whitelist_cache_table, :whitelist) end + rescue + ArgumentError -> :ok end end diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index ba25d396..4745aaa5 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -18,6 +18,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do alias Towerops.Alerts alias Towerops.Devices alias Towerops.Monitoring + alias Towerops.PagerDuty.Notifier alias Towerops.Snmp.Client alias Towerops.Workers.PollingOffset @@ -210,7 +211,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do message: alert_message }) - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end) + Task.start(fn -> Notifier.notify_trigger(alert, device) end) _ = Phoenix.PubSub.broadcast( @@ -239,7 +240,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do message: recovery_message }) - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end) + Task.start(fn -> Notifier.notify_trigger(alert, device) end) resolve_down_alert(device) diff --git a/lib/towerops_web/controllers/api/error_helpers.ex b/lib/towerops_web/controllers/api/error_helpers.ex new file mode 100644 index 00000000..bb9b6d24 --- /dev/null +++ b/lib/towerops_web/controllers/api/error_helpers.ex @@ -0,0 +1,26 @@ +defmodule ToweropsWeb.Api.ErrorHelpers do + @moduledoc "Shared error translation helpers for API controllers." + + @doc """ + Translates changeset errors into a map of field names to error message lists. + + Interpolates message parameters (e.g. `%{count}`) safely, validating keys + before atom conversion to prevent atom exhaustion attacks. + """ + @spec translate_errors(Ecto.Changeset.t()) :: %{atom() => [String.t()]} + def translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + safe_translate_key(key, opts) + end) + end) + end + + defp safe_translate_key(key, opts) do + if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + else + key + end + end +end diff --git a/lib/towerops_web/controllers/api/v1/agents_controller.ex b/lib/towerops_web/controllers/api/v1/agents_controller.ex index 3cf9aba7..de781d1c 100644 --- a/lib/towerops_web/controllers/api/v1/agents_controller.ex +++ b/lib/towerops_web/controllers/api/v1/agents_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.AgentsController do @moduledoc "API controller for agent tokens." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Agents def index(conn, _params) do @@ -45,7 +47,8 @@ defmodule ToweropsWeb.Api.V1.AgentsController do json(conn, %{ data: - format_agent(agent) + agent + |> format_agent() |> Map.put(:device_count, device_count) }) else @@ -85,16 +88,4 @@ defmodule ToweropsWeb.Api.V1.AgentsController do inserted_at: agent.inserted_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/alerts_controller.ex b/lib/towerops_web/controllers/api/v1/alerts_controller.ex index f5e62f0c..9739872e 100644 --- a/lib/towerops_web/controllers/api/v1/alerts_controller.ex +++ b/lib/towerops_web/controllers/api/v1/alerts_controller.ex @@ -49,7 +49,7 @@ defmodule ToweropsWeb.Api.V1.AlertsController do device = alert.device if device && device.organization_id == organization_id do - user_id = if user, do: user.id, else: nil + user_id = if user, do: user.id case Alerts.acknowledge_alert(alert, user_id) do {:ok, updated} -> diff --git a/lib/towerops_web/controllers/api/v1/check_results_controller.ex b/lib/towerops_web/controllers/api/v1/check_results_controller.ex index a56c539f..5883d9e9 100644 --- a/lib/towerops_web/controllers/api/v1/check_results_controller.ex +++ b/lib/towerops_web/controllers/api/v1/check_results_controller.ex @@ -12,7 +12,8 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do case get_org_device(device_id, organization_id) do {:ok, device} -> checks = - Monitoring.list_checks(organization_id, device_id: device.id) + organization_id + |> Monitoring.list_checks(device_id: device.id) |> Enum.map(fn check -> latest = Monitoring.get_latest_check_result(check.id) @@ -77,24 +78,8 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do interfaces = case device.snmp_device do - nil -> - [] - - snmp_device -> - Snmp.list_interfaces(snmp_device.id) - |> Enum.map(fn iface -> - %{ - id: iface.id, - if_index: iface.if_index, - if_name: iface.if_name, - if_descr: iface.if_descr, - if_alias: iface.if_alias, - if_speed: iface.if_speed, - if_admin_status: iface.if_admin_status, - if_oper_status: iface.if_oper_status, - monitored: iface.monitored - } - end) + nil -> [] + snmp_device -> format_interfaces(snmp_device) end json(conn, %{data: interfaces}) @@ -126,4 +111,22 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do end defp parse_int(val, _default) when is_integer(val), do: val + + defp format_interfaces(snmp_device) do + snmp_device.id + |> Snmp.list_interfaces() + |> Enum.map(fn iface -> + %{ + id: iface.id, + if_index: iface.if_index, + if_name: iface.if_name, + if_descr: iface.if_descr, + if_alias: iface.if_alias, + if_speed: iface.if_speed, + if_admin_status: iface.if_admin_status, + if_oper_status: iface.if_oper_status, + monitored: iface.monitored + } + end) + end end diff --git a/lib/towerops_web/controllers/api/v1/integrations_controller.ex b/lib/towerops_web/controllers/api/v1/integrations_controller.ex index 6578a6a3..f6fc7e82 100644 --- a/lib/towerops_web/controllers/api/v1/integrations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/integrations_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do @moduledoc "API controller for third-party integrations." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Integrations def index(conn, _params) do @@ -49,8 +51,11 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do case Integrations.get_integration_by_id(id) do {:ok, integration} when integration.organization_id == organization_id -> case Integrations.update_integration(integration, to_atom_keys(attrs)) do - {:ok, updated} -> json(conn, %{data: format_integration(updated)}) - {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) + {:ok, updated} -> + json(conn, %{data: format_integration(updated)}) + + {:error, changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end _ -> @@ -108,16 +113,4 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do rescue ArgumentError -> map end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/invitations_controller.ex b/lib/towerops_web/controllers/api/v1/invitations_controller.ex index 2b255ca3..31abc5a4 100644 --- a/lib/towerops_web/controllers/api/v1/invitations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/invitations_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do @moduledoc "API controller for organization invitations." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def index(conn, _params) do @@ -25,7 +27,7 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do role: role, invited_by_id: user && user.id, token: Ecto.UUID.generate(), - expires_at: DateTime.add(DateTime.utc_now(), 7 * 86_400, :second) |> DateTime.truncate(:second) + expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second) } case Organizations.create_invitation(attrs) do @@ -81,16 +83,4 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do loaded -> Map.get(loaded, field) end end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/members_controller.ex b/lib/towerops_web/controllers/api/v1/members_controller.ex index 07697e78..efadf32a 100644 --- a/lib/towerops_web/controllers/api/v1/members_controller.ex +++ b/lib/towerops_web/controllers/api/v1/members_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.MembersController do @moduledoc "API controller for organization members." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def index(conn, _params) do @@ -56,16 +58,4 @@ defmodule ToweropsWeb.Api.V1.MembersController do inserted_at: membership.inserted_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/organization_controller.ex b/lib/towerops_web/controllers/api/v1/organization_controller.ex index 19df5c3c..9e6592a0 100644 --- a/lib/towerops_web/controllers/api/v1/organization_controller.ex +++ b/lib/towerops_web/controllers/api/v1/organization_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.OrganizationController do @moduledoc "API controller for current organization settings." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def show(conn, _params) do @@ -54,16 +56,4 @@ defmodule ToweropsWeb.Api.V1.OrganizationController do updated_at: org.updated_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex index 35ca220a..2c86ca3f 100644 --- a/lib/towerops_web/controllers/api_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -1420,7 +1420,7 @@ Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.j
Alerts represent monitoring events triggered when a device check fails or recovers. You can list, view, acknowledge, and resolve alerts.
- +Returns all alerts for the authenticated organization.
-Retrieves a single alert by its ID.
++ Retrieves a single alert by its ID. +
Agents are lightweight monitoring agents that run on your infrastructure to collect device data. Each agent has a token used for authentication.
- +Returns all agent tokens for the authenticated organization.
++ Returns all agent tokens for the authenticated organization. +
Creates a new agent token. The raw token is returned only once in the response — store it securely.
-Retrieves a single agent by ID. Includes a count of assigned devices.
++ Retrieves a single agent by ID. Includes a count of assigned devices. +
Permanently deletes an agent token. Devices assigned to this agent will need to be reassigned.
++ Permanently deletes an agent token. Devices assigned to this agent will need to be reassigned. +
Retrieve and update settings for the authenticated organization, including SNMP defaults, MikroTik configuration, and general preferences.
- +Returns the full settings for the authenticated organization.
++ Returns the full settings for the authenticated organization. +
Updates organization settings. Only the provided fields are changed.
-Manage the users who belong to your organization. You can list members, change roles, and remove members.
- +Returns all members of the authenticated organization.
++ Returns all members of the authenticated organization. +
Updates a member's role. Cannot change the owner's role.
-+ Updates a member's role. Cannot change the owner's role. +
+Removes a member from the organization. The organization owner cannot be removed.
++ Removes a member from the organization. The organization owner cannot be removed. +
Invite new users to your organization. Invitations expire after 7 days.
- +Returns all pending invitations for the organization.
++ Returns all pending invitations for the organization. +
Sends an invitation to join the organization.
-+ Sends an invitation to join the organization. +
+Cancels a pending invitation.
++ Cancels a pending invitation. +
Manage third-party integrations for your organization, such as notification providers and external monitoring systems.
- +Returns all integrations for the organization.
++ Returns all integrations for the organization. +
Creates a new integration.
-Retrieves a single integration by ID.
++ Retrieves a single integration by ID. +
Updates an integration's settings.
++ Updates an integration's settings. +
Permanently deletes an integration.
++ Permanently deletes an integration. +
Tests the connection for an integration to verify it's properly configured.
++ Tests the connection for an integration to verify it's properly configured. +
Returns all monitoring checks configured for a device, including the latest result for each check.
++ Returns all monitoring checks configured for a device, including the latest result for each check. +
Returns historical check results for a device, useful for graphing and trend analysis.
-+ Returns historical check results for a device, useful for graphing and trend analysis. +
+Returns SNMP-discovered network interfaces for a device.
++ Returns SNMP-discovered network interfaces for a device. +
Returns recent activity events for the organization.
-+ Returns recent activity events for the organization. +
+- First 10 devices are free forever — all features, no limits. Pay $3/device/month only after that. + First 10 devices are free forever + — all features, no limits. Pay $3/device/month only after that.
- <.link href={~p"/users/log-in"}>Log in | - <.link href={~p"/users/register"}>Register + <.link href={~p"/users/log-in"}>Log in + | <.link href={~p"/users/register"}>Register
services:
- towerops-agent:
+ <%= raw(~S[services:
+ towerops-agent:
image: ghcr.io/towerops-app/towerops-agent:latest
container_name: towerops-agent
restart: unless-stopped
@@ -907,7 +907,7 @@ defmodule ToweropsWeb.HelpLive.Index do
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=towerops"
- watchtower:
+ watchtower:
image: containrrr/watchtower:latest
container_name: towerops-watchtower
restart: unless-stopped
@@ -916,7 +916,7 @@ defmodule ToweropsWeb.HelpLive.Index do
- WATCHTOWER_LABEL_ENABLE=true
- WATCHTOWER_CLEANUP=true
volumes:
- - /var/run/docker.sock:/var/run/docker.sock
+ - /var/run/docker.sock:/var/run/docker.sock]) %>
The agent is lightweight — it uses minimal CPU and memory. Watchtower is included @@ -1487,7 +1487,8 @@ defmodule ToweropsWeb.HelpLive.Index do
- Use the Force Apply button to push + Use the Force Apply + button to push the organization's SNMP settings to all devices, overriding any device or site-level customizations.
@@ -1528,7 +1529,8 @@ defmodule ToweropsWeb.HelpLive.Index do- Use the Force Apply button to push + Use the Force Apply + button to push the default agent assignment to all devices, overriding any device or site-level agent selections.
@@ -1559,7 +1561,11 @@ defmodule ToweropsWeb.HelpLive.Index doIntegrations allow you to connect Towerops with third-party services to enrich your monitoring data, sync subscriber information, and streamline your workflow. Configure - integrations from <.code>Organization Settings → <.code>Integrations tab. + integrations from + <.code>Organization Settings + → + <.code>Integrations + tab.
- Preseem + + Preseem + is a Quality of Experience (QoE) monitoring platform designed for WISPs and broadband providers. The Preseem integration syncs subscriber and access point QoE data into Towerops, giving you a unified view of network health alongside device monitoring. @@ -1615,7 +1626,11 @@ defmodule ToweropsWeb.HelpLive.Index do Add the Integration
- Navigate to <.code>Organization Settings → <.code>Integrations tab. + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab. Enter your Preseem API key and configure the sync interval.
- Use the Test Connection button + Use the + Test Connection + button to verify your API key is valid and Towerops can reach the Preseem API.
- Gaiia + + Gaiia + is a billing and subscriber management platform. The Gaiia integration syncs subscriber data, service plans, and entity mappings into Towerops, enabling you to correlate network issues with specific customers and services. @@ -1694,7 +1716,11 @@ defmodule ToweropsWeb.HelpLive.Index do Configure Gaiia Credentials
- Navigate to <.code>Organization Settings → <.code>Integrations tab + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab and enter your Gaiia API credentials.
- PagerDuty + + PagerDuty + is an incident management and on-call alerting platform. The PagerDuty integration provides 2-way alert sync — when a device goes down in Towerops, a PagerDuty incident is automatically triggered. Acknowledging or resolving the alert in Towerops updates the PagerDuty incident as well. @@ -1793,7 +1824,15 @@ defmodule ToweropsWeb.HelpLive.Index do Create an Events API v2 Integration in PagerDuty
- In PagerDuty, go to <.code>Services → select your service → <.code>Integrations tab → <.code>Add Integration → choose <.code>Events API v2. Copy the <.code>Integration Key (routing key). + In PagerDuty, go to + <.code>Services + → select your service → + <.code>Integrations + tab → + <.code>Add Integration + → choose <.code>Events API v2. Copy the + <.code>Integration Key + (routing key).
- Navigate to <.code>Organization Settings → <.code>Integrations tab → click <.code>Configure on PagerDuty. Paste your integration key and test the connection. + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab → click + <.code>Configure + on PagerDuty. Paste your integration key and test the connection.
| Role | -Sent | -Expires | ++ Email + | ++ Role + | ++ Sent + | ++ Expires + | <%= if @membership.role in [:owner, :admin] do %> -Actions | ++ Actions + | <% end %>|||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {invitation.email} | ++ {invitation.email} + | {invitation.role} | - <.timestamp datetime={invitation.inserted_at} timezone={@timezone} format="absolute" /> + <.timestamp + datetime={invitation.inserted_at} + timezone={@timezone} + format="absolute" + /> | - <.timestamp datetime={invitation.expires_at} timezone={@timezone} format="absolute" /> + <.timestamp + datetime={invitation.expires_at} + timezone={@timezone} + format="absolute" + /> | <%= if @membership.role in [:owner, :admin] do %>
@@ -639,8 +692,8 @@
<% end %>
-
-
+
+
@@ -656,11 +709,19 @@
|