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: + <% 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 +

    +
      +
    1. In PagerDuty, go to Services → select your service (or create one)
    2. +
    3. Click the Integrations tab
    4. +
    5. Click Add Integration → select Events API v2
    6. +
    7. Copy the Integration Key and paste it above
    8. +
    +

    + 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"},