feat: expand REST API with alerts, agents, org settings, members, integrations, metrics, activity endpoints

This commit is contained in:
Graham McIntire 2026-02-14 11:14:53 -06:00
parent 685e131cb9
commit a8339797fa
9 changed files with 776 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)