Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
322 lines
8.2 KiB
Elixir
322 lines
8.2 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
|
|
|
|
organizations =
|
|
user.id
|
|
|> Organizations.list_user_organizations()
|
|
|> Enum.map(fn org ->
|
|
%{
|
|
id: org.id,
|
|
name: org.name,
|
|
sites_count: Sites.count_organization_sites(org.id),
|
|
device_count: Devices.count_organization_devices(org.id),
|
|
active_alerts_count: Alerts.count_active_alerts(org.id)
|
|
}
|
|
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} ->
|
|
sites =
|
|
org_id
|
|
|> Sites.list_organization_sites()
|
|
|> Enum.map(fn site ->
|
|
%{
|
|
id: site.id,
|
|
name: site.name,
|
|
location: site.location,
|
|
device_count: Devices.count_site_devices(site.id),
|
|
equipment_down_count: Devices.count_site_devices_down(site.id)
|
|
}
|
|
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.name,
|
|
type: sensor.sensor_type,
|
|
unit: sensor.unit,
|
|
current_value: sensor.current_value,
|
|
status: sensor.status
|
|
}
|
|
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
|