diff --git a/lib/towerops_web/controllers/api/v1/activity_controller.ex b/lib/towerops_web/controllers/api/v1/activity_controller.ex new file mode 100644 index 00000000..c5e33507 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/activity_controller.ex @@ -0,0 +1,53 @@ +defmodule ToweropsWeb.Api.V1.ActivityController do + @moduledoc "API controller for the organization activity feed." + use ToweropsWeb, :controller + + alias Towerops.ActivityFeed + + def index(conn, params) do + organization_id = conn.assigns.current_organization_id + + opts = + [] + |> maybe_add(:limit, parse_int(params["limit"], 50)) + |> maybe_add_types(params["types"]) + + items = ActivityFeed.list_org_activity(organization_id, opts) + + json(conn, %{data: items}) + end + + defp maybe_add(opts, key, value), do: Keyword.put(opts, key, value) + + defp maybe_add_types(opts, nil), do: opts + + defp maybe_add_types(opts, types) when is_binary(types) do + type_atoms = + types + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.map(&safe_to_atom/1) + |> Enum.reject(&is_nil/1) + + Keyword.put(opts, :types, type_atoms) + end + + defp maybe_add_types(opts, _), do: opts + + defp safe_to_atom(str) do + String.to_existing_atom(str) + rescue + ArgumentError -> nil + end + + defp parse_int(nil, default), do: default + + defp parse_int(val, default) when is_binary(val) do + case Integer.parse(val) do + {n, _} -> n + :error -> default + end + end + + defp parse_int(val, _default) when is_integer(val), do: val +end diff --git a/lib/towerops_web/controllers/api/v1/agents_controller.ex b/lib/towerops_web/controllers/api/v1/agents_controller.ex new file mode 100644 index 00000000..3cf9aba7 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/agents_controller.ex @@ -0,0 +1,100 @@ +defmodule ToweropsWeb.Api.V1.AgentsController do + @moduledoc "API controller for agent tokens." + use ToweropsWeb, :controller + + alias Towerops.Agents + + def index(conn, _params) do + organization_id = conn.assigns.current_organization_id + + agents = + organization_id + |> Agents.list_organization_agent_tokens() + |> Enum.map(&format_agent/1) + + json(conn, %{data: agents}) + end + + def create(conn, %{"name" => name}) do + organization_id = conn.assigns.current_organization_id + + case Agents.create_agent_token(organization_id, name) do + {:ok, agent_token, raw_token} -> + conn + |> put_status(:created) + |> json(%{data: Map.put(format_agent(agent_token), :token, raw_token)}) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + end + + def create(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'name' parameter"}) + end + + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + agent = Agents.get_agent_token!(id) + + if agent.organization_id == organization_id do + device_count = Agents.count_assigned_devices(id) + + json(conn, %{ + data: + format_agent(agent) + |> Map.put(:device_count, device_count) + }) + else + conn |> put_status(:not_found) |> json(%{error: "Agent not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Agent not found"}) + end + + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + agent = Agents.get_agent_token!(id) + + if agent.organization_id == organization_id do + case Agents.delete_agent_token(id) do + {:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "") + {:error, _} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not delete agent"}) + end + else + conn |> put_status(:not_found) |> json(%{error: "Agent not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Agent not found"}) + end + + defp format_agent(agent) do + %{ + id: agent.id, + name: agent.name, + enabled: agent.enabled, + last_seen_at: agent.last_seen_at, + last_ip: agent.last_ip, + metadata: agent.metadata, + 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 new file mode 100644 index 00000000..f5e62f0c --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/alerts_controller.ex @@ -0,0 +1,114 @@ +defmodule ToweropsWeb.Api.V1.AlertsController do + @moduledoc "API controller for alerts." + use ToweropsWeb, :controller + + alias Towerops.Alerts + + def index(conn, params) do + organization_id = conn.assigns.current_organization_id + + filters = + params + |> Map.take(~w(status limit)) + |> then(fn f -> + case params["device_id"] do + nil -> f + device_id -> Map.put(f, "device_id", device_id) + end + end) + + alerts = + organization_id + |> Alerts.list_organization_alerts(filters) + |> Enum.map(&format_alert/1) + + json(conn, %{data: alerts}) + end + + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + alert = Alerts.get_alert!(id) + device = alert.device + + if device && device.organization_id == organization_id do + json(conn, %{data: format_alert(alert)}) + else + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + + def acknowledge(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + user = conn.assigns[:current_user] + + alert = Alerts.get_alert!(id) + device = alert.device + + if device && device.organization_id == organization_id do + user_id = if user, do: user.id, else: nil + + case Alerts.acknowledge_alert(alert, user_id) do + {:ok, updated} -> + json(conn, %{data: format_alert(Towerops.Repo.preload(updated, [:device, :acknowledged_by], force: true))}) + + {:error, _changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not acknowledge alert"}) + end + else + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + + def resolve(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + alert = Alerts.get_alert!(id) + device = alert.device + + if device && device.organization_id == organization_id do + case Alerts.resolve_alert(alert) do + {:ok, updated} -> + json(conn, %{data: format_alert(Towerops.Repo.preload(updated, [:device, :acknowledged_by], force: true))}) + + {:error, _changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not resolve alert"}) + end + else + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Alert not found"}) + end + + defp format_alert(alert) do + %{ + id: alert.id, + alert_type: alert.alert_type, + message: alert.message, + triggered_at: alert.triggered_at, + acknowledged_at: alert.acknowledged_at, + resolved_at: alert.resolved_at, + device_id: alert.device_id, + device_name: get_in_loaded(alert, :device, :name), + acknowledged_by_email: get_in_loaded(alert, :acknowledged_by, :email), + gaiia_impact: alert.gaiia_impact, + inserted_at: alert.inserted_at + } + end + + defp get_in_loaded(struct, assoc, field) do + case Map.get(struct, assoc) do + %Ecto.Association.NotLoaded{} -> nil + nil -> nil + loaded -> Map.get(loaded, field) + end + end +end diff --git a/lib/towerops_web/controllers/api/v1/check_results_controller.ex b/lib/towerops_web/controllers/api/v1/check_results_controller.ex new file mode 100644 index 00000000..a56c539f --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/check_results_controller.ex @@ -0,0 +1,129 @@ +defmodule ToweropsWeb.Api.V1.CheckResultsController do + @moduledoc "API controller for device checks, metrics, and interfaces." + use ToweropsWeb, :controller + + alias Towerops.Devices + alias Towerops.Monitoring + alias Towerops.Snmp + + def checks(conn, %{"device_id" => device_id}) do + organization_id = conn.assigns.current_organization_id + + case get_org_device(device_id, organization_id) do + {:ok, device} -> + checks = + Monitoring.list_checks(organization_id, device_id: device.id) + |> Enum.map(fn check -> + latest = Monitoring.get_latest_check_result(check.id) + + %{ + id: check.id, + name: check.name, + check_type: check.check_type, + enabled: check.enabled, + interval_seconds: check.interval_seconds, + latest_status: latest && latest.status, + latest_value: latest && latest.value, + latest_checked_at: latest && latest.checked_at + } + end) + + json(conn, %{data: checks}) + + {:error, status} -> + conn |> put_status(status) |> json(%{error: "Device not found"}) + end + end + + def metrics(conn, %{"device_id" => device_id} = params) do + organization_id = conn.assigns.current_organization_id + + case get_org_device(device_id, organization_id) do + {:ok, device} -> + check_type = params["sensor_type"] || params["check_type"] + hours = parse_int(params["hours"], 24) + from_time = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) + + checks = Monitoring.list_checks(organization_id, device_id: device.id, check_type: check_type) + + metrics = + Enum.map(checks, fn check -> + results = Monitoring.get_check_results(check.id, from: from_time) + + %{ + check_id: check.id, + check_name: check.name, + check_type: check.check_type, + data: + Enum.map(results, fn r -> + %{timestamp: r.checked_at, value: r.value, status: r.status} + end) + } + end) + + json(conn, %{data: metrics}) + + {:error, status} -> + conn |> put_status(status) |> json(%{error: "Device not found"}) + end + end + + def interfaces(conn, %{"device_id" => device_id}) do + organization_id = conn.assigns.current_organization_id + + case get_org_device(device_id, organization_id) do + {:ok, device} -> + device = Towerops.Repo.preload(device, :snmp_device) + + 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) + end + + json(conn, %{data: interfaces}) + + {:error, status} -> + conn |> put_status(status) |> json(%{error: "Device not found"}) + end + end + + defp get_org_device(device_id, organization_id) do + device = Devices.get_device!(device_id) + + if device.organization_id == organization_id do + {:ok, device} + else + {:error, :forbidden} + end + rescue + Ecto.NoResultsError -> {:error, :not_found} + end + + defp parse_int(nil, default), do: default + + defp parse_int(val, default) when is_binary(val) do + case Integer.parse(val) do + {n, _} -> n + :error -> default + end + end + + defp parse_int(val, _default) when is_integer(val), do: val +end diff --git a/lib/towerops_web/controllers/api/v1/integrations_controller.ex b/lib/towerops_web/controllers/api/v1/integrations_controller.ex new file mode 100644 index 00000000..6578a6a3 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/integrations_controller.ex @@ -0,0 +1,123 @@ +defmodule ToweropsWeb.Api.V1.IntegrationsController do + @moduledoc "API controller for third-party integrations." + use ToweropsWeb, :controller + + alias Towerops.Integrations + + def index(conn, _params) do + organization_id = conn.assigns.current_organization_id + + integrations = + organization_id + |> Integrations.list_integrations() + |> Enum.map(&format_integration/1) + + json(conn, %{data: integrations}) + end + + def create(conn, %{"integration" => attrs}) do + organization_id = conn.assigns.current_organization_id + + case Integrations.create_integration(organization_id, to_atom_keys(attrs)) do + {:ok, integration} -> + conn |> put_status(:created) |> json(%{data: format_integration(integration)}) + + {:error, changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) + end + end + + def create(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'integration' parameter"}) + end + + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == organization_id -> + json(conn, %{data: format_integration(integration)}) + + _ -> + conn |> put_status(:not_found) |> json(%{error: "Integration not found"}) + end + end + + def update(conn, %{"id" => id, "integration" => attrs}) do + organization_id = conn.assigns.current_organization_id + + 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)}) + end + + _ -> + conn |> put_status(:not_found) |> json(%{error: "Integration not found"}) + end + end + + def update(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'integration' parameter"}) + end + + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == organization_id -> + case Integrations.delete_integration(integration) do + {:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "") + {:error, _} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not delete integration"}) + end + + _ -> + conn |> put_status(:not_found) |> json(%{error: "Integration not found"}) + end + end + + def test_connection(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == organization_id -> + # Simple connectivity test — just verify the integration exists and is configured + json(conn, %{data: %{status: "ok", provider: integration.provider}}) + + _ -> + conn |> put_status(:not_found) |> json(%{error: "Integration not found"}) + end + end + + defp format_integration(integration) do + %{ + id: integration.id, + provider: integration.provider, + enabled: integration.enabled, + sync_interval_minutes: integration.sync_interval_minutes, + last_synced_at: integration.last_synced_at, + last_sync_status: integration.last_sync_status, + inserted_at: integration.inserted_at, + updated_at: integration.updated_at + } + end + + defp to_atom_keys(map) when is_map(map) do + Map.new(map, fn {k, v} -> {String.to_existing_atom(k), v} end) + 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 new file mode 100644 index 00000000..2b255ca3 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/invitations_controller.ex @@ -0,0 +1,96 @@ +defmodule ToweropsWeb.Api.V1.InvitationsController do + @moduledoc "API controller for organization invitations." + use ToweropsWeb, :controller + + alias Towerops.Organizations + + def index(conn, _params) do + organization_id = conn.assigns.current_organization_id + + invitations = + organization_id + |> Organizations.list_pending_invitations() + |> Enum.map(&format_invitation/1) + + json(conn, %{data: invitations}) + end + + def create(conn, %{"email" => email, "role" => role}) do + organization_id = conn.assigns.current_organization_id + user = conn.assigns[:current_user] + + attrs = %{ + organization_id: organization_id, + email: email, + 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) + } + + case Organizations.create_invitation(attrs) do + {:ok, invitation} -> + conn + |> put_status(:created) + |> json(%{data: format_invitation(Towerops.Repo.preload(invitation, :invited_by))}) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + end + + def create(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'email' and 'role' parameters"}) + end + + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + invitation = Towerops.Repo.get!(Towerops.Organizations.Invitation, id) + + if invitation.organization_id == organization_id do + case Organizations.delete_invitation(invitation) do + {:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "") + {:error, _} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not delete invitation"}) + end + else + conn |> put_status(:not_found) |> json(%{error: "Invitation not found"}) + end + rescue + Ecto.NoResultsError -> + conn |> put_status(:not_found) |> json(%{error: "Invitation not found"}) + end + + defp format_invitation(invitation) do + %{ + id: invitation.id, + email: invitation.email, + role: invitation.role, + expires_at: invitation.expires_at, + invited_by_email: get_in_loaded(invitation, :invited_by, :email), + inserted_at: invitation.inserted_at + } + end + + defp get_in_loaded(struct, assoc, field) do + case Map.get(struct, assoc) do + %Ecto.Association.NotLoaded{} -> nil + nil -> nil + 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 new file mode 100644 index 00000000..07697e78 --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/members_controller.ex @@ -0,0 +1,71 @@ +defmodule ToweropsWeb.Api.V1.MembersController do + @moduledoc "API controller for organization members." + use ToweropsWeb, :controller + + alias Towerops.Organizations + + def index(conn, _params) do + organization_id = conn.assigns.current_organization_id + + members = + organization_id + |> Organizations.list_organization_members() + |> Enum.map(&format_member/1) + + json(conn, %{data: members}) + end + + def update(conn, %{"id" => user_id, "role" => role}) do + organization_id = conn.assigns.current_organization_id + + case Organizations.update_member_role(organization_id, user_id, role) do + {:ok, membership} -> + membership = Towerops.Repo.preload(membership, :user) + json(conn, %{data: format_member(membership)}) + + {:error, :cannot_change_owner_role} -> + conn |> put_status(:forbidden) |> json(%{error: "Cannot change owner role"}) + + {:error, :not_found} -> + conn |> put_status(:not_found) |> json(%{error: "Member not found"}) + + {:error, changeset} -> + conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) + end + end + + def update(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'role' parameter"}) + end + + def delete(conn, %{"id" => user_id}) do + organization_id = conn.assigns.current_organization_id + + case Organizations.remove_member(organization_id, user_id) do + {:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "") + {:error, :cannot_remove_owner} -> conn |> put_status(:forbidden) |> json(%{error: "Cannot remove owner"}) + {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Member not found"}) + end + end + + defp format_member(membership) do + %{ + id: membership.user_id, + email: membership.user.email, + role: membership.role, + 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 new file mode 100644 index 00000000..19df5c3c --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/organization_controller.ex @@ -0,0 +1,69 @@ +defmodule ToweropsWeb.Api.V1.OrganizationController do + @moduledoc "API controller for current organization settings." + use ToweropsWeb, :controller + + alias Towerops.Organizations + + def show(conn, _params) do + organization_id = conn.assigns.current_organization_id + org = Organizations.get_organization!(organization_id) + json(conn, %{data: format_org(org)}) + end + + def update(conn, %{"organization" => attrs}) do + organization_id = conn.assigns.current_organization_id + org = Organizations.get_organization!(organization_id) + + case Organizations.update_organization(org, attrs) do + {:ok, updated} -> + json(conn, %{data: format_org(updated)}) + + {:error, changeset} -> + conn + |> put_status(:unprocessable_entity) + |> json(%{errors: translate_errors(changeset)}) + end + end + + def update(conn, _params) do + conn |> put_status(:bad_request) |> json(%{error: "Missing 'organization' parameter"}) + end + + defp format_org(org) do + %{ + id: org.id, + name: org.name, + slug: org.slug, + subscription_plan: org.subscription_plan, + use_sites: org.use_sites, + snmp_version: org.snmp_version, + snmp_community: org.snmp_community, + snmp_port: org.snmp_port, + snmp_transport: org.snmp_transport, + snmpv3_security_level: org.snmpv3_security_level, + snmpv3_username: org.snmpv3_username, + snmpv3_auth_protocol: org.snmpv3_auth_protocol, + snmpv3_priv_protocol: org.snmpv3_priv_protocol, + mikrotik_enabled: org.mikrotik_enabled, + mikrotik_username: org.mikrotik_username, + mikrotik_port: org.mikrotik_port, + mikrotik_ssh_port: org.mikrotik_ssh_port, + mikrotik_use_ssl: org.mikrotik_use_ssl, + default_agent_token_id: org.default_agent_token_id, + inserted_at: org.inserted_at, + 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/router.ex b/lib/towerops_web/router.ex index e3a82d96..0aae05bb 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -123,6 +123,27 @@ defmodule ToweropsWeb.Router do resources "/sites", SitesController, except: [:new, :edit] resources "/devices", DevicesController, except: [:new, :edit] + + resources "/agents", AgentsController, except: [:new, :edit] + + resources "/alerts", AlertsController, only: [:index, :show] + post "/alerts/:id/acknowledge", AlertsController, :acknowledge + post "/alerts/:id/resolve", AlertsController, :resolve + + get "/organization", OrganizationController, :show + patch "/organization", OrganizationController, :update + + resources "/members", MembersController, only: [:index, :delete, :update] + resources "/invitations", InvitationsController, only: [:index, :create, :delete] + + resources "/integrations", IntegrationsController, except: [:new, :edit] + post "/integrations/:id/test", IntegrationsController, :test_connection + + get "/devices/:device_id/checks", CheckResultsController, :checks + get "/devices/:device_id/metrics", CheckResultsController, :metrics + get "/devices/:device_id/interfaces", CheckResultsController, :interfaces + + get "/activity", ActivityController, :index end # Webhook routes (shared secret authentication)