From 146f5745cfab64d8b2c579fc48c0a3b268963f9e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 14 Feb 2026 12:22:44 -0600 Subject: [PATCH] fix: resolve compilation errors, test failures, and credo issues - Escape HEEx template braces in GraphQL/API docs with raw(~S[...]) - Fix test assertions for updated marketing copy and UI text - Extract helper functions in GraphQL resolvers to reduce nesting depth - Create shared ErrorHelpers module for API controllers - Fix ETS race condition in brute force whitelist cache for async tests - Fix property test generators to use ASCII instead of printable unicode - Add alert_severity helper to site_live/show - Update accounts fixtures for explicit user confirmation --- lib/towerops/accounts.ex | 23 +- lib/towerops/activity_feed.ex | 2 +- lib/towerops/alerts.ex | 5 +- lib/towerops/organizations.ex | 5 +- lib/towerops/pagerduty/notifier.ex | 4 +- lib/towerops/security/brute_force.ex | 23 +- lib/towerops/workers/device_monitor_worker.ex | 5 +- .../controllers/api/error_helpers.ex | 26 + .../controllers/api/v1/agents_controller.ex | 17 +- .../controllers/api/v1/alerts_controller.ex | 2 +- .../api/v1/check_results_controller.ex | 41 +- .../api/v1/integrations_controller.ex | 21 +- .../api/v1/invitations_controller.ex | 16 +- .../controllers/api/v1/members_controller.ex | 14 +- .../api/v1/organization_controller.ex | 14 +- .../controllers/api_docs_html/index.html.heex | 837 +++++++++++++----- .../graphql_docs_html/index.html.heex | 650 ++++++++++---- .../controllers/invitation_controller.ex | 38 +- .../controllers/page_html/home.html.heex | 6 +- .../user_confirmation_html/new.html.heex | 4 +- .../graphql/resolvers/activity.ex | 4 +- lib/towerops_web/graphql/resolvers/agent.ex | 21 +- lib/towerops_web/graphql/resolvers/device.ex | 151 ++-- .../graphql/resolvers/integration.ex | 6 +- lib/towerops_web/graphql/resolvers/member.ex | 32 +- .../graphql/resolvers/organization.ex | 6 +- lib/towerops_web/graphql/resolvers/site.ex | 59 +- lib/towerops_web/graphql/schema.ex | 164 ++-- lib/towerops_web/graphql/types/agent.ex | 4 +- lib/towerops_web/graphql/types/alert.ex | 20 +- lib/towerops_web/graphql/types/device.ex | 4 +- lib/towerops_web/graphql/types/member.ex | 5 +- lib/towerops_web/live/help_live/index.ex | 75 +- lib/towerops_web/live/org/settings_live.ex | 12 +- .../live/org/settings_live.html.heex | 217 +++-- lib/towerops_web/live/site_live/show.ex | 4 + .../live/site_live/show.html.heex | 7 +- mix.lock | 3 + test/support/fixtures/accounts_fixtures.ex | 14 +- test/towerops/accounts_test.exs | 2 +- .../integration_property_test.exs | 18 +- .../organization_property_test.exs | 46 +- .../controllers/page_controller_test.exs | 4 +- .../controllers/page_html_test.exs | 4 +- test/towerops_web/live/alert_live_test.exs | 22 +- .../live/org/settings_live_members_test.exs | 4 +- .../live/org/settings_live_property_test.exs | 17 +- .../live/org/settings_live_test.exs | 82 +- test/towerops_web/live/site_live_test.exs | 11 +- 49 files changed, 1797 insertions(+), 974 deletions(-) create mode 100644 lib/towerops_web/controllers/api/error_helpers.ex diff --git a/lib/towerops/accounts.ex b/lib/towerops/accounts.ex index 668205f4..4a75c4ca 100644 --- a/lib/towerops/accounts.ex +++ b/lib/towerops/accounts.ex @@ -122,9 +122,7 @@ defmodule Towerops.Accounts do """ @spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def register_user(attrs) do - user_changeset = - %User{} - |> User.registration_changeset(attrs) + user_changeset = User.registration_changeset(%User{}, attrs) multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :user, user_changeset) @@ -154,9 +152,7 @@ defmodule Towerops.Accounts do @dialyzer {:nowarn_function, register_user_with_organization: 1} @spec register_user_with_organization(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def register_user_with_organization(attrs) do - user_changeset = - %User{} - |> User.registration_changeset(attrs) + user_changeset = User.registration_changeset(%User{}, attrs) multi = Ecto.Multi.new() multi = Ecto.Multi.insert(multi, :user, user_changeset) @@ -1614,14 +1610,13 @@ defmodule Towerops.Accounts do with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), %User{} = user <- Repo.one(query), {:ok, %{user: user}} <- - Repo.transaction( - Ecto.Multi.new() - |> Ecto.Multi.update(:user, User.confirm_changeset(user)) - |> Ecto.Multi.delete_all( - :tokens, - UserToken.by_user_and_contexts_query(user, ["confirm"]) - ) - ) do + Ecto.Multi.new() + |> Ecto.Multi.update(:user, User.confirm_changeset(user)) + |> Ecto.Multi.delete_all( + :tokens, + UserToken.by_user_and_contexts_query(user, ["confirm"]) + ) + |> Repo.transaction() do {:ok, user} else _ -> :error diff --git a/lib/towerops/activity_feed.ex b/lib/towerops/activity_feed.ex index 642dea43..6050865f 100644 --- a/lib/towerops/activity_feed.ex +++ b/lib/towerops/activity_feed.ex @@ -18,8 +18,8 @@ defmodule Towerops.ActivityFeed do alias Towerops.Devices.Device alias Towerops.Devices.Event, as: DeviceEvent alias Towerops.Preseem.SyncLog - alias Towerops.Sites.Site alias Towerops.Repo + alias Towerops.Sites.Site @type activity_item :: %{ type: atom(), diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index b2f4d717..2fe32ab0 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -7,6 +7,7 @@ defmodule Towerops.Alerts do alias Towerops.Alerts.Alert alias Towerops.Gaiia.ImpactAnalysis + alias Towerops.PagerDuty.Notifier alias Towerops.Repo @doc """ @@ -225,7 +226,7 @@ defmodule Towerops.Alerts do case result do {:ok, updated_alert} -> - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_acknowledge(updated_alert) end) + Task.start(fn -> Notifier.notify_acknowledge(updated_alert) end) {:ok, updated_alert} error -> @@ -244,7 +245,7 @@ defmodule Towerops.Alerts do case result do {:ok, updated_alert} -> - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_resolve(updated_alert) end) + Task.start(fn -> Notifier.notify_resolve(updated_alert) end) {:ok, updated_alert} error -> diff --git a/lib/towerops/organizations.ex b/lib/towerops/organizations.ex index 6dd67176..bb604189 100644 --- a/lib/towerops/organizations.ex +++ b/lib/towerops/organizations.ex @@ -224,7 +224,10 @@ defmodule Towerops.Organizations do join: u in assoc(m, :user), preload: [user: u], order_by: [ - fragment("CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END", m.role), + fragment( + "CASE ? WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'member' THEN 2 WHEN 'viewer' THEN 3 END", + m.role + ), asc: u.email ] ) diff --git a/lib/towerops/pagerduty/notifier.ex b/lib/towerops/pagerduty/notifier.ex index 4909dccb..7ebeeb3c 100644 --- a/lib/towerops/pagerduty/notifier.ex +++ b/lib/towerops/pagerduty/notifier.ex @@ -34,9 +34,7 @@ defmodule Towerops.PagerDuty.Notifier do :ok {:error, reason} -> - Logger.warning( - "PagerDuty acknowledge failed for alert #{alert.id}: #{inspect(reason)}" - ) + Logger.warning("PagerDuty acknowledge failed for alert #{alert.id}: #{inspect(reason)}") :error end diff --git a/lib/towerops/security/brute_force.ex b/lib/towerops/security/brute_force.ex index a1cc8084..ee446761 100644 --- a/lib/towerops/security/brute_force.ex +++ b/lib/towerops/security/brute_force.ex @@ -366,9 +366,15 @@ defmodule Towerops.Security.BruteForce do load_whitelist() _ -> - case :ets.lookup(@whitelist_cache_table, :whitelist) do - [{:whitelist, entries}] -> entries - [] -> load_whitelist() + try do + case :ets.lookup(@whitelist_cache_table, :whitelist) do + [{:whitelist, entries}] -> entries + [] -> load_whitelist() + end + rescue + ArgumentError -> + create_cache_table() + load_whitelist() end end end @@ -377,13 +383,18 @@ defmodule Towerops.Security.BruteForce do :ets.new(@whitelist_cache_table, [:set, :public, :named_table, read_concurrency: true]) rescue ArgumentError -> - # Table already exists due to race condition in async tests - this is fine :ets.whereis(@whitelist_cache_table) end defp load_whitelist do entries = Repo.all(IpWhitelist) - :ets.insert(@whitelist_cache_table, {:whitelist, entries}) + + try do + :ets.insert(@whitelist_cache_table, {:whitelist, entries}) + rescue + ArgumentError -> :ok + end + entries end @@ -392,5 +403,7 @@ defmodule Towerops.Security.BruteForce do :undefined -> :ok _ -> :ets.delete(@whitelist_cache_table, :whitelist) end + rescue + ArgumentError -> :ok end end diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index ba25d396..4745aaa5 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -18,6 +18,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do alias Towerops.Alerts alias Towerops.Devices alias Towerops.Monitoring + alias Towerops.PagerDuty.Notifier alias Towerops.Snmp.Client alias Towerops.Workers.PollingOffset @@ -210,7 +211,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do message: alert_message }) - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end) + Task.start(fn -> Notifier.notify_trigger(alert, device) end) _ = Phoenix.PubSub.broadcast( @@ -239,7 +240,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do message: recovery_message }) - Task.start(fn -> Towerops.PagerDuty.Notifier.notify_trigger(alert, device) end) + Task.start(fn -> Notifier.notify_trigger(alert, device) end) resolve_down_alert(device) diff --git a/lib/towerops_web/controllers/api/error_helpers.ex b/lib/towerops_web/controllers/api/error_helpers.ex new file mode 100644 index 00000000..bb9b6d24 --- /dev/null +++ b/lib/towerops_web/controllers/api/error_helpers.ex @@ -0,0 +1,26 @@ +defmodule ToweropsWeb.Api.ErrorHelpers do + @moduledoc "Shared error translation helpers for API controllers." + + @doc """ + Translates changeset errors into a map of field names to error message lists. + + Interpolates message parameters (e.g. `%{count}`) safely, validating keys + before atom conversion to prevent atom exhaustion attacks. + """ + @spec translate_errors(Ecto.Changeset.t()) :: %{atom() => [String.t()]} + def translate_errors(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + safe_translate_key(key, opts) + end) + end) + end + + defp safe_translate_key(key, opts) do + if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + else + key + end + end +end diff --git a/lib/towerops_web/controllers/api/v1/agents_controller.ex b/lib/towerops_web/controllers/api/v1/agents_controller.ex index 3cf9aba7..de781d1c 100644 --- a/lib/towerops_web/controllers/api/v1/agents_controller.ex +++ b/lib/towerops_web/controllers/api/v1/agents_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.AgentsController do @moduledoc "API controller for agent tokens." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Agents def index(conn, _params) do @@ -45,7 +47,8 @@ defmodule ToweropsWeb.Api.V1.AgentsController do json(conn, %{ data: - format_agent(agent) + agent + |> format_agent() |> Map.put(:device_count, device_count) }) else @@ -85,16 +88,4 @@ defmodule ToweropsWeb.Api.V1.AgentsController do inserted_at: agent.inserted_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/alerts_controller.ex b/lib/towerops_web/controllers/api/v1/alerts_controller.ex index f5e62f0c..9739872e 100644 --- a/lib/towerops_web/controllers/api/v1/alerts_controller.ex +++ b/lib/towerops_web/controllers/api/v1/alerts_controller.ex @@ -49,7 +49,7 @@ defmodule ToweropsWeb.Api.V1.AlertsController do device = alert.device if device && device.organization_id == organization_id do - user_id = if user, do: user.id, else: nil + user_id = if user, do: user.id case Alerts.acknowledge_alert(alert, user_id) do {:ok, updated} -> diff --git a/lib/towerops_web/controllers/api/v1/check_results_controller.ex b/lib/towerops_web/controllers/api/v1/check_results_controller.ex index a56c539f..5883d9e9 100644 --- a/lib/towerops_web/controllers/api/v1/check_results_controller.ex +++ b/lib/towerops_web/controllers/api/v1/check_results_controller.ex @@ -12,7 +12,8 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do case get_org_device(device_id, organization_id) do {:ok, device} -> checks = - Monitoring.list_checks(organization_id, device_id: device.id) + organization_id + |> Monitoring.list_checks(device_id: device.id) |> Enum.map(fn check -> latest = Monitoring.get_latest_check_result(check.id) @@ -77,24 +78,8 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do interfaces = case device.snmp_device do - nil -> - [] - - snmp_device -> - Snmp.list_interfaces(snmp_device.id) - |> Enum.map(fn iface -> - %{ - id: iface.id, - if_index: iface.if_index, - if_name: iface.if_name, - if_descr: iface.if_descr, - if_alias: iface.if_alias, - if_speed: iface.if_speed, - if_admin_status: iface.if_admin_status, - if_oper_status: iface.if_oper_status, - monitored: iface.monitored - } - end) + nil -> [] + snmp_device -> format_interfaces(snmp_device) end json(conn, %{data: interfaces}) @@ -126,4 +111,22 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do end defp parse_int(val, _default) when is_integer(val), do: val + + defp format_interfaces(snmp_device) do + snmp_device.id + |> Snmp.list_interfaces() + |> Enum.map(fn iface -> + %{ + id: iface.id, + if_index: iface.if_index, + if_name: iface.if_name, + if_descr: iface.if_descr, + if_alias: iface.if_alias, + if_speed: iface.if_speed, + if_admin_status: iface.if_admin_status, + if_oper_status: iface.if_oper_status, + monitored: iface.monitored + } + end) + end end diff --git a/lib/towerops_web/controllers/api/v1/integrations_controller.ex b/lib/towerops_web/controllers/api/v1/integrations_controller.ex index 6578a6a3..f6fc7e82 100644 --- a/lib/towerops_web/controllers/api/v1/integrations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/integrations_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do @moduledoc "API controller for third-party integrations." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Integrations def index(conn, _params) do @@ -49,8 +51,11 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do case Integrations.get_integration_by_id(id) do {:ok, integration} when integration.organization_id == organization_id -> case Integrations.update_integration(integration, to_atom_keys(attrs)) do - {:ok, updated} -> json(conn, %{data: format_integration(updated)}) - {:error, changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) + {:ok, updated} -> + json(conn, %{data: format_integration(updated)}) + + {:error, changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end _ -> @@ -108,16 +113,4 @@ defmodule ToweropsWeb.Api.V1.IntegrationsController do rescue ArgumentError -> map end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/invitations_controller.ex b/lib/towerops_web/controllers/api/v1/invitations_controller.ex index 2b255ca3..31abc5a4 100644 --- a/lib/towerops_web/controllers/api/v1/invitations_controller.ex +++ b/lib/towerops_web/controllers/api/v1/invitations_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do @moduledoc "API controller for organization invitations." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def index(conn, _params) do @@ -25,7 +27,7 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do role: role, invited_by_id: user && user.id, token: Ecto.UUID.generate(), - expires_at: DateTime.add(DateTime.utc_now(), 7 * 86_400, :second) |> DateTime.truncate(:second) + expires_at: DateTime.utc_now() |> DateTime.add(7 * 86_400, :second) |> DateTime.truncate(:second) } case Organizations.create_invitation(attrs) do @@ -81,16 +83,4 @@ defmodule ToweropsWeb.Api.V1.InvitationsController do loaded -> Map.get(loaded, field) end end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/members_controller.ex b/lib/towerops_web/controllers/api/v1/members_controller.ex index 07697e78..efadf32a 100644 --- a/lib/towerops_web/controllers/api/v1/members_controller.ex +++ b/lib/towerops_web/controllers/api/v1/members_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.MembersController do @moduledoc "API controller for organization members." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def index(conn, _params) do @@ -56,16 +58,4 @@ defmodule ToweropsWeb.Api.V1.MembersController do inserted_at: membership.inserted_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api/v1/organization_controller.ex b/lib/towerops_web/controllers/api/v1/organization_controller.ex index 19df5c3c..9e6592a0 100644 --- a/lib/towerops_web/controllers/api/v1/organization_controller.ex +++ b/lib/towerops_web/controllers/api/v1/organization_controller.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.Api.V1.OrganizationController do @moduledoc "API controller for current organization settings." use ToweropsWeb, :controller + import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] + alias Towerops.Organizations def show(conn, _params) do @@ -54,16 +56,4 @@ defmodule ToweropsWeb.Api.V1.OrganizationController do updated_at: org.updated_at } end - - defp translate_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> - Regex.replace(~r"%{(\w+)}", msg, fn _, key -> - if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do - opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() - else - key - end - end) - end) - end end diff --git a/lib/towerops_web/controllers/api_docs_html/index.html.heex b/lib/towerops_web/controllers/api_docs_html/index.html.heex index 35ca220a..2c86ca3f 100644 --- a/lib/towerops_web/controllers/api_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/api_docs_html/index.html.heex @@ -1420,7 +1420,7 @@ Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.j
- +

@@ -1429,7 +1429,7 @@ Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.j

Alerts represent monitoring events triggered when a device check fails or recovers. You can list, view, acknowledge, and resolve alerts.

- +

The alert model

@@ -1437,70 +1437,120 @@ Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.j
- id string + id + + string +
-
Unique identifier (UUID).
+
+ Unique identifier (UUID). +
- alert_type string + alert_type + + string +
-
Type of alert (e.g. "ping_down", "snmp_down").
+
+ Type of alert (e.g. "ping_down", "snmp_down"). +
- message string + message + + string +
-
Human-readable alert message.
+
+ Human-readable alert message. +
- triggered_at timestamp + triggered_at + + timestamp +
-
When the alert was triggered.
+
+ When the alert was triggered. +
- acknowledged_at timestamp + acknowledged_at + + timestamp +
-
When the alert was acknowledged (null if not acknowledged).
+
+ When the alert was acknowledged (null if not acknowledged). +
- resolved_at timestamp + resolved_at + + timestamp +
-
When the alert was resolved (null if still active).
+
+ When the alert was resolved (null if still active). +
- device_id string + device_id + + string +
-
The device this alert belongs to.
+
+ The device this alert belongs to. +
- device_name string + device_name + + string +
-
Name of the associated device.
+
+ Name of the associated device. +
- acknowledged_by_email string + acknowledged_by_email + + string +
-
Email of the user who acknowledged this alert.
+
+ Email of the user who acknowledged this alert. +
- gaiia_impact string + gaiia_impact + + string +
-
AI-generated impact assessment (if available).
+
+ AI-generated impact assessment (if available). +

- +
@@ -1515,25 +1565,42 @@ Content-Disposition: attachment; filename="towerops-data-{user_id}-{timestamp}.j

Returns all alerts for the authenticated organization.

-

Optional parameters

+

+ Optional parameters +

- status string + status + + string +
-
Filter by status (e.g. "active", "acknowledged", "resolved").
+
+ Filter by status (e.g. "active", "acknowledged", "resolved"). +
- device_id string + device_id + + string +
-
Filter alerts for a specific device.
+
+ Filter alerts for a specific device. +
- limit integer + limit + + integer +
-
Maximum number of alerts to return.
+
+ Maximum number of alerts to return. +
@@ -1577,7 +1644,7 @@ curl -G https://towerops.net/api/v1/alerts \\

- +
@@ -1585,11 +1652,15 @@ curl -G https://towerops.net/api/v1/alerts \\ GET - /api/v1/alerts/:id + + /api/v1/alerts/:id +
-

Retrieves a single alert by its ID.

+

+ Retrieves a single alert by its ID. +

@@ -1606,15 +1677,19 @@ curl https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \\

- +
-

Acknowledge an alert

+

+ Acknowledge an alert +

POST - /api/v1/alerts/:id/acknowledge + + /api/v1/alerts/:id/acknowledge +
@@ -1659,7 +1734,7 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456

- +
@@ -1667,7 +1742,9 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456 POST - /api/v1/alerts/:id/resolve + + /api/v1/alerts/:id/resolve +
@@ -1691,7 +1768,7 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456


- +

@@ -1700,7 +1777,7 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456

Agents are lightweight monitoring agents that run on your infrastructure to collect device data. Each agent has a token used for authentication.

- +

The agent model

@@ -1708,46 +1785,74 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456
- id string + id + + string +
-
Unique identifier (UUID).
+
+ Unique identifier (UUID). +
- name string + name + + string +
Name of the agent.
- enabled boolean + enabled + + boolean +
-
Whether the agent is enabled.
+
+ Whether the agent is enabled. +
- last_seen_at timestamp + last_seen_at + + timestamp +
-
When the agent last checked in.
+
+ When the agent last checked in. +
- last_ip string + last_ip + + string +
-
Last known IP address of the agent.
+
+ Last known IP address of the agent. +
- metadata object + metadata + + object +
-
Agent metadata (version, OS, etc.).
+
+ Agent metadata (version, OS, etc.). +

- +
@@ -1759,7 +1864,9 @@ curl -X POST https://towerops.net/api/v1/alerts/a1b2c3d4-e5f6-7890-abcd-ef123456
-

Returns all agent tokens for the authenticated organization.

+

+ Returns all agent tokens for the authenticated organization. +

@@ -1796,7 +1903,7 @@ curl -G https://towerops.net/api/v1/agents \\

- +
@@ -1811,13 +1918,20 @@ curl -G https://towerops.net/api/v1/agents \\

Creates a new agent token. The raw token is returned only once in the response — store it securely.

-

Required parameters

+

+ Required parameters +

- name string + name + + string +
-
A descriptive name for the agent.
+
+ A descriptive name for the agent. +
@@ -1857,7 +1971,7 @@ curl -X POST https://towerops.net/api/v1/agents \\

- +
@@ -1865,11 +1979,15 @@ curl -X POST https://towerops.net/api/v1/agents \\ GET - /api/v1/agents/:id + + /api/v1/agents/:id +
-

Retrieves a single agent by ID. Includes a count of assigned devices.

+

+ Retrieves a single agent by ID. Includes a count of assigned devices. +

@@ -1905,7 +2023,7 @@ curl https://towerops.net/api/v1/agents/b2c3d4e5-f6a7-8901-bcde-f12345678901 \\

- +
@@ -1913,11 +2031,15 @@ curl https://towerops.net/api/v1/agents/b2c3d4e5-f6a7-8901-bcde-f12345678901 \\ DELETE - /api/v1/agents/:id + + /api/v1/agents/:id +
-

Permanently deletes an agent token. Devices assigned to this agent will need to be reassigned.

+

+ Permanently deletes an agent token. Devices assigned to this agent will need to be reassigned. +

@@ -1943,7 +2065,7 @@ curl -X DELETE https://towerops.net/api/v1/agents/b2c3d4e5-f6a7-8901-bcde-f12345


- +

@@ -1952,72 +2074,115 @@ curl -X DELETE https://towerops.net/api/v1/agents/b2c3d4e5-f6a7-8901-bcde-f12345

Retrieve and update settings for the authenticated organization, including SNMP defaults, MikroTik configuration, and general preferences.

- +
-

The organization model

+

+ The organization model +

- id string + id + + string +
-
Unique identifier (UUID).
+
+ Unique identifier (UUID). +
- name string + name + + string +
-
Organization display name.
+
+ Organization display name. +
- slug string + slug + + string +
-
URL-friendly identifier.
+
+ URL-friendly identifier. +
- subscription_plan string + subscription_plan + + string +
-
Current subscription plan.
+
+ Current subscription plan. +
- snmp_version string + snmp_version + + string +
-
Default SNMP version (v2c or v3).
+
+ Default SNMP version (v2c or v3). +
- snmp_community string + snmp_community + + string +
-
Default SNMP community string.
+
+ Default SNMP community string. +
- mikrotik_enabled boolean + mikrotik_enabled + + boolean +
-
Whether MikroTik API integration is enabled.
+
+ Whether MikroTik API integration is enabled. +

- +
-

Get organization settings

+

+ Get organization settings +

GET - /api/v1/organization + + /api/v1/organization +
-

Returns the full settings for the authenticated organization.

+

+ Returns the full settings for the authenticated organization. +

@@ -2066,34 +2231,50 @@ curl https://towerops.net/api/v1/organization \\

- +
-

Update organization settings

+

+ Update organization settings +

PATCH - /api/v1/organization + + /api/v1/organization +

Updates organization settings. Only the provided fields are changed.

-

Parameters (all optional)

+

+ Parameters (all optional) +

- organization[name] string + organization[name] + + string +
-
Organization display name.
+
+ Organization display name. +
- organization[snmp_community] string + organization[snmp_community] + + string +
-
Default SNMP community string.
+
+ Default SNMP community string. +
@@ -2115,7 +2296,7 @@ curl -X PATCH https://towerops.net/api/v1/organization \\


- +

@@ -2124,7 +2305,7 @@ curl -X PATCH https://towerops.net/api/v1/organization \\

Manage the users who belong to your organization. You can list members, change roles, and remove members.

- +

The member model

@@ -2132,28 +2313,43 @@ curl -X PATCH https://towerops.net/api/v1/organization \\
- id string + id + + string +
-
User ID of the member.
+
+ User ID of the member. +
- email string + email + + string +
-
Email address of the member.
+
+ Email address of the member. +
- role string + role + + string +
-
Member's role (e.g. "owner", "admin", "member").
+
+ Member's role (e.g. "owner", "admin", "member"). +

- +
@@ -2161,11 +2357,15 @@ curl -X PATCH https://towerops.net/api/v1/organization \\ GET - /api/v1/members + + /api/v1/members +
-

Returns all members of the authenticated organization.

+

+ Returns all members of the authenticated organization. +

@@ -2205,26 +2405,39 @@ curl -G https://towerops.net/api/v1/members \\

- +
-

Change member role

+

+ Change member role +

PATCH - /api/v1/members/:user_id + + /api/v1/members/:user_id +
-

Updates a member's role. Cannot change the owner's role.

-

Required parameters

+

+ Updates a member's role. Cannot change the owner's role. +

+

+ Required parameters +

- role string + role + + string +
-
New role for the member (e.g. "admin", "member").
+
+ New role for the member (e.g. "admin", "member"). +
@@ -2245,7 +2458,7 @@ curl -X PATCH https://towerops.net/api/v1/members/user-uuid-2 \\

- +
@@ -2253,11 +2466,15 @@ curl -X PATCH https://towerops.net/api/v1/members/user-uuid-2 \\ DELETE - /api/v1/members/:user_id + + /api/v1/members/:user_id +
-

Removes a member from the organization. The organization owner cannot be removed.

+

+ Removes a member from the organization. The organization owner cannot be removed. +

@@ -2275,7 +2492,7 @@ curl -X DELETE https://towerops.net/api/v1/members/user-uuid-2 \\


- +

@@ -2284,60 +2501,93 @@ curl -X DELETE https://towerops.net/api/v1/members/user-uuid-2 \\

Invite new users to your organization. Invitations expire after 7 days.

- +
-

The invitation model

+

+ The invitation model +

- id string + id + + string +
-
Unique identifier (UUID).
+
+ Unique identifier (UUID). +
- email string + email + + string +
-
Email address of the invited user.
+
+ Email address of the invited user. +
- role string + role + + string +
-
Role the user will receive upon accepting.
+
+ Role the user will receive upon accepting. +
- expires_at timestamp + expires_at + + timestamp +
-
When the invitation expires.
+
+ When the invitation expires. +
- invited_by_email string + invited_by_email + + string +
-
Email of the user who sent the invitation.
+
+ Email of the user who sent the invitation. +

- +
-

List pending invitations

+

+ List pending invitations +

GET - /api/v1/invitations + + /api/v1/invitations +
-

Returns all pending invitations for the organization.

+

+ Returns all pending invitations for the organization. +

@@ -2373,32 +2623,50 @@ curl -G https://towerops.net/api/v1/invitations \\

- +
-

Create an invitation

+

+ Create an invitation +

POST - /api/v1/invitations + + /api/v1/invitations +
-

Sends an invitation to join the organization.

-

Required parameters

+

+ Sends an invitation to join the organization. +

+

+ Required parameters +

- email string + email + + string +
-
Email address to invite.
+
+ Email address to invite. +
- role string + role + + string +
-
Role to assign (e.g. "admin", "member").
+
+ Role to assign (e.g. "admin", "member"). +
@@ -2419,19 +2687,25 @@ curl -X POST https://towerops.net/api/v1/invitations \\

- +
-

Cancel an invitation

+

+ Cancel an invitation +

DELETE - /api/v1/invitations/:id + + /api/v1/invitations/:id +
-

Cancels a pending invitation.

+

+ Cancels a pending invitation. +

@@ -2449,7 +2723,7 @@ curl -X DELETE https://towerops.net/api/v1/invitations/inv-uuid-1 \\


- +

@@ -2458,66 +2732,104 @@ curl -X DELETE https://towerops.net/api/v1/invitations/inv-uuid-1 \\

Manage third-party integrations for your organization, such as notification providers and external monitoring systems.

- +
-

The integration model

+

+ The integration model +

- id string + id + + string +
-
Unique identifier (UUID).
+
+ Unique identifier (UUID). +
- provider string + provider + + string +
-
Integration provider name (e.g. "slack", "pagerduty", "email").
+
+ Integration provider name (e.g. "slack", "pagerduty", "email"). +
- enabled boolean + enabled + + boolean +
-
Whether the integration is active.
+
+ Whether the integration is active. +
- sync_interval_minutes integer + sync_interval_minutes + + integer +
-
How often the integration syncs (in minutes).
+
+ How often the integration syncs (in minutes). +
- last_synced_at timestamp + last_synced_at + + timestamp +
-
When the integration last synced.
+
+ When the integration last synced. +
- last_sync_status string + last_sync_status + + string +
-
Status of the last sync ("success", "error", etc.).
+
+ Status of the last sync ("success", "error", etc.). +

- +
-

List all integrations

+

+ List all integrations +

GET - /api/v1/integrations + + /api/v1/integrations +
-

Returns all integrations for the organization.

+

+ Returns all integrations for the organization. +

@@ -2555,41 +2867,64 @@ curl -G https://towerops.net/api/v1/integrations \\

- +
-

Create an integration

+

+ Create an integration +

POST - /api/v1/integrations + + /api/v1/integrations +

Creates a new integration.

-

Required parameters

+

+ Required parameters +

- integration[provider] string + integration[provider] + + string +
-
Provider name (e.g. "slack", "pagerduty").
+
+ Provider name (e.g. "slack", "pagerduty"). +
-

Optional parameters

+

+ Optional parameters +

- integration[enabled] boolean + integration[enabled] + + boolean +
-
Whether to enable the integration immediately (default: true).
+
+ Whether to enable the integration immediately (default: true). +
- integration[sync_interval_minutes] integer + integration[sync_interval_minutes] + + integer +
-
Sync interval in minutes.
+
+ Sync interval in minutes. +
@@ -2610,19 +2945,25 @@ curl -X POST https://towerops.net/api/v1/integrations \\

- +
-

Retrieve an integration

+

+ Retrieve an integration +

GET - /api/v1/integrations/:id + + /api/v1/integrations/:id +
-

Retrieves a single integration by ID.

+

+ Retrieves a single integration by ID. +

@@ -2639,19 +2980,25 @@ curl https://towerops.net/api/v1/integrations/intg-uuid-1 \\

- +
-

Update an integration

+

+ Update an integration +

PATCH - /api/v1/integrations/:id + + /api/v1/integrations/:id +
-

Updates an integration's settings.

+

+ Updates an integration's settings. +

@@ -2670,19 +3017,25 @@ curl -X PATCH https://towerops.net/api/v1/integrations/intg-uuid-1 \\

- +
-

Delete an integration

+

+ Delete an integration +

DELETE - /api/v1/integrations/:id + + /api/v1/integrations/:id +
-

Permanently deletes an integration.

+

+ Permanently deletes an integration. +

@@ -2699,7 +3052,7 @@ curl -X DELETE https://towerops.net/api/v1/integrations/intg-uuid-1 \\

- +
@@ -2707,11 +3060,15 @@ curl -X DELETE https://towerops.net/api/v1/integrations/intg-uuid-1 \\ POST - /api/v1/integrations/:id/test + + /api/v1/integrations/:id/test +
-

Tests the connection for an integration to verify it's properly configured.

+

+ Tests the connection for an integration to verify it's properly configured. +

@@ -2742,7 +3099,7 @@ curl -X POST https://towerops.net/api/v1/integrations/intg-uuid-1/test \\


- +

@@ -2753,19 +3110,25 @@ curl -X POST https://towerops.net/api/v1/integrations/intg-uuid-1/test \\


- +
-

List device checks

+

+ List device checks +

GET - /api/v1/devices/:device_id/checks + + /api/v1/devices/:device_id/checks +
-

Returns all monitoring checks configured for a device, including the latest result for each check.

+

+ Returns all monitoring checks configured for a device, including the latest result for each check. +

@@ -2803,32 +3166,50 @@ curl -G https://towerops.net/api/v1/devices/550e8400-e29b-41d4-a716-446655440000

- +
-

Get device metrics

+

+ Get device metrics +

GET - /api/v1/devices/:device_id/metrics + + /api/v1/devices/:device_id/metrics +
-

Returns historical check results for a device, useful for graphing and trend analysis.

-

Optional parameters

+

+ Returns historical check results for a device, useful for graphing and trend analysis. +

+

+ Optional parameters +

- hours integer + hours + + integer +
-
Number of hours of history to return (default: 24).
+
+ Number of hours of history to return (default: 24). +
- check_type string + check_type + + string +
-
Filter by check type (e.g. "ping", "snmp").
+
+ Filter by check type (e.g. "ping", "snmp"). +
@@ -2870,19 +3251,25 @@ curl -G https://towerops.net/api/v1/devices/550e8400-e29b-41d4-a716-446655440000

- +
-

List device interfaces

+

+ List device interfaces +

GET - /api/v1/devices/:device_id/interfaces + + /api/v1/devices/:device_id/interfaces +
-

Returns SNMP-discovered network interfaces for a device.

+

+ Returns SNMP-discovered network interfaces for a device. +

@@ -2922,7 +3309,7 @@ curl -G https://towerops.net/api/v1/devices/550e8400-e29b-41d4-a716-446655440000


- +

@@ -2933,32 +3320,50 @@ curl -G https://towerops.net/api/v1/devices/550e8400-e29b-41d4-a716-446655440000


- +
-

List organization activity

+

+ List organization activity +

GET - /api/v1/activity + + /api/v1/activity +
-

Returns recent activity events for the organization.

-

Optional parameters

+

+ Returns recent activity events for the organization. +

+

+ Optional parameters +

- limit integer + limit + + integer +
-
Maximum number of events to return (default: 50).
+
+ Maximum number of events to return (default: 50). +
- types string + types + + string +
-
Comma-separated list of event types to filter by.
+
+ Comma-separated list of event types to filter by. +
diff --git a/lib/towerops_web/controllers/graphql_docs_html/index.html.heex b/lib/towerops_web/controllers/graphql_docs_html/index.html.heex index 7f191eac..011fdc68 100644 --- a/lib/towerops_web/controllers/graphql_docs_html/index.html.heex +++ b/lib/towerops_web/controllers/graphql_docs_html/index.html.heex @@ -7,8 +7,8 @@ Towerops GraphQL API
- - + +

- diff --git a/lib/towerops_web/controllers/invitation_controller.ex b/lib/towerops_web/controllers/invitation_controller.ex index 26969fb1..1cc18772 100644 --- a/lib/towerops_web/controllers/invitation_controller.ex +++ b/lib/towerops_web/controllers/invitation_controller.ex @@ -11,23 +11,31 @@ defmodule ToweropsWeb.InvitationController do |> redirect(to: ~p"/") invitation -> - case conn.assigns[:current_scope] do - %{user: user} when not is_nil(user) -> - case Organizations.accept_invitation(invitation, user.id) do - {:ok, _membership} -> - conn - |> put_flash(:info, "You've joined #{invitation.organization.name}!") - |> redirect(to: ~p"/orgs/#{invitation.organization.slug}/settings?tab=members") + accept_or_redirect(conn, invitation, token) + end + end - {:error, _changeset} -> - conn - |> put_flash(:error, "Could not accept invitation. You may already be a member.") - |> redirect(to: ~p"/dashboard") - end + defp accept_or_redirect(conn, invitation, token) do + case conn.assigns[:current_scope] do + %{user: user} when not is_nil(user) -> + accept_invitation(conn, invitation, user) - _ -> - redirect(conn, to: ~p"/users/register?invitation_token=#{token}") - end + _ -> + redirect(conn, to: ~p"/users/register?invitation_token=#{token}") + end + end + + defp accept_invitation(conn, invitation, user) do + case Organizations.accept_invitation(invitation, user.id) do + {:ok, _membership} -> + conn + |> put_flash(:info, "You've joined #{invitation.organization.name}!") + |> redirect(to: ~p"/orgs/#{invitation.organization.slug}/settings?tab=members") + + {:error, _changeset} -> + conn + |> put_flash(:error, "Could not accept invitation. You may already be a member.") + |> redirect(to: ~p"/dashboard") end end end diff --git a/lib/towerops_web/controllers/page_html/home.html.heex b/lib/towerops_web/controllers/page_html/home.html.heex index 2ffc8d2c..c297c951 100644 --- a/lib/towerops_web/controllers/page_html/home.html.heex +++ b/lib/towerops_web/controllers/page_html/home.html.heex @@ -329,7 +329,8 @@ /device/month

- First 10 devices are free forever — all features, no limits. Pay $3/device/month only after that. + First 10 devices are free forever + — all features, no limits. Pay $3/device/month only after that.

@@ -370,8 +371,7 @@ Team access & email alerts
- <.icon name="hero-check" class="h-5 w-5 text-blue-600 flex-shrink-0" /> - API access + <.icon name="hero-check" class="h-5 w-5 text-blue-600 flex-shrink-0" /> API access
diff --git a/lib/towerops_web/controllers/user_confirmation_html/new.html.heex b/lib/towerops_web/controllers/user_confirmation_html/new.html.heex index 8319dff6..1c917119 100644 --- a/lib/towerops_web/controllers/user_confirmation_html/new.html.heex +++ b/lib/towerops_web/controllers/user_confirmation_html/new.html.heex @@ -8,8 +8,8 @@

- <.link href={~p"/users/log-in"}>Log in | - <.link href={~p"/users/register"}>Register + <.link href={~p"/users/log-in"}>Log in + | <.link href={~p"/users/register"}>Register

diff --git a/lib/towerops_web/graphql/resolvers/activity.ex b/lib/towerops_web/graphql/resolvers/activity.ex index 68ff28f8..57b461cb 100644 --- a/lib/towerops_web/graphql/resolvers/activity.ex +++ b/lib/towerops_web/graphql/resolvers/activity.ex @@ -4,9 +4,7 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Activity do alias Towerops.ActivityFeed def list(_parent, args, %{context: %{organization_id: org_id}}) do - opts = - [limit: Map.get(args, :limit, 50)] - |> maybe_add_type(args[:type]) + opts = maybe_add_type([limit: Map.get(args, :limit, 50)], args[:type]) items = ActivityFeed.list_org_activity(org_id, opts) {:ok, items} diff --git a/lib/towerops_web/graphql/resolvers/agent.ex b/lib/towerops_web/graphql/resolvers/agent.ex index 65f07c0b..99edbd79 100644 --- a/lib/towerops_web/graphql/resolvers/agent.ex +++ b/lib/towerops_web/graphql/resolvers/agent.ex @@ -27,13 +27,14 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Agent do def create(_parent, %{name: name}, %{context: %{organization_id: org_id}}) do case Agents.create_agent_token(org_id, name) do {:ok, agent_token, raw_token} -> - {:ok, %{ - id: agent_token.id, - name: agent_token.name, - enabled: agent_token.enabled, - token: raw_token, - inserted_at: agent_token.inserted_at - }} + {:ok, + %{ + id: agent_token.id, + name: agent_token.name, + enabled: agent_token.enabled, + token: raw_token, + inserted_at: agent_token.inserted_at + }} {:error, changeset} -> {:error, format_errors(changeset)} @@ -60,12 +61,12 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Agent do def delete(_parent, _args, _resolution), do: {:error, "Authentication required"} defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/resolvers/device.ex b/lib/towerops_web/graphql/resolvers/device.ex index ba49ce02..5dd49258 100644 --- a/lib/towerops_web/graphql/resolvers/device.ex +++ b/lib/towerops_web/graphql/resolvers/device.ex @@ -2,12 +2,13 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Device do @moduledoc "GraphQL resolvers for device queries and mutations." alias Towerops.Devices + alias Towerops.Devices.Device 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) + params = Map.new(args, fn {k, v} -> {to_string(k), v} end) devices = Devices.list_organization_devices(org_id, params) {:ok, devices} end @@ -15,16 +16,8 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Device do 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 + with {:ok, device} <- fetch_org_device(id, org_id) do + {:ok, Repo.preload(device, :site)} end end @@ -45,109 +38,85 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Device do 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"} + with {:ok, device} <- fetch_org_device(id, org_id) do + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) - 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 + case Devices.update_device(device, attrs) do + {:ok, updated} -> {:ok, Repo.preload(updated, :site, force: true)} + {:error, changeset} -> {:error, format_errors(changeset)} + 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 + with {:ok, device} <- fetch_org_device(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 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 + with {:ok, device} <- fetch_org_device(device_id, org_id) do + {:ok, build_device_metrics(device, org_id, args)} 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"} + with {:ok, device} <- fetch_org_device(device_id, org_id) do + device = Repo.preload(device, :snmp_device) - 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"} + interfaces = + case device.snmp_device do + nil -> [] + snmp_device -> Snmp.list_interfaces(snmp_device.id) end + + {:ok, interfaces} end end def interfaces(_parent, _args, _resolution), do: {:error, "Authentication required"} + defp fetch_org_device(id, org_id) do + case Repo.get(Device, id) do + nil -> {:error, "Device not found"} + %Device{organization_id: ^org_id} = device -> {:ok, device} + %Device{} -> {:error, "Device not found"} + end + end + + defp build_device_metrics(device, org_id, args) 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) + + Enum.flat_map(checks, fn check -> + check.id + |> Monitoring.get_check_results(from: from_time) + |> Enum.map(&format_check_result(&1, check)) + end) + end + + defp format_check_result(result, check) do + %{ + timestamp: to_string(result.checked_at), + value: result.value, + status: result.status, + check_name: check.name, + check_type: check.check_type + } + end + defp parse_time_range(range) do case Integer.parse(String.replace(range, "h", "")) do {hours, _} -> hours @@ -156,12 +125,12 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Device do end defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/resolvers/integration.ex b/lib/towerops_web/graphql/resolvers/integration.ex index 61b5a7bc..b254a694 100644 --- a/lib/towerops_web/graphql/resolvers/integration.ex +++ b/lib/towerops_web/graphql/resolvers/integration.ex @@ -66,12 +66,12 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Integration do def test_connection(_parent, _args, _resolution), do: {:error, "Authentication required"} defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/resolvers/member.ex b/lib/towerops_web/graphql/resolvers/member.ex index c3614a6d..60f7fb11 100644 --- a/lib/towerops_web/graphql/resolvers/member.ex +++ b/lib/towerops_web/graphql/resolvers/member.ex @@ -29,19 +29,11 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do def invite(_parent, _args, _resolution), do: {:error, "Authentication required"} def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do - case Repo.get(Invitation, id) do - nil -> - {:error, "Invitation not found"} - - invitation -> - if invitation.organization_id == org_id do - case Repo.delete(invitation) do - {:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}} - {:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}} - end - else - {:error, "Invitation not found"} - end + with {:ok, invitation} <- fetch_org_invitation(id, org_id) do + case Repo.delete(invitation) do + {:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}} + {:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}} + end end end @@ -76,13 +68,21 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Member do def update_role(_parent, _args, _resolution), do: {:error, "Authentication required"} + defp fetch_org_invitation(id, org_id) do + case Repo.get(Invitation, id) do + nil -> {:error, "Invitation not found"} + %Invitation{organization_id: ^org_id} = invitation -> {:ok, invitation} + %Invitation{} -> {:error, "Invitation not found"} + end + end + defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/resolvers/organization.ex b/lib/towerops_web/graphql/resolvers/organization.ex index 372bed27..be51facd 100644 --- a/lib/towerops_web/graphql/resolvers/organization.ex +++ b/lib/towerops_web/graphql/resolvers/organization.ex @@ -23,12 +23,12 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Organization do def update(_parent, _args, _resolution), do: {:error, "Authentication required"} defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/resolvers/site.ex b/lib/towerops_web/graphql/resolvers/site.ex index 41e96e30..3f1b1e18 100644 --- a/lib/towerops_web/graphql/resolvers/site.ex +++ b/lib/towerops_web/graphql/resolvers/site.ex @@ -3,6 +3,7 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Site do alias Towerops.Repo alias Towerops.Sites + alias Towerops.Sites.Site def list(_parent, _args, %{context: %{organization_id: org_id}}) do sites = Sites.list_organization_sites(org_id) @@ -12,15 +13,7 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Site do 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 + fetch_org_site(id, org_id) end def get(_parent, _args, _resolution), do: {:error, "Authentication required"} @@ -40,48 +33,44 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Site do 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) + with {:ok, site} <- fetch_org_site(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 + case Sites.update_site(site, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, format_errors(changeset)} + 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 + with {:ok, site} <- fetch_org_site(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 end end def delete(_parent, _args, _resolution), do: {:error, "Authentication required"} + defp fetch_org_site(id, org_id) do + case Repo.get(Site, id) do + nil -> {:error, "Site not found"} + %Site{organization_id: ^org_id} = site -> {:ok, site} + %Site{} -> {:error, "Site not found"} + end + end + defp format_errors(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(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("; ") + |> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) end end diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 70b7dad4..763caf4d 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -7,206 +7,206 @@ defmodule ToweropsWeb.GraphQL.Schema do """ 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 + 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 + 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 + 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 + resolve(&ToweropsWeb.GraphQL.Resolvers.Site.list/3) end field :site, :site do - arg :id, non_null(:id) - resolve &ToweropsWeb.GraphQL.Resolvers.Site.get/3 + 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 + 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 + 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 + resolve(&ToweropsWeb.GraphQL.Resolvers.Agent.list/3) end field :agent, :agent do - arg :id, non_null(:id) - resolve &ToweropsWeb.GraphQL.Resolvers.Agent.get/3 + 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 + resolve(&ToweropsWeb.GraphQL.Resolvers.Organization.get/3) end # Members field :members, list_of(:member) do - resolve &ToweropsWeb.GraphQL.Resolvers.Member.list/3 + resolve(&ToweropsWeb.GraphQL.Resolvers.Member.list/3) end # Integrations field :integrations, list_of(:integration) do - resolve &ToweropsWeb.GraphQL.Resolvers.Integration.list/3 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + 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 + arg(:id, non_null(:id)) + resolve(&ToweropsWeb.GraphQL.Resolvers.Integration.test_connection/3) end end end diff --git a/lib/towerops_web/graphql/types/agent.ex b/lib/towerops_web/graphql/types/agent.ex index 0fa00c16..371370bd 100644 --- a/lib/towerops_web/graphql/types/agent.ex +++ b/lib/towerops_web/graphql/types/agent.ex @@ -16,10 +16,10 @@ defmodule ToweropsWeb.GraphQL.Types.Agent do field :updated_at, :string field :device_count, :integer do - resolve fn agent, _, _ -> + resolve(fn agent, _, _ -> count = Towerops.Agents.count_assigned_devices(agent.id) {:ok, count} - end + end) end end diff --git a/lib/towerops_web/graphql/types/alert.ex b/lib/towerops_web/graphql/types/alert.ex index 506aa741..4cef14bc 100644 --- a/lib/towerops_web/graphql/types/alert.ex +++ b/lib/towerops_web/graphql/types/alert.ex @@ -2,6 +2,8 @@ defmodule ToweropsWeb.GraphQL.Types.Alert do @moduledoc "GraphQL types for alerts." use Absinthe.Schema.Notation + alias Ecto.Association.NotLoaded + object :alert do field :id, :id field :alert_type, :string @@ -16,33 +18,33 @@ defmodule ToweropsWeb.GraphQL.Types.Alert do field :inserted_at, :string field :device, :device do - resolve fn alert, _, _ -> + resolve(fn alert, _, _ -> case alert.device do - %Ecto.Association.NotLoaded{} -> {:ok, nil} + %NotLoaded{} -> {:ok, nil} device -> {:ok, device} end - end + end) end field :acknowledged_by_email, :string do - resolve fn alert, _, _ -> + resolve(fn alert, _, _ -> case alert.acknowledged_by do - %Ecto.Association.NotLoaded{} -> {:ok, nil} + %NotLoaded{} -> {:ok, nil} nil -> {:ok, nil} user -> {:ok, user.email} end - end + end) end end scalar :json do - parse fn input -> + parse(fn input -> case Jason.decode(input.value) do {:ok, result} -> {:ok, result} _ -> :error end - end + end) - serialize fn value -> value end + serialize(fn value -> value end) end end diff --git a/lib/towerops_web/graphql/types/device.ex b/lib/towerops_web/graphql/types/device.ex index 2010ca7b..e7e9cd40 100644 --- a/lib/towerops_web/graphql/types/device.ex +++ b/lib/towerops_web/graphql/types/device.ex @@ -50,12 +50,12 @@ defmodule ToweropsWeb.GraphQL.Types.Device do field :updated_at, :string field :site, :site do - resolve fn device, _, _ -> + resolve(fn device, _, _ -> case device.site do %Ecto.Association.NotLoaded{} -> {:ok, nil} site -> {:ok, site} end - end + end) end end diff --git a/lib/towerops_web/graphql/types/member.ex b/lib/towerops_web/graphql/types/member.ex index a089ab4b..d3bf421a 100644 --- a/lib/towerops_web/graphql/types/member.ex +++ b/lib/towerops_web/graphql/types/member.ex @@ -9,14 +9,15 @@ defmodule ToweropsWeb.GraphQL.Types.Member do field :inserted_at, :string field :user_id, :id + field :email, :string do - resolve fn membership, _, _ -> + resolve(fn membership, _, _ -> case membership.user do %Ecto.Association.NotLoaded{} -> {:ok, nil} nil -> {:ok, nil} user -> {:ok, user.email} end - end + end) end end diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index c9dd1f0d..f8103d89 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -895,8 +895,8 @@ defmodule ToweropsWeb.HelpLive.Index do with your agent token pre-filled. It looks like this:

-
services:
-  towerops-agent:
+                            
<%= raw(~S[services:
+    towerops-agent:
     image: ghcr.io/towerops-app/towerops-agent:latest
     container_name: towerops-agent
     restart: unless-stopped
@@ -907,7 +907,7 @@ defmodule ToweropsWeb.HelpLive.Index do
       - "com.centurylinklabs.watchtower.enable=true"
       - "com.centurylinklabs.watchtower.scope=towerops"
 
-  watchtower:
+    watchtower:
     image: containrrr/watchtower:latest
     container_name: towerops-watchtower
     restart: unless-stopped
@@ -916,7 +916,7 @@ defmodule ToweropsWeb.HelpLive.Index do
       - WATCHTOWER_LABEL_ENABLE=true
       - WATCHTOWER_CLEANUP=true
     volumes:
-      - /var/run/docker.sock:/var/run/docker.sock
+ - /var/run/docker.sock:/var/run/docker.sock]) %>

The agent is lightweight — it uses minimal CPU and memory. Watchtower is included @@ -1487,7 +1487,8 @@ defmodule ToweropsWeb.HelpLive.Index do

- Use the Force Apply button to push + Use the Force Apply + button to push the organization's SNMP settings to all devices, overriding any device or site-level customizations.

@@ -1528,7 +1529,8 @@ defmodule ToweropsWeb.HelpLive.Index do

- Use the Force Apply button to push + Use the Force Apply + button to push the default agent assignment to all devices, overriding any device or site-level agent selections.

@@ -1559,7 +1561,11 @@ defmodule ToweropsWeb.HelpLive.Index do

Integrations allow you to connect Towerops with third-party services to enrich your monitoring data, sync subscriber information, and streamline your workflow. Configure - integrations from <.code>Organization Settings → <.code>Integrations tab. + integrations from + <.code>Organization Settings + → + <.code>Integrations + tab.

@@ -1567,7 +1573,12 @@ defmodule ToweropsWeb.HelpLive.Index do

- Preseem + + Preseem + is a Quality of Experience (QoE) monitoring platform designed for WISPs and broadband providers. The Preseem integration syncs subscriber and access point QoE data into Towerops, giving you a unified view of network health alongside device monitoring. @@ -1615,7 +1626,11 @@ defmodule ToweropsWeb.HelpLive.Index do Add the Integration

- Navigate to <.code>Organization Settings → <.code>Integrations tab. + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab. Enter your Preseem API key and configure the sync interval.

@@ -1632,7 +1647,9 @@ defmodule ToweropsWeb.HelpLive.Index do Test the Connection

- Use the Test Connection button + Use the + Test Connection + button to verify your API key is valid and Towerops can reach the Preseem API.

@@ -1662,7 +1679,12 @@ defmodule ToweropsWeb.HelpLive.Index do

- Gaiia + + Gaiia + is a billing and subscriber management platform. The Gaiia integration syncs subscriber data, service plans, and entity mappings into Towerops, enabling you to correlate network issues with specific customers and services. @@ -1694,7 +1716,11 @@ defmodule ToweropsWeb.HelpLive.Index do Configure Gaiia Credentials

- Navigate to <.code>Organization Settings → <.code>Integrations tab + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab and enter your Gaiia API credentials.

@@ -1761,7 +1787,12 @@ defmodule ToweropsWeb.HelpLive.Index do

- PagerDuty + + PagerDuty + is an incident management and on-call alerting platform. The PagerDuty integration provides 2-way alert sync — when a device goes down in Towerops, a PagerDuty incident is automatically triggered. Acknowledging or resolving the alert in Towerops updates the PagerDuty incident as well. @@ -1793,7 +1824,15 @@ defmodule ToweropsWeb.HelpLive.Index do Create an Events API v2 Integration in PagerDuty

- In PagerDuty, go to <.code>Services → select your service → <.code>Integrations tab → <.code>Add Integration → choose <.code>Events API v2. Copy the <.code>Integration Key (routing key). + In PagerDuty, go to + <.code>Services + → select your service → + <.code>Integrations + tab → + <.code>Add Integration + → choose <.code>Events API v2. Copy the + <.code>Integration Key + (routing key).

@@ -1809,7 +1848,13 @@ defmodule ToweropsWeb.HelpLive.Index do Configure in Towerops

- Navigate to <.code>Organization Settings → <.code>Integrations tab → click <.code>Configure on PagerDuty. Paste your integration key and test the connection. + Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab → click + <.code>Configure + on PagerDuty. Paste your integration key and test the connection.

diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index de2427e3..d32028aa 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -2,13 +2,13 @@ defmodule ToweropsWeb.Org.SettingsLive do @moduledoc false use ToweropsWeb, :live_view + alias Towerops.Accounts.UserNotifier alias Towerops.Admin.AuditLogger alias Towerops.Agents alias Towerops.Gaiia.Client, as: GaiiaClient alias Towerops.Integrations alias Towerops.Integrations.Integration alias Towerops.Organizations - alias Towerops.Accounts.UserNotifier alias Towerops.Preseem.Client, as: PreseemClient require Logger @@ -168,9 +168,7 @@ defmodule ToweropsWeb.Org.SettingsLive do |> assign(:invite_form, to_form(%{"email" => "", "role" => "member"}))} {:error, changeset} -> - {:noreply, - socket - |> put_flash(:error, "Failed to send invitation: #{error_messages(changeset)}")} + {:noreply, put_flash(socket, :error, "Failed to send invitation: #{error_messages(changeset)}")} end end @@ -403,8 +401,7 @@ defmodule ToweropsWeb.Org.SettingsLive do defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key) defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key) - defp test_provider_connection("pagerduty", api_key), - do: Towerops.PagerDuty.Client.test_connection(api_key) + defp test_provider_connection("pagerduty", api_key), do: Towerops.PagerDuty.Client.test_connection(api_key) defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"} @@ -480,7 +477,8 @@ defmodule ToweropsWeb.Org.SettingsLive do end defp error_messages(changeset) do - Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 8f0c067a..5bcd6a81 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -21,36 +21,69 @@