From 9d437a81196d49e5fb2ca2fea33a60b0c61b0b50 Mon Sep 17 00:00:00 2001
From: Graham McIntie
Date: Sat, 14 Feb 2026 11:19:57 -0600
Subject: [PATCH] ux: improve PagerDuty integration clarity
- Replace terse helper text with step-by-step setup guide in config form
- Add what-gets-synced breakdown when PagerDuty is enabled
- Clear visual hierarchy with numbered steps and inline callout
---
.../controllers/api_docs_html/index.html.heex | 72 ++++++
lib/towerops_web/graphql/resolvers/alert.ex | 77 +++++++
lib/towerops_web/graphql/resolvers/device.ex | 167 ++++++++++++++
lib/towerops_web/graphql/resolvers/site.ex | 87 +++++++
lib/towerops_web/graphql/schema.ex | 212 ++++++++++++++++++
lib/towerops_web/graphql/types/activity.ex | 16 ++
lib/towerops_web/graphql/types/agent.ex | 33 +++
lib/towerops_web/graphql/types/alert.ex | 48 ++++
lib/towerops_web/graphql/types/common.ex | 34 +++
lib/towerops_web/graphql/types/device.ex | 87 +++++++
lib/towerops_web/graphql/types/integration.ex | 23 ++
lib/towerops_web/graphql/types/member.ex | 31 +++
.../graphql/types/organization.ex | 56 +++++
lib/towerops_web/graphql/types/site.ex | 60 +++++
.../live/org/settings_live.html.heex | 24 +-
mix.exs | 2 +
16 files changed, 1025 insertions(+), 4 deletions(-)
create mode 100644 lib/towerops_web/graphql/resolvers/alert.ex
create mode 100644 lib/towerops_web/graphql/resolvers/device.ex
create mode 100644 lib/towerops_web/graphql/resolvers/site.ex
create mode 100644 lib/towerops_web/graphql/schema.ex
create mode 100644 lib/towerops_web/graphql/types/activity.ex
create mode 100644 lib/towerops_web/graphql/types/agent.ex
create mode 100644 lib/towerops_web/graphql/types/alert.ex
create mode 100644 lib/towerops_web/graphql/types/common.ex
create mode 100644 lib/towerops_web/graphql/types/device.ex
create mode 100644 lib/towerops_web/graphql/types/integration.ex
create mode 100644 lib/towerops_web/graphql/types/member.ex
create mode 100644 lib/towerops_web/graphql/types/organization.ex
create mode 100644 lib/towerops_web/graphql/types/site.ex
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 e4ce3382..5ec51b0d 100644
--- a/lib/towerops_web/controllers/api_docs_html/index.html.heex
+++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex
@@ -76,6 +76,78 @@
Account Data (GDPR)
+
+
+ Alerts
+
+
+
+
+ Agents
+
+
+
+
+ Organization
+
+
+
+
+ Members
+
+
+
+
+ Invitations
+
+
+
+
+ Integrations
+
+
+
+
+ Check Results & Metrics
+
+
+
+
+ Activity Feed
+
+
Map.take([:status, :device_id, :limit])
+ |> Map.new(fn {k, v} -> {to_string(k), v} end)
+
+ alerts = Alerts.list_organization_alerts(org_id, filters)
+ {:ok, alerts}
+ end
+
+ def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ alert = Alerts.get_alert!(id)
+ device = alert.device
+
+ if device && device.organization_id == org_id do
+ {:ok, alert}
+ else
+ {:error, "Alert not found"}
+ end
+ rescue
+ Ecto.NoResultsError -> {:error, "Alert not found"}
+ end
+
+ def get(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def acknowledge(_parent, %{id: id}, %{context: %{organization_id: org_id} = ctx}) do
+ alert = Alerts.get_alert!(id)
+ device = alert.device
+
+ if device && device.organization_id == org_id do
+ user_id = ctx[:user] && ctx[:user].id
+
+ case Alerts.acknowledge_alert(alert, user_id) do
+ {:ok, updated} ->
+ {:ok, Repo.preload(updated, [:device, :acknowledged_by], force: true)}
+
+ {:error, _changeset} ->
+ {:error, "Could not acknowledge alert"}
+ end
+ else
+ {:error, "Alert not found"}
+ end
+ rescue
+ Ecto.NoResultsError -> {:error, "Alert not found"}
+ end
+
+ def acknowledge(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def resolve_alert(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ alert = Alerts.get_alert!(id)
+ device = alert.device
+
+ if device && device.organization_id == org_id do
+ case Alerts.resolve_alert(alert) do
+ {:ok, updated} ->
+ {:ok, Repo.preload(updated, [:device, :acknowledged_by], force: true)}
+
+ {:error, _changeset} ->
+ {:error, "Could not resolve alert"}
+ end
+ else
+ {:error, "Alert not found"}
+ end
+ rescue
+ Ecto.NoResultsError -> {:error, "Alert not found"}
+ end
+
+ def resolve_alert(_parent, _args, _resolution), do: {:error, "Authentication required"}
+end
diff --git a/lib/towerops_web/graphql/resolvers/device.ex b/lib/towerops_web/graphql/resolvers/device.ex
new file mode 100644
index 00000000..ba49ce02
--- /dev/null
+++ b/lib/towerops_web/graphql/resolvers/device.ex
@@ -0,0 +1,167 @@
+defmodule ToweropsWeb.GraphQL.Resolvers.Device do
+ @moduledoc "GraphQL resolvers for device queries and mutations."
+
+ alias Towerops.Devices
+ alias Towerops.Monitoring
+ alias Towerops.Repo
+ alias Towerops.Snmp
+
+ def list(_parent, args, %{context: %{organization_id: org_id}}) do
+ params = args |> Map.new(fn {k, v} -> {to_string(k), v} end)
+ devices = Devices.list_organization_devices(org_id, params)
+ {:ok, devices}
+ end
+
+ def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Devices.Device, id) do
+ nil ->
+ {:error, "Device not found"}
+
+ device ->
+ if device.organization_id == org_id do
+ {:ok, Repo.preload(device, :site)}
+ else
+ {:error, "Device not found"}
+ end
+ end
+ end
+
+ def get(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
+ attrs =
+ input
+ |> Map.new(fn {k, v} -> {to_string(k), v} end)
+ |> Map.put("organization_id", org_id)
+
+ case Devices.create_device(attrs) do
+ {:ok, device} -> {:ok, Repo.preload(device, :site)}
+ {:error, changeset} -> {:error, format_errors(changeset)}
+ end
+ end
+
+ def create(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Devices.Device, id) do
+ nil ->
+ {:error, "Device not found"}
+
+ device ->
+ if device.organization_id == org_id do
+ attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
+
+ case Devices.update_device(device, attrs) do
+ {:ok, updated} -> {:ok, Repo.preload(updated, :site, force: true)}
+ {:error, changeset} -> {:error, format_errors(changeset)}
+ end
+ else
+ {:error, "Device not found"}
+ end
+ end
+ end
+
+ def update(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Devices.Device, id) do
+ nil ->
+ {:error, "Device not found"}
+
+ device ->
+ if device.organization_id == org_id do
+ case Devices.delete_device(device) do
+ {:ok, _} -> {:ok, %{success: true, message: "Device deleted"}}
+ {:error, _} -> {:ok, %{success: false, message: "Could not delete device"}}
+ end
+ else
+ {:error, "Device not found"}
+ end
+ end
+ end
+
+ def delete(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def metrics(_parent, %{device_id: device_id} = args, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Devices.Device, device_id) do
+ nil ->
+ {:error, "Device not found"}
+
+ device ->
+ if device.organization_id == org_id do
+ hours = parse_time_range(Map.get(args, :time_range, "24h"))
+ from_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second)
+ check_type = args[:sensor_type]
+
+ checks = Monitoring.list_checks(org_id, device_id: device.id, check_type: check_type)
+
+ metrics =
+ Enum.flat_map(checks, fn check ->
+ results = Monitoring.get_check_results(check.id, from: from_time)
+
+ Enum.map(results, fn r ->
+ %{
+ timestamp: to_string(r.checked_at),
+ value: r.value,
+ status: r.status,
+ check_name: check.name,
+ check_type: check.check_type
+ }
+ end)
+ end)
+
+ {:ok, metrics}
+ else
+ {:error, "Device not found"}
+ end
+ end
+ end
+
+ def metrics(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def interfaces(_parent, %{device_id: device_id}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Devices.Device, device_id) do
+ nil ->
+ {:error, "Device not found"}
+
+ device ->
+ if device.organization_id == org_id do
+ device = Repo.preload(device, :snmp_device)
+
+ interfaces =
+ case device.snmp_device do
+ nil ->
+ []
+
+ snmp_device ->
+ Snmp.list_interfaces(snmp_device.id)
+ end
+
+ {:ok, interfaces}
+ else
+ {:error, "Device not found"}
+ end
+ end
+ end
+
+ def interfaces(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ defp parse_time_range(range) do
+ case Integer.parse(String.replace(range, "h", "")) do
+ {hours, _} -> hours
+ :error -> 24
+ end
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
+ |> Enum.join("; ")
+ end
+end
diff --git a/lib/towerops_web/graphql/resolvers/site.ex b/lib/towerops_web/graphql/resolvers/site.ex
new file mode 100644
index 00000000..41e96e30
--- /dev/null
+++ b/lib/towerops_web/graphql/resolvers/site.ex
@@ -0,0 +1,87 @@
+defmodule ToweropsWeb.GraphQL.Resolvers.Site do
+ @moduledoc "GraphQL resolvers for site queries and mutations."
+
+ alias Towerops.Repo
+ alias Towerops.Sites
+
+ def list(_parent, _args, %{context: %{organization_id: org_id}}) do
+ sites = Sites.list_organization_sites(org_id)
+ {:ok, sites}
+ end
+
+ def list(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Sites.Site, id) do
+ nil -> {:error, "Site not found"}
+ site ->
+ if site.organization_id == org_id do
+ {:ok, site}
+ else
+ {:error, "Site not found"}
+ end
+ end
+ end
+
+ def get(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do
+ attrs =
+ input
+ |> Map.new(fn {k, v} -> {to_string(k), v} end)
+ |> Map.put("organization_id", org_id)
+
+ case Sites.create_site(attrs) do
+ {:ok, site} -> {:ok, site}
+ {:error, changeset} -> {:error, format_errors(changeset)}
+ end
+ end
+
+ def create(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Sites.Site, id) do
+ nil -> {:error, "Site not found"}
+ site ->
+ if site.organization_id == org_id do
+ attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end)
+
+ case Sites.update_site(site, attrs) do
+ {:ok, updated} -> {:ok, updated}
+ {:error, changeset} -> {:error, format_errors(changeset)}
+ end
+ else
+ {:error, "Site not found"}
+ end
+ end
+ end
+
+ def update(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do
+ case Repo.get(Towerops.Sites.Site, id) do
+ nil -> {:error, "Site not found"}
+ site ->
+ if site.organization_id == org_id do
+ case Sites.delete_site(site) do
+ {:ok, _} -> {:ok, %{success: true, message: "Site deleted"}}
+ {:error, _} -> {:ok, %{success: false, message: "Could not delete site"}}
+ end
+ else
+ {:error, "Site not found"}
+ end
+ end
+ end
+
+ def delete(_parent, _args, _resolution), do: {:error, "Authentication required"}
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
+ |> Enum.join("; ")
+ end
+end
diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex
new file mode 100644
index 00000000..70b7dad4
--- /dev/null
+++ b/lib/towerops_web/graphql/schema.ex
@@ -0,0 +1,212 @@
+defmodule ToweropsWeb.GraphQL.Schema do
+ @moduledoc """
+ GraphQL schema for the TowerOps API.
+
+ Provides a complete GraphQL interface for managing devices, sites, alerts,
+ agents, organization settings, members, integrations, and activity feeds.
+ """
+ use Absinthe.Schema
+
+ import_types ToweropsWeb.GraphQL.Types.Common
+ import_types ToweropsWeb.GraphQL.Types.Device
+ import_types ToweropsWeb.GraphQL.Types.Site
+ import_types ToweropsWeb.GraphQL.Types.Alert
+ import_types ToweropsWeb.GraphQL.Types.Agent
+ import_types ToweropsWeb.GraphQL.Types.Organization
+ import_types ToweropsWeb.GraphQL.Types.Member
+ import_types ToweropsWeb.GraphQL.Types.Integration
+ import_types ToweropsWeb.GraphQL.Types.Activity
+
+ query do
+ # Devices
+ field :devices, list_of(:device) do
+ arg :site_id, :id
+ arg :status, :string
+ arg :limit, :integer, default_value: 100
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.list/3
+ end
+
+ field :device, :device do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.get/3
+ end
+
+ # Sites
+ field :sites, list_of(:site) do
+ resolve &ToweropsWeb.GraphQL.Resolvers.Site.list/3
+ end
+
+ field :site, :site do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Site.get/3
+ end
+
+ # Alerts
+ field :alerts, list_of(:alert) do
+ arg :status, :string
+ arg :device_id, :id
+ arg :limit, :integer, default_value: 100
+ resolve &ToweropsWeb.GraphQL.Resolvers.Alert.list/3
+ end
+
+ field :alert, :alert do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Alert.get/3
+ end
+
+ # Agents
+ field :agents, list_of(:agent) do
+ resolve &ToweropsWeb.GraphQL.Resolvers.Agent.list/3
+ end
+
+ field :agent, :agent do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Agent.get/3
+ end
+
+ # Organization
+ field :organization, :organization do
+ resolve &ToweropsWeb.GraphQL.Resolvers.Organization.get/3
+ end
+
+ # Members
+ field :members, list_of(:member) do
+ resolve &ToweropsWeb.GraphQL.Resolvers.Member.list/3
+ end
+
+ # Integrations
+ field :integrations, list_of(:integration) do
+ resolve &ToweropsWeb.GraphQL.Resolvers.Integration.list/3
+ end
+
+ # Activity feed
+ field :activity, list_of(:activity_item) do
+ arg :limit, :integer, default_value: 50
+ arg :type, :string
+ resolve &ToweropsWeb.GraphQL.Resolvers.Activity.list/3
+ end
+
+ # Device metrics
+ field :device_metrics, list_of(:metric_point) do
+ arg :device_id, non_null(:id)
+ arg :sensor_type, :string
+ arg :time_range, :string, default_value: "24h"
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.metrics/3
+ end
+
+ # Device interfaces
+ field :device_interfaces, list_of(:interface) do
+ arg :device_id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.interfaces/3
+ end
+ end
+
+ mutation do
+ # Device CRUD
+ field :create_device, :device do
+ arg :input, non_null(:device_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.create/3
+ end
+
+ field :update_device, :device do
+ arg :id, non_null(:id)
+ arg :input, non_null(:device_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.update/3
+ end
+
+ field :delete_device, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Device.delete/3
+ end
+
+ # Site CRUD
+ field :create_site, :site do
+ arg :input, non_null(:site_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Site.create/3
+ end
+
+ field :update_site, :site do
+ arg :id, non_null(:id)
+ arg :input, non_null(:site_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Site.update/3
+ end
+
+ field :delete_site, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Site.delete/3
+ end
+
+ # Alert actions
+ field :acknowledge_alert, :alert do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Alert.acknowledge/3
+ end
+
+ field :resolve_alert, :alert do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Alert.resolve_alert/3
+ end
+
+ # Agent management
+ field :create_agent, :agent_with_token do
+ arg :name, non_null(:string)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Agent.create/3
+ end
+
+ field :delete_agent, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Agent.delete/3
+ end
+
+ # Organization settings
+ field :update_organization, :organization do
+ arg :input, non_null(:organization_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Organization.update/3
+ end
+
+ # Member management
+ field :send_invitation, :invitation do
+ arg :email, non_null(:string)
+ arg :role, :string, default_value: "member"
+ resolve &ToweropsWeb.GraphQL.Resolvers.Member.invite/3
+ end
+
+ field :cancel_invitation, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Member.cancel_invitation/3
+ end
+
+ field :remove_member, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Member.remove/3
+ end
+
+ field :update_member_role, :member do
+ arg :id, non_null(:id)
+ arg :role, non_null(:string)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Member.update_role/3
+ end
+
+ # Integrations
+ field :create_integration, :integration do
+ arg :input, non_null(:integration_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Integration.create/3
+ end
+
+ field :update_integration, :integration do
+ arg :id, non_null(:id)
+ arg :input, non_null(:integration_input)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Integration.update/3
+ end
+
+ field :delete_integration, :delete_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Integration.delete/3
+ end
+
+ field :test_integration, :test_result do
+ arg :id, non_null(:id)
+ resolve &ToweropsWeb.GraphQL.Resolvers.Integration.test_connection/3
+ end
+ end
+end
diff --git a/lib/towerops_web/graphql/types/activity.ex b/lib/towerops_web/graphql/types/activity.ex
new file mode 100644
index 00000000..b8f107d8
--- /dev/null
+++ b/lib/towerops_web/graphql/types/activity.ex
@@ -0,0 +1,16 @@
+defmodule ToweropsWeb.GraphQL.Types.Activity do
+ @moduledoc "GraphQL types for activity feed items."
+ use Absinthe.Schema.Notation
+
+ object :activity_item do
+ field :summary, :string
+ field :detail, :string
+ field :timestamp, :string
+ field :severity, :string
+ field :icon, :string
+ field :device_name, :string
+ field :site_name, :string
+ field :link, :string
+ field :type, :string
+ end
+end
diff --git a/lib/towerops_web/graphql/types/agent.ex b/lib/towerops_web/graphql/types/agent.ex
new file mode 100644
index 00000000..0fa00c16
--- /dev/null
+++ b/lib/towerops_web/graphql/types/agent.ex
@@ -0,0 +1,33 @@
+defmodule ToweropsWeb.GraphQL.Types.Agent do
+ @moduledoc "GraphQL types for agents."
+ use Absinthe.Schema.Notation
+
+ object :agent do
+ field :id, :id
+ field :name, :string
+ field :enabled, :boolean
+ field :is_cloud_poller, :boolean
+ field :allow_remote_debug, :boolean
+ field :last_seen_at, :string
+ field :last_ip, :string
+ field :metadata, :json
+ field :organization_id, :id
+ field :inserted_at, :string
+ field :updated_at, :string
+
+ field :device_count, :integer do
+ resolve fn agent, _, _ ->
+ count = Towerops.Agents.count_assigned_devices(agent.id)
+ {:ok, count}
+ end
+ end
+ end
+
+ object :agent_with_token do
+ field :id, :id
+ field :name, :string
+ field :enabled, :boolean
+ field :token, :string
+ field :inserted_at, :string
+ end
+end
diff --git a/lib/towerops_web/graphql/types/alert.ex b/lib/towerops_web/graphql/types/alert.ex
new file mode 100644
index 00000000..506aa741
--- /dev/null
+++ b/lib/towerops_web/graphql/types/alert.ex
@@ -0,0 +1,48 @@
+defmodule ToweropsWeb.GraphQL.Types.Alert do
+ @moduledoc "GraphQL types for alerts."
+ use Absinthe.Schema.Notation
+
+ object :alert do
+ field :id, :id
+ field :alert_type, :string
+ field :message, :string
+ field :triggered_at, :string
+ field :acknowledged_at, :string
+ field :resolved_at, :string
+ field :email_sent_at, :string
+ field :device_id, :id
+ field :acknowledged_by_id, :id
+ field :gaiia_impact, :json
+ field :inserted_at, :string
+
+ field :device, :device do
+ resolve fn alert, _, _ ->
+ case alert.device do
+ %Ecto.Association.NotLoaded{} -> {:ok, nil}
+ device -> {:ok, device}
+ end
+ end
+ end
+
+ field :acknowledged_by_email, :string do
+ resolve fn alert, _, _ ->
+ case alert.acknowledged_by do
+ %Ecto.Association.NotLoaded{} -> {:ok, nil}
+ nil -> {:ok, nil}
+ user -> {:ok, user.email}
+ end
+ end
+ end
+ end
+
+ scalar :json do
+ parse fn input ->
+ case Jason.decode(input.value) do
+ {:ok, result} -> {:ok, result}
+ _ -> :error
+ end
+ end
+
+ serialize fn value -> value end
+ end
+end
diff --git a/lib/towerops_web/graphql/types/common.ex b/lib/towerops_web/graphql/types/common.ex
new file mode 100644
index 00000000..98e774fa
--- /dev/null
+++ b/lib/towerops_web/graphql/types/common.ex
@@ -0,0 +1,34 @@
+defmodule ToweropsWeb.GraphQL.Types.Common do
+ @moduledoc "Common GraphQL types shared across the schema."
+ use Absinthe.Schema.Notation
+
+ object :delete_result do
+ field :success, non_null(:boolean)
+ field :message, :string
+ end
+
+ object :test_result do
+ field :success, non_null(:boolean)
+ field :message, :string
+ end
+
+ object :metric_point do
+ field :timestamp, :string
+ field :value, :float
+ field :status, :string
+ field :check_name, :string
+ field :check_type, :string
+ end
+
+ object :interface do
+ field :id, :id
+ field :if_index, :integer
+ field :if_name, :string
+ field :if_descr, :string
+ field :if_type, :string
+ field :if_speed, :float
+ field :if_admin_status, :string
+ field :if_oper_status, :string
+ field :if_alias, :string
+ end
+end
diff --git a/lib/towerops_web/graphql/types/device.ex b/lib/towerops_web/graphql/types/device.ex
new file mode 100644
index 00000000..2010ca7b
--- /dev/null
+++ b/lib/towerops_web/graphql/types/device.ex
@@ -0,0 +1,87 @@
+defmodule ToweropsWeb.GraphQL.Types.Device do
+ @moduledoc "GraphQL types for devices."
+ use Absinthe.Schema.Notation
+
+ object :device do
+ field :id, :id
+ field :name, :string
+ field :ip_address, :string
+ field :description, :string
+ field :display_order, :integer
+ field :status, :string
+ field :last_checked_at, :string
+ field :last_status_change_at, :string
+ field :monitoring_enabled, :boolean
+ field :check_interval_seconds, :integer
+
+ # SNMP fields
+ field :snmp_enabled, :boolean
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_community_source, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+ field :snmp_transport_source, :string
+ field :last_discovery_at, :string
+ field :last_snmp_poll_at, :string
+
+ # SNMPv3 fields
+ field :snmpv3_security_level, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_priv_protocol, :string
+ field :snmpv3_credential_source, :string
+
+ # MikroTik fields
+ field :mikrotik_username, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+ field :mikrotik_enabled, :boolean
+ field :mikrotik_credential_source, :string
+
+ # Topology
+ field :device_role, :string
+ field :device_role_source, :string
+
+ field :site_id, :id
+ field :organization_id, :id
+ field :inserted_at, :string
+ field :updated_at, :string
+
+ field :site, :site do
+ resolve fn device, _, _ ->
+ case device.site do
+ %Ecto.Association.NotLoaded{} -> {:ok, nil}
+ site -> {:ok, site}
+ end
+ end
+ end
+ end
+
+ input_object :device_input do
+ field :name, :string
+ field :ip_address, :string
+ field :description, :string
+ field :site_id, :id
+ field :monitoring_enabled, :boolean
+ field :check_interval_seconds, :integer
+ field :snmp_enabled, :boolean
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_auth_password, :string
+ field :snmpv3_priv_protocol, :string
+ field :snmpv3_priv_password, :string
+ field :mikrotik_username, :string
+ field :mikrotik_password, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+ field :mikrotik_enabled, :boolean
+ field :device_role, :string
+ end
+end
diff --git a/lib/towerops_web/graphql/types/integration.ex b/lib/towerops_web/graphql/types/integration.ex
new file mode 100644
index 00000000..36d3f915
--- /dev/null
+++ b/lib/towerops_web/graphql/types/integration.ex
@@ -0,0 +1,23 @@
+defmodule ToweropsWeb.GraphQL.Types.Integration do
+ @moduledoc "GraphQL types for integrations."
+ use Absinthe.Schema.Notation
+
+ object :integration do
+ field :id, :id
+ field :provider, :string
+ field :enabled, :boolean
+ field :sync_interval_minutes, :integer
+ field :last_synced_at, :string
+ field :last_sync_status, :string
+ field :organization_id, :id
+ field :inserted_at, :string
+ field :updated_at, :string
+ end
+
+ input_object :integration_input do
+ field :provider, :string
+ field :enabled, :boolean
+ field :credentials, :json
+ field :sync_interval_minutes, :integer
+ end
+end
diff --git a/lib/towerops_web/graphql/types/member.ex b/lib/towerops_web/graphql/types/member.ex
new file mode 100644
index 00000000..a089ab4b
--- /dev/null
+++ b/lib/towerops_web/graphql/types/member.ex
@@ -0,0 +1,31 @@
+defmodule ToweropsWeb.GraphQL.Types.Member do
+ @moduledoc "GraphQL types for members and invitations."
+ use Absinthe.Schema.Notation
+
+ object :member do
+ field :id, :id
+ field :role, :string
+ field :is_default, :boolean
+ field :inserted_at, :string
+
+ field :user_id, :id
+ field :email, :string do
+ resolve fn membership, _, _ ->
+ case membership.user do
+ %Ecto.Association.NotLoaded{} -> {:ok, nil}
+ nil -> {:ok, nil}
+ user -> {:ok, user.email}
+ end
+ end
+ end
+ end
+
+ object :invitation do
+ field :id, :id
+ field :email, :string
+ field :role, :string
+ field :expires_at, :string
+ field :accepted_at, :string
+ field :inserted_at, :string
+ end
+end
diff --git a/lib/towerops_web/graphql/types/organization.ex b/lib/towerops_web/graphql/types/organization.ex
new file mode 100644
index 00000000..29eed548
--- /dev/null
+++ b/lib/towerops_web/graphql/types/organization.ex
@@ -0,0 +1,56 @@
+defmodule ToweropsWeb.GraphQL.Types.Organization do
+ @moduledoc "GraphQL types for organizations."
+ use Absinthe.Schema.Notation
+
+ object :organization do
+ field :id, :id
+ field :name, :string
+ field :slug, :string
+ field :subscription_plan, :string
+ field :use_sites, :boolean
+
+ # SNMP defaults
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+
+ # SNMPv3 defaults
+ field :snmpv3_security_level, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_priv_protocol, :string
+
+ # MikroTik defaults
+ field :mikrotik_enabled, :boolean
+ field :mikrotik_username, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+
+ field :default_agent_token_id, :id
+ field :inserted_at, :string
+ field :updated_at, :string
+ end
+
+ input_object :organization_input do
+ field :name, :string
+ field :use_sites, :boolean
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_auth_password, :string
+ field :snmpv3_priv_protocol, :string
+ field :snmpv3_priv_password, :string
+ field :mikrotik_enabled, :boolean
+ field :mikrotik_username, :string
+ field :mikrotik_password, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+ field :default_agent_token_id, :id
+ end
+end
diff --git a/lib/towerops_web/graphql/types/site.ex b/lib/towerops_web/graphql/types/site.ex
new file mode 100644
index 00000000..82cb72e0
--- /dev/null
+++ b/lib/towerops_web/graphql/types/site.ex
@@ -0,0 +1,60 @@
+defmodule ToweropsWeb.GraphQL.Types.Site do
+ @moduledoc "GraphQL types for sites."
+ use Absinthe.Schema.Notation
+
+ object :site do
+ field :id, :id
+ field :name, :string
+ field :description, :string
+ field :location, :string
+ field :display_order, :integer
+
+ # SNMP config
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+
+ # SNMPv3
+ field :snmpv3_security_level, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_priv_protocol, :string
+
+ # MikroTik
+ field :mikrotik_username, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+ field :mikrotik_enabled, :boolean
+
+ field :organization_id, :id
+ field :agent_token_id, :id
+ field :parent_site_id, :id
+ field :inserted_at, :string
+ field :updated_at, :string
+ end
+
+ input_object :site_input do
+ field :name, :string
+ field :description, :string
+ field :location, :string
+ field :snmp_version, :string
+ field :snmp_community, :string
+ field :snmp_port, :integer
+ field :snmp_transport, :string
+ field :snmpv3_username, :string
+ field :snmpv3_auth_protocol, :string
+ field :snmpv3_auth_password, :string
+ field :snmpv3_priv_protocol, :string
+ field :snmpv3_priv_password, :string
+ field :mikrotik_username, :string
+ field :mikrotik_password, :string
+ field :mikrotik_port, :integer
+ field :mikrotik_ssh_port, :integer
+ field :mikrotik_use_ssl, :boolean
+ field :mikrotik_enabled, :boolean
+ field :agent_token_id, :id
+ field :parent_site_id, :id
+ end
+end
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex
index 774ffa86..8f0c067a 100644
--- a/lib/towerops_web/live/org/settings_live.html.heex
+++ b/lib/towerops_web/live/org/settings_live.html.heex
@@ -841,7 +841,12 @@
Syncs every {integration.sync_interval_minutes} minutes. Reconciliation runs nightly. Also receives real-time webhook updates.
<% end %>
<%= if provider.id == "pagerduty" do %>
- Event-driven — alerts are sent to PagerDuty in real time when devices go down, are acknowledged, or recover.
+ Event-driven — no polling needed. TowerOps sends events to PagerDuty in real time:
+
+ - Device down → PagerDuty incident triggered (critical)
+ - Alert acknowledged → PagerDuty incident acknowledged
+ - Device recovers → PagerDuty incident auto-resolved
+
<% end %>
@@ -904,9 +909,20 @@
/>
<%= if provider.id == "pagerduty" do %>
-
- Find this in PagerDuty → Services → Your Service → Integrations → Events API v2
-
+
+
+ Where to find your Integration Key
+
+
+ - In PagerDuty, go to Services → select your service (or create one)
+ - Click the Integrations tab
+ - Click Add Integration → select Events API v2
+ - Copy the Integration Key and paste it above
+
+
+ When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
+
+
<% end %>
<%= if provider.id != "pagerduty" do %>
diff --git a/mix.exs b/mix.exs
index 674efbc7..489cdc41 100644
--- a/mix.exs
+++ b/mix.exs
@@ -77,6 +77,8 @@ defmodule Towerops.MixProject do
{:phoenix_pubsub_redis, "~> 3.0"},
{:ecto_psql_extras, "~> 0.6"},
{:mox, "~> 1.0", only: :test},
+ {:absinthe, "~> 1.7"},
+ {:absinthe_plug, "~> 1.5"},
{:honeybadger, "~> 0.24"},
{:stream_data, "~> 1.1", only: :test},
{:tzdata, "~> 1.1"},