towerops/lib/towerops_web/controllers/api/mobile_controller.ex
Graham McIntire d1403c8069 Fix failing tests and clean up code
- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior
- Fix dynamic_extra_test vendor post-processing tests to seed sensor data
- Fix activity_controller_test to seed devices for feed data
- Fix session_manager_test to create browser session for test
- Fix topology_test link creation for connection test
- Fix device_monitor/driver_worker tests for unique job constraints
- Fix accounts_test expired_tokens assertion (magic link token is expired)
- Fix happy_path_test and show_events_test to seed monitor data
- Fix admin user_live_test user.name -> user.email (no name field)
- Fix schema_test to seed activity data
- Fix mobile_qr_live_test to match actual template text
- Fix SnmpKit.MIB doctests and tests for enriched return values
- Fix onboarding_live, mobile_controller, mib_test weak assertions
- Remove dead code and fix credo warnings
2026-06-16 14:54:34 -05:00

330 lines
8.5 KiB
Elixir

defmodule ToweropsWeb.Api.MobileController do
@moduledoc """
API controller for mobile app data access.
All endpoints require mobile authentication via bearer token.
"""
use ToweropsWeb, :controller
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Organizations
alias Towerops.Sites
@doc """
GET /api/v1/mobile/organizations
Returns list of organizations the user has access to.
Response:
{
"organizations": [
{
"id": "uuid",
"name": "Acme Corp",
"sites_count": 3,
"device_count": 15,
"active_alerts_count": 2
}
]
}
"""
def list_organizations(conn, _params) do
user = conn.assigns.current_user
orgs = Organizations.list_user_organizations(user.id)
org_ids = Enum.map(orgs, & &1.id)
site_counts = Sites.batch_count_organization_sites(org_ids)
device_counts = Devices.batch_count_organization_devices(org_ids)
alert_counts = Alerts.batch_count_active_alerts(org_ids)
organizations =
Enum.map(orgs, fn org ->
%{
id: org.id,
name: org.name,
sites_count: Map.get(site_counts, org.id, 0),
device_count: Map.get(device_counts, org.id, 0),
active_alerts_count: Map.get(alert_counts, org.id, 0)
}
end)
json(conn, %{organizations: organizations})
end
@doc """
GET /api/v1/mobile/organizations/:id/sites
Returns list of sites for an organization.
Response:
{
"sites": [
{
"id": "uuid",
"name": "Main Office",
"location": "New York, NY",
"device_count": 5,
"equipment_down_count": 1
}
]
}
"""
def list_sites(conn, %{"organization_id" => org_id}) do
user = conn.assigns.current_user
case verify_organization_access(user, org_id) do
{:ok, _org} ->
site_list = Sites.list_organization_sites(org_id)
site_ids = Enum.map(site_list, & &1.id)
counts = Devices.batch_count_site_devices(site_ids)
sites =
Enum.map(site_list, fn site ->
%{total: total, down: down} = Map.get(counts, site.id, %{total: 0, down: 0})
%{
id: site.id,
name: site.name,
location: site.location,
device_count: total,
equipment_down_count: down
}
end)
json(conn, %{sites: sites})
{:error, :unauthorized} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this organization"})
end
end
@doc """
GET /api/v1/mobile/organizations/:id/devices
Returns list of devices for an organization with current status.
Query params:
- site_id: Filter by site (optional)
- status: Filter by status: "up", "down", "unknown" (optional)
Response:
{
"device": [
{
"id": "uuid",
"name": "Core Router",
"ip_address": "192.168.1.1",
"site_name": "Main Office",
"status": "up",
"uptime": "15 days, 4 hours",
"last_seen_at": "2026-01-15T19:44:25Z"
}
]
}
"""
def list_devices(conn, %{"organization_id" => org_id} = params) do
user = conn.assigns.current_user
case verify_organization_access(user, org_id) do
{:ok, _org} ->
equipment_list =
org_id
|> Devices.list_organization_devices(params)
|> Enum.map(&format_equipment/1)
json(conn, %{device: equipment_list})
{:error, :unauthorized} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this organization"})
end
end
@doc """
GET /api/v1/mobile/devices/:id
Returns detailed device information including interfaces and sensors.
Response:
{
"id": "uuid",
"name": "Core Router",
"ip_address": "192.168.1.1",
"status": "up",
"uptime": "15 days, 4 hours",
"site": {"id": "uuid", "name": "Main Office"},
"interfaces": [...],
"sensors": [...]
}
"""
def get_device(conn, %{"id" => device_id}) do
user = conn.assigns.current_user
case Devices.get_device_with_details(device_id) do
nil ->
conn
|> put_status(:not_found)
|> json(%{error: "Device not found"})
device ->
case verify_organization_access(user, device.organization_id) do
{:ok, _org} ->
json(conn, format_equipment_details(device))
{:error, :unauthorized} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this device"})
end
end
end
@doc """
GET /api/v1/mobile/organizations/:id/alerts
Returns list of alerts for an organization.
Query params:
- severity: Filter by severity: "critical", "warning", "info" (optional)
- status: Filter by status: "active", "acknowledged", "resolved" (optional)
- limit: Number of alerts to return (default 50, max 200)
Response:
{
"alerts": [
{
"id": "uuid",
"severity": "critical",
"status": "active",
"message": "Device Down: Core Router",
"equipment_name": "Core Router",
"device_id": "uuid",
"occurred_at": "2026-01-15T19:44:25Z"
}
]
}
"""
def list_alerts(conn, %{"organization_id" => org_id} = params) do
user = conn.assigns.current_user
case verify_organization_access(user, org_id) do
{:ok, _org} ->
limit =
case Integer.parse(params["limit"] || "50") do
{n, _} when n > 0 and n <= 200 -> n
_ -> 50
end
alerts =
org_id
|> Alerts.list_organization_alerts(Map.put(params, "limit", limit))
|> Enum.map(&format_alert/1)
json(conn, %{alerts: alerts})
{:error, :unauthorized} ->
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this organization"})
end
end
# Private helpers
defp verify_organization_access(user, org_id) do
if Organizations.user_has_access?(user.id, org_id) do
{:ok, org_id}
else
{:error, :unauthorized}
end
end
defp format_equipment(device) do
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
site_name: device.site && device.site.name,
status: device.status || "unknown",
uptime: format_uptime(device),
last_seen_at: device.last_seen_at
}
end
defp format_equipment_details(device) do
%{
id: device.id,
name: device.name,
ip_address: device.ip_address,
status: device.status || "unknown",
uptime: format_uptime(device),
site: %{
id: device.site.id,
name: device.site.name
},
interfaces:
Enum.map(device.snmp_device.interfaces || [], fn interface ->
%{
id: interface.id,
name: interface.if_name,
alias: interface.if_alias,
status: interface.if_oper_status,
admin_status: interface.if_admin_status,
speed: interface.if_speed,
mac_address: interface.if_phys_address
}
end),
sensors:
Enum.map(device.snmp_device.sensors || [], fn sensor ->
%{
id: sensor.id,
name: sensor.sensor_descr,
type: sensor.sensor_type,
unit: sensor.sensor_unit,
current_value: sensor.last_value,
status: nil
}
end)
}
end
defp format_alert(alert) do
%{
id: alert.id,
status: format_alert_status(alert),
message: alert.message,
equipment_name: alert.device && alert.device.name,
device_id: alert.device_id,
occurred_at: alert.triggered_at
}
end
@doc false
def format_alert_status(%{resolved_at: t}) when not is_nil(t), do: "resolved"
def format_alert_status(%{acknowledged_at: t}) when not is_nil(t), do: "acknowledged"
def format_alert_status(_), do: "active"
@doc false
def timeticks_to_string(timeticks) do
seconds = div(timeticks, 100)
days = div(seconds, 86_400)
hours = div(rem(seconds, 86_400), 3600)
minutes = div(rem(seconds, 3600), 60)
format_uptime_components(days, hours, minutes)
end
defp format_uptime_components(days, hours, _min) when days > 0, do: "#{days}d #{hours}h"
defp format_uptime_components(_days, hours, minutes) when hours > 0, do: "#{hours}h #{minutes}m"
defp format_uptime_components(_, _, minutes), do: "#{minutes}m"
defp format_uptime(device) do
if device.snmp_device && device.snmp_device.sys_uptime do
timeticks_to_string(device.snmp_device.sys_uptime)
end
end
end