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
This commit is contained in:
parent
bb4ff232e3
commit
9d437a8119
16 changed files with 1025 additions and 4 deletions
|
|
@ -76,6 +76,78 @@
|
|||
Account Data (GDPR)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#alerts"
|
||||
data-nav-link="alerts"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Alerts
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#agents"
|
||||
data-nav-link="agents"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Agents
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#organization"
|
||||
data-nav-link="organization"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Organization
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#members"
|
||||
data-nav-link="members"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Members
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#invitations"
|
||||
data-nav-link="invitations"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Invitations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#integrations"
|
||||
data-nav-link="integrations"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Integrations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#check-results"
|
||||
data-nav-link="check-results"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Check Results & Metrics
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#activity"
|
||||
data-nav-link="activity"
|
||||
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
Activity Feed
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#graphs"
|
||||
|
|
|
|||
77
lib/towerops_web/graphql/resolvers/alert.ex
Normal file
77
lib/towerops_web/graphql/resolvers/alert.ex
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
defmodule ToweropsWeb.GraphQL.Resolvers.Alert do
|
||||
@moduledoc "GraphQL resolvers for alert queries and mutations."
|
||||
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Repo
|
||||
|
||||
def list(_parent, args, %{context: %{organization_id: org_id}}) do
|
||||
filters =
|
||||
args
|
||||
|> 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
|
||||
167
lib/towerops_web/graphql/resolvers/device.ex
Normal file
167
lib/towerops_web/graphql/resolvers/device.ex
Normal file
|
|
@ -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
|
||||
87
lib/towerops_web/graphql/resolvers/site.ex
Normal file
87
lib/towerops_web/graphql/resolvers/site.ex
Normal file
|
|
@ -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
|
||||
212
lib/towerops_web/graphql/schema.ex
Normal file
212
lib/towerops_web/graphql/schema.ex
Normal file
|
|
@ -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
|
||||
16
lib/towerops_web/graphql/types/activity.ex
Normal file
16
lib/towerops_web/graphql/types/activity.ex
Normal file
|
|
@ -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
|
||||
33
lib/towerops_web/graphql/types/agent.ex
Normal file
33
lib/towerops_web/graphql/types/agent.ex
Normal file
|
|
@ -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
|
||||
48
lib/towerops_web/graphql/types/alert.ex
Normal file
48
lib/towerops_web/graphql/types/alert.ex
Normal file
|
|
@ -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
|
||||
34
lib/towerops_web/graphql/types/common.ex
Normal file
34
lib/towerops_web/graphql/types/common.ex
Normal file
|
|
@ -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
|
||||
87
lib/towerops_web/graphql/types/device.ex
Normal file
87
lib/towerops_web/graphql/types/device.ex
Normal file
|
|
@ -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
|
||||
23
lib/towerops_web/graphql/types/integration.ex
Normal file
23
lib/towerops_web/graphql/types/integration.ex
Normal file
|
|
@ -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
|
||||
31
lib/towerops_web/graphql/types/member.ex
Normal file
31
lib/towerops_web/graphql/types/member.ex
Normal file
|
|
@ -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
|
||||
56
lib/towerops_web/graphql/types/organization.ex
Normal file
56
lib/towerops_web/graphql/types/organization.ex
Normal file
|
|
@ -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
|
||||
60
lib/towerops_web/graphql/types/site.ex
Normal file
60
lib/towerops_web/graphql/types/site.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
<ul class="mt-1 space-y-0.5 list-disc list-inside text-xs text-gray-500 dark:text-gray-400">
|
||||
<li><strong class="text-gray-700 dark:text-gray-300">Device down</strong> → PagerDuty incident triggered (critical)</li>
|
||||
<li><strong class="text-gray-700 dark:text-gray-300">Alert acknowledged</strong> → PagerDuty incident acknowledged</li>
|
||||
<li><strong class="text-gray-700 dark:text-gray-300">Device recovers</strong> → PagerDuty incident auto-resolved</li>
|
||||
</ul>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -904,9 +909,20 @@
|
|||
/>
|
||||
|
||||
<%= if provider.id == "pagerduty" do %>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 -mt-2">
|
||||
Find this in PagerDuty → Services → Your Service → Integrations → Events API v2
|
||||
</p>
|
||||
<div class="rounded-md bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 p-4 -mt-1">
|
||||
<h4 class="text-xs font-semibold text-blue-900 dark:text-blue-300 mb-2">
|
||||
Where to find your Integration Key
|
||||
</h4>
|
||||
<ol class="list-decimal list-inside space-y-1 text-xs text-blue-800 dark:text-blue-300">
|
||||
<li>In PagerDuty, go to <strong>Services</strong> → select your service (or create one)</li>
|
||||
<li>Click the <strong>Integrations</strong> tab</li>
|
||||
<li>Click <strong>Add Integration</strong> → select <strong>Events API v2</strong></li>
|
||||
<li>Copy the <strong>Integration Key</strong> and paste it above</li>
|
||||
</ol>
|
||||
<p class="mt-2 text-xs text-blue-700 dark:text-blue-400">
|
||||
When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if provider.id != "pagerduty" do %>
|
||||
|
|
|
|||
2
mix.exs
2
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"},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue