From 42bfc8b9d245d2d81226af2efbdef90388206d91 Mon Sep 17 00:00:00 2001
From: graham
Date: Sun, 15 Mar 2026 17:57:36 -0500
Subject: [PATCH] paperclip/TOW-21-uisp-integration-phase1 (#31)
Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/31
---
config/runtime.exs | 6 +
lib/towerops/cn_maestro/client.ex | 175 ++++++++++
lib/towerops/cn_maestro/sync.ex | 169 ++++++++++
lib/towerops/integrations/integration.ex | 2 +-
lib/towerops/reports.ex | 187 +++++++++++
lib/towerops/reports/notifier.ex | 71 +++++
lib/towerops/reports/report.ex | 91 ++++++
lib/towerops/rf_links.ex | 134 ++++++++
lib/towerops/status_pages.ex | 115 +++++++
lib/towerops/status_pages/status_incident.ex | 38 +++
.../status_pages/status_page_component.ex | 35 ++
.../status_pages/status_page_config.ex | 38 +++
lib/towerops/uisp/client.ex | 133 ++++++++
lib/towerops/uisp/config_snapshot.ex | 172 ++++++++++
lib/towerops/uisp/device_sync.ex | 146 +++++++++
lib/towerops/uisp/gps_sync.ex | 96 ++++++
lib/towerops/uisp/site_sync.ex | 101 ++++++
lib/towerops/uisp/statistics_sync.ex | 143 +++++++++
lib/towerops/uisp/sync.ex | 116 +++++++
lib/towerops/uisp/sync_log.ex | 41 +++
.../workers/cn_maestro_sync_worker.ex | 61 ++++
lib/towerops/workers/report_worker.ex | 71 +++++
lib/towerops/workers/uisp_sync_worker.ex | 87 +++++
lib/towerops_web/components/layouts.ex | 12 +
.../live/org/integrations_live.ex | 43 +++
.../live/org/integrations_live.html.heex | 38 +++
lib/towerops_web/live/reports_live.ex | 141 +++++++++
lib/towerops_web/live/reports_live.html.heex | 228 +++++++++++++
lib/towerops_web/live/rf_link_health_live.ex | 203 ++++++++++++
.../live/rf_link_health_live.html.heex | 299 ++++++++++++++++++
lib/towerops_web/live/status_page_live.ex | 101 ++++++
.../live/status_page_live.html.heex | 146 +++++++++
lib/towerops_web/router.ex | 9 +
.../20260315120000_create_uisp_sync_logs.exs | 26 ++
.../20260315150000_create_reports.exs | 29 ++
...315160000_create_uisp_config_snapshots.exs | 22 ++
.../20260315170000_create_status_pages.exs | 59 ++++
test/towerops/cn_maestro/client_test.exs | 96 ++++++
test/towerops/reports_test.exs | 167 ++++++++++
test/towerops/rf_links_test.exs | 127 ++++++++
test/towerops/status_pages_test.exs | 142 +++++++++
test/towerops/uisp/client_test.exs | 155 +++++++++
test/towerops/uisp/device_sync_test.exs | 158 +++++++++
test/towerops/uisp/site_sync_test.exs | 125 ++++++++
test/towerops/uisp/sync_test.exs | 85 +++++
45 files changed, 4638 insertions(+), 1 deletion(-)
create mode 100644 lib/towerops/cn_maestro/client.ex
create mode 100644 lib/towerops/cn_maestro/sync.ex
create mode 100644 lib/towerops/reports.ex
create mode 100644 lib/towerops/reports/notifier.ex
create mode 100644 lib/towerops/reports/report.ex
create mode 100644 lib/towerops/rf_links.ex
create mode 100644 lib/towerops/status_pages.ex
create mode 100644 lib/towerops/status_pages/status_incident.ex
create mode 100644 lib/towerops/status_pages/status_page_component.ex
create mode 100644 lib/towerops/status_pages/status_page_config.ex
create mode 100644 lib/towerops/uisp/client.ex
create mode 100644 lib/towerops/uisp/config_snapshot.ex
create mode 100644 lib/towerops/uisp/device_sync.ex
create mode 100644 lib/towerops/uisp/gps_sync.ex
create mode 100644 lib/towerops/uisp/site_sync.ex
create mode 100644 lib/towerops/uisp/statistics_sync.ex
create mode 100644 lib/towerops/uisp/sync.ex
create mode 100644 lib/towerops/uisp/sync_log.ex
create mode 100644 lib/towerops/workers/cn_maestro_sync_worker.ex
create mode 100644 lib/towerops/workers/report_worker.ex
create mode 100644 lib/towerops/workers/uisp_sync_worker.ex
create mode 100644 lib/towerops_web/live/reports_live.ex
create mode 100644 lib/towerops_web/live/reports_live.html.heex
create mode 100644 lib/towerops_web/live/rf_link_health_live.ex
create mode 100644 lib/towerops_web/live/rf_link_health_live.html.heex
create mode 100644 lib/towerops_web/live/status_page_live.ex
create mode 100644 lib/towerops_web/live/status_page_live.html.heex
create mode 100644 priv/repo/migrations/20260315120000_create_uisp_sync_logs.exs
create mode 100644 priv/repo/migrations/20260315150000_create_reports.exs
create mode 100644 priv/repo/migrations/20260315160000_create_uisp_config_snapshots.exs
create mode 100644 priv/repo/migrations/20260315170000_create_status_pages.exs
create mode 100644 test/towerops/cn_maestro/client_test.exs
create mode 100644 test/towerops/reports_test.exs
create mode 100644 test/towerops/rf_links_test.exs
create mode 100644 test/towerops/status_pages_test.exs
create mode 100644 test/towerops/uisp/client_test.exs
create mode 100644 test/towerops/uisp/device_sync_test.exs
create mode 100644 test/towerops/uisp/site_sync_test.exs
create mode 100644 test/towerops/uisp/sync_test.exs
diff --git a/config/runtime.exs b/config/runtime.exs
index 1a4a08e2..06175cd3 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -216,6 +216,12 @@ if config_env() == :prod do
{"*/5 * * * *", Towerops.Workers.SplynxSyncWorker},
# Sync VISP billing data every 5 minutes
{"*/5 * * * *", Towerops.Workers.VispSyncWorker},
+ # Sync UISP infrastructure data every 5 minutes
+ {"*/5 * * * *", Towerops.Workers.UispSyncWorker},
+ # Sync Cambium cnMaestro data every 5 minutes
+ {"*/5 * * * *", Towerops.Workers.CnMaestroSyncWorker},
+ # Scheduled reports — check for due reports every hour
+ {"0 * * * *", Towerops.Workers.ReportWorker},
# Device health insights nightly at 3:30 AM
{"30 3 * * *", Towerops.Workers.DeviceHealthInsightWorker},
# System insights (agent offline detection) every 5 minutes
diff --git a/lib/towerops/cn_maestro/client.ex b/lib/towerops/cn_maestro/client.ex
new file mode 100644
index 00000000..e9d61814
--- /dev/null
+++ b/lib/towerops/cn_maestro/client.ex
@@ -0,0 +1,175 @@
+defmodule Towerops.CnMaestro.Client do
+ @moduledoc """
+ HTTP client for the Cambium cnMaestro API.
+
+ Uses OAuth 2.0 Client Credentials Grant for authentication.
+ Tokens are cached in-process for the duration of a sync cycle.
+ """
+
+ require Logger
+
+ @token_path "/api/v1/access/token"
+
+ @doc """
+ Tests the connection by obtaining an OAuth token and fetching one device.
+ """
+ def test_connection(base_url, client_id, client_secret) do
+ case get_token(base_url, client_id, client_secret) do
+ {:ok, token} ->
+ case request(:get, "#{base_url}/api/v1/devices?limit=1", token) do
+ {:ok, %{status: status}} when status in 200..299 ->
+ {:ok, :connected}
+
+ {:ok, %{status: status}} ->
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Obtains an OAuth 2.0 access token using client credentials.
+ """
+ def get_token(base_url, client_id, client_secret) do
+ opts = [
+ method: :post,
+ url: "#{base_url}#{@token_path}",
+ headers: [{"content-type", "application/x-www-form-urlencoded"}],
+ body:
+ URI.encode_query(%{
+ "grant_type" => "client_credentials",
+ "client_id" => client_id,
+ "client_secret" => client_secret
+ })
+ ]
+
+ opts = maybe_add_test_plug(opts)
+
+ case Req.request(opts) do
+ {:ok, %{status: 200, body: %{"access_token" => token}}} ->
+ {:ok, token}
+
+ {:ok, %{status: 200, body: body}} when is_binary(body) ->
+ case Jason.decode(body) do
+ {:ok, %{"access_token" => token}} -> {:ok, token}
+ _ -> {:error, :invalid_token_response}
+ end
+
+ {:ok, %{status: 401}} ->
+ {:error, :unauthorized}
+
+ {:ok, %{status: status, body: body}} ->
+ Logger.warning("cnMaestro token request failed: status=#{status} body=#{inspect(body)}")
+ {:error, {:token_failed, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ rescue
+ e -> {:error, Exception.message(e)}
+ end
+
+ @doc """
+ Lists all devices from cnMaestro.
+ """
+ def list_devices(base_url, token) do
+ case request(:get, "#{base_url}/api/v1/devices", token) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, extract_data(body)}
+
+ {:ok, %{status: status}} ->
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Lists all networks (sites) from cnMaestro.
+ """
+ def list_networks(base_url, token) do
+ case request(:get, "#{base_url}/api/v1/networks", token) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, extract_data(body)}
+
+ {:ok, %{status: status}} ->
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Gets statistics for a specific device by MAC address.
+ """
+ def get_device_statistics(base_url, token, mac) do
+ case request(:get, "#{base_url}/api/v1/devices/#{mac}/statistics", token) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, extract_data(body)}
+
+ {:ok, %{status: 404}} ->
+ {:error, :not_found}
+
+ {:ok, %{status: status}} ->
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Gets performance data for a specific device by MAC address.
+ """
+ def get_device_performance(base_url, token, mac) do
+ case request(:get, "#{base_url}/api/v1/devices/#{mac}/performance", token) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, extract_data(body)}
+
+ {:ok, %{status: 404}} ->
+ {:error, :not_found}
+
+ {:ok, %{status: status}} ->
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp extract_data(%{"data" => data}) when is_list(data), do: data
+ defp extract_data(data) when is_list(data), do: data
+ defp extract_data(%{"data" => data}), do: [data]
+ defp extract_data(_), do: []
+
+ defp request(method, url, token) do
+ opts = [
+ method: method,
+ url: url,
+ headers: [
+ {"authorization", "Bearer #{token}"},
+ {"accept", "application/json"}
+ ]
+ ]
+
+ opts = maybe_add_test_plug(opts)
+ Req.request(opts)
+ rescue
+ e -> {:error, Exception.message(e)}
+ end
+
+ defp maybe_add_test_plug(opts) do
+ if Application.get_env(:towerops, :env) == :test do
+ Keyword.put(opts, :plug, {Req.Test, __MODULE__})
+ else
+ opts
+ end
+ end
+end
diff --git a/lib/towerops/cn_maestro/sync.ex b/lib/towerops/cn_maestro/sync.ex
new file mode 100644
index 00000000..3adcc783
--- /dev/null
+++ b/lib/towerops/cn_maestro/sync.ex
@@ -0,0 +1,169 @@
+defmodule Towerops.CnMaestro.Sync do
+ @moduledoc """
+ Orchestrates syncing data from cnMaestro into Towerops.
+
+ Authenticates via OAuth, then syncs networks (sites) and devices.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.CnMaestro.Client
+ alias Towerops.Devices.Device
+ alias Towerops.Integrations
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Sites.Site
+
+ require Logger
+
+ @doc """
+ Main entry point: syncs networks and devices for a cnMaestro integration.
+ """
+ def sync_organization(%Integration{} = integration) do
+ base_url = integration.credentials["url"]
+ client_id = integration.credentials["client_id"]
+ client_secret = integration.credentials["client_secret"]
+ org_id = integration.organization_id
+
+ start_time = System.monotonic_time(:millisecond)
+
+ with {:ok, token} <- Client.get_token(base_url, client_id, client_secret),
+ {:ok, network_result} <- sync_networks(base_url, token, org_id),
+ {:ok, device_result} <- sync_devices(base_url, token, org_id, network_result.site_map) do
+ duration_ms = System.monotonic_time(:millisecond) - start_time
+
+ message =
+ "Synced #{network_result.synced} networks, matched #{device_result.matched} devices, created #{device_result.created} devices"
+
+ Integrations.update_sync_status(integration, "success", message)
+
+ Logger.info("cnMaestro sync completed for org #{org_id} in #{duration_ms}ms: #{message}")
+
+ {:ok,
+ %{
+ networks_synced: network_result.synced,
+ devices_matched: device_result.matched,
+ devices_created: device_result.created
+ }}
+ else
+ {:error, :unauthorized} ->
+ Integrations.update_sync_status(integration, "failed", "Authentication failed — check client credentials")
+ {:error, :unauthorized}
+
+ {:error, reason} ->
+ Integrations.update_sync_status(integration, "failed", "Sync failed: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ defp sync_networks(base_url, token, org_id) do
+ case Client.list_networks(base_url, token) do
+ {:ok, networks} ->
+ {synced, site_map} =
+ Enum.reduce(networks, {0, %{}}, fn network, {count, acc} ->
+ name = network["name"]
+ network_id = network["id"] || network["name"]
+
+ if is_nil(name) or name == "" do
+ {count, acc}
+ else
+ attrs = %{
+ organization_id: org_id,
+ name: name
+ }
+
+ case upsert_site(attrs) do
+ {:ok, site} ->
+ new_acc = if network_id, do: Map.put(acc, network_id, site), else: acc
+ {count + 1, new_acc}
+
+ {:error, _} ->
+ {count, acc}
+ end
+ end
+ end)
+
+ {:ok, %{synced: synced, site_map: site_map}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp sync_devices(base_url, token, org_id, site_map) do
+ case Client.list_devices(base_url, token) do
+ {:ok, devices} ->
+ result =
+ Enum.reduce(devices, %{matched: 0, created: 0}, fn device_data, acc ->
+ mac = device_data["mac"]
+ ip = device_data["ip"]
+ name = device_data["name"] || device_data["mac"]
+ network = device_data["network"]
+ local_site = site_map[network]
+
+ if is_nil(ip) and is_nil(mac) do
+ acc
+ else
+ case find_existing_device(org_id, ip, mac) do
+ nil ->
+ case create_device(org_id, ip || mac, name, local_site) do
+ {:ok, _} -> Map.update!(acc, :created, &(&1 + 1))
+ {:error, _} -> acc
+ end
+
+ device ->
+ maybe_update_site(device, local_site)
+ Map.update!(acc, :matched, &(&1 + 1))
+ end
+ end
+ end)
+
+ {:ok, result}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp find_existing_device(org_id, ip, _mac) when not is_nil(ip) do
+ Device
+ |> where(organization_id: ^org_id, ip_address: ^ip)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ defp find_existing_device(_org_id, _ip, _mac), do: nil
+
+ defp create_device(org_id, ip, name, site) do
+ %Device{}
+ |> Device.changeset(%{
+ organization_id: org_id,
+ ip_address: ip,
+ name: name,
+ site_id: if(site, do: site.id),
+ snmp_enabled: true,
+ monitoring_enabled: true
+ })
+ |> Repo.insert()
+ end
+
+ defp maybe_update_site(device, nil), do: {:ok, device}
+
+ defp maybe_update_site(device, site) do
+ if device.site_id != site.id do
+ device |> Device.changeset(%{site_id: site.id}) |> Repo.update()
+ else
+ {:ok, device}
+ end
+ end
+
+ defp upsert_site(attrs) do
+ %Site{}
+ |> Site.changeset(attrs)
+ |> Repo.insert(
+ on_conflict: {:replace, [:updated_at]},
+ conflict_target: [:organization_id, :name],
+ returning: true
+ )
+ end
+end
diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex
index b211fd62..fedbd950 100644
--- a/lib/towerops/integrations/integration.ex
+++ b/lib/towerops/integrations/integration.ex
@@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
- @valid_providers ~w(preseem gaiia pagerduty netbox sonar splynx visp)
+ @valid_providers ~w(preseem gaiia pagerduty netbox sonar splynx visp uisp cn_maestro)
@valid_sync_statuses ~w(never success partial failed)
schema "integrations" do
diff --git a/lib/towerops/reports.ex b/lib/towerops/reports.ex
new file mode 100644
index 00000000..780a91f0
--- /dev/null
+++ b/lib/towerops/reports.ex
@@ -0,0 +1,187 @@
+defmodule Towerops.Reports do
+ @moduledoc """
+ Context for managing scheduled reports.
+
+ Handles CRUD operations and report generation/delivery.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Alerts
+ alias Towerops.Capacity
+ alias Towerops.Devices
+ alias Towerops.Repo
+ alias Towerops.Reports.Report
+ alias Towerops.RfLinks
+
+ require Logger
+
+ # -- CRUD --
+
+ def list_reports(organization_id) do
+ Report
+ |> where(organization_id: ^organization_id)
+ |> order_by(desc: :inserted_at)
+ |> Repo.all()
+ end
+
+ def get_report!(id), do: Repo.get!(Report, id)
+
+ def get_report(id) do
+ case Repo.get(Report, id) do
+ nil -> {:error, :not_found}
+ report -> {:ok, report}
+ end
+ end
+
+ def create_report(attrs) do
+ %Report{}
+ |> Report.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_report(%Report{} = report, attrs) do
+ report
+ |> Report.changeset(attrs)
+ |> Repo.update()
+ end
+
+ def delete_report(%Report{} = report) do
+ Repo.delete(report)
+ end
+
+ def toggle_report(%Report{} = report) do
+ update_report(report, %{enabled: !report.enabled})
+ end
+
+ # -- Report Generation --
+
+ @doc """
+ Generates report data as a CSV string based on report type and scope.
+ """
+ def generate_csv(%Report{} = report) do
+ org_id = report.organization_id
+ time_range = parse_time_range(report.scope)
+
+ case report.report_type do
+ "uptime_summary" -> generate_uptime_csv(org_id, time_range)
+ "alert_history" -> generate_alert_history_csv(org_id, time_range)
+ "capacity_trends" -> generate_capacity_csv(org_id)
+ "rf_link_health" -> generate_rf_link_csv(org_id)
+ end
+ end
+
+ defp generate_uptime_csv(org_id, _time_range) do
+ devices = Devices.list_organization_devices(org_id)
+
+ header = "Device,IP Address,Status,Last Seen\n"
+
+ rows =
+ Enum.map_join(devices, "\n", fn device ->
+ status = if device.monitoring_enabled, do: "monitored", else: "unmonitored"
+ "#{csv_escape(device.name)},#{device.ip_address},#{status},#{device.updated_at}"
+ end)
+
+ {:ok, header <> rows}
+ end
+
+ defp generate_alert_history_csv(org_id, _time_range) do
+ alerts = Alerts.list_organization_alerts(org_id, 500)
+
+ header = "Alert,Severity,Status,Device,Created At,Resolved At\n"
+
+ rows =
+ Enum.map_join(alerts, "\n", fn alert ->
+ device_name = if alert.device, do: alert.device.name, else: "-"
+ resolved = if alert.resolved_at, do: to_string(alert.resolved_at), else: "-"
+
+ "#{csv_escape(alert.message)},#{alert.severity},#{alert.status},#{csv_escape(device_name)},#{alert.inserted_at},#{resolved}"
+ end)
+
+ {:ok, header <> rows}
+ end
+
+ defp generate_capacity_csv(org_id) do
+ summaries = Capacity.get_organization_capacity_summary(org_id)
+
+ header = "Site,Total Capacity (bps),Throughput (bps),Utilization %\n"
+
+ rows =
+ Enum.map_join(summaries, "\n", fn s ->
+ "#{csv_escape(s.site_name || "Unknown")},#{s.total_capacity_bps},#{s.total_throughput_bps},#{s.utilization_pct}"
+ end)
+
+ {:ok, header <> rows}
+ end
+
+ defp generate_rf_link_csv(org_id) do
+ links = RfLinks.list_rf_links(org_id)
+
+ header = "Client,MAC,Signal (dBm),SNR (dB),TX Rate,RX Rate,Health,Last Seen\n"
+
+ rows =
+ Enum.map_join(links, "\n", fn link ->
+ name = link.hostname || link.ip_address || link.mac_address || "-"
+
+ "#{csv_escape(name)},#{link.mac_address},#{link.signal_strength || "-"},#{link.snr || "-"},#{link.tx_rate || "-"},#{link.rx_rate || "-"},#{link.health},#{link.last_seen_at}"
+ end)
+
+ {:ok, header <> rows}
+ end
+
+ defp csv_escape(nil), do: ""
+
+ defp csv_escape(value) when is_binary(value) do
+ if String.contains?(value, [",", "\"", "\n"]) do
+ "\"#{String.replace(value, "\"", "\"\"")}\""
+ else
+ value
+ end
+ end
+
+ defp csv_escape(value), do: to_string(value)
+
+ defp parse_time_range(scope) do
+ days = Map.get(scope, "days", 7)
+ %{from: DateTime.add(DateTime.utc_now(), -days, :day), to: DateTime.utc_now()}
+ end
+
+ # -- Schedule Helpers --
+
+ @doc """
+ Returns true if a report is due to run based on its schedule.
+ """
+ def due?(%Report{schedule: %{"type" => "one_time"}} = report) do
+ is_nil(report.last_run_at)
+ end
+
+ def due?(%Report{schedule: %{"type" => "daily"}} = report) do
+ elapsed_since_last_run(report) >= 86_400
+ end
+
+ def due?(%Report{schedule: %{"type" => "weekly"}} = report) do
+ elapsed_since_last_run(report) >= 604_800
+ end
+
+ def due?(%Report{schedule: %{"type" => "monthly"}} = report) do
+ elapsed_since_last_run(report) >= 2_592_000
+ end
+
+ def due?(_), do: false
+
+ defp elapsed_since_last_run(%{last_run_at: nil}), do: :infinity
+
+ defp elapsed_since_last_run(%{last_run_at: last_run_at}) do
+ DateTime.diff(DateTime.utc_now(), last_run_at, :second)
+ end
+
+ @doc """
+ Marks a report as having been run.
+ """
+ def mark_run(%Report{} = report, status) do
+ update_report(report, %{
+ last_run_at: DateTime.truncate(DateTime.utc_now(), :second),
+ last_run_status: status
+ })
+ end
+end
diff --git a/lib/towerops/reports/notifier.ex b/lib/towerops/reports/notifier.ex
new file mode 100644
index 00000000..3e4a4c60
--- /dev/null
+++ b/lib/towerops/reports/notifier.ex
@@ -0,0 +1,71 @@
+defmodule Towerops.Reports.Notifier do
+ @moduledoc """
+ Sends scheduled report emails with CSV attachments.
+ """
+
+ import Swoosh.Email
+
+ alias Towerops.Mailer
+ alias Towerops.Reports.Report
+
+ require Logger
+
+ @doc """
+ Delivers a report CSV to all recipients via email.
+ """
+ def deliver_report(%Report{} = report, csv_data) do
+ from_address = Application.get_env(:towerops, :mailer_from, {"Towerops", "hi@towerops.net"})
+ filename = "#{report.report_type}_#{Date.to_iso8601(Date.utc_today())}.csv"
+
+ subject = "Towerops Report: #{humanize_type(report.report_type)} — #{report.name}"
+
+ body = """
+ Your scheduled report "#{report.name}" is ready.
+
+ Report Type: #{humanize_type(report.report_type)}
+ Generated: #{Calendar.strftime(DateTime.utc_now(), "%B %d, %Y at %H:%M UTC")}
+
+ The report is attached as a CSV file.
+
+ — Towerops
+ """
+
+ results =
+ Enum.map(report.recipients, fn recipient ->
+ email =
+ new()
+ |> to(recipient)
+ |> from(from_address)
+ |> subject(subject)
+ |> text_body(body)
+ |> attachment(
+ Swoosh.Attachment.new({:data, csv_data},
+ filename: filename,
+ content_type: "text/csv"
+ )
+ )
+
+ case Mailer.deliver(email) do
+ {:ok, _} ->
+ Logger.info("Report #{report.id} delivered to #{recipient}")
+ :ok
+
+ {:error, reason} ->
+ Logger.error("Failed to deliver report #{report.id} to #{recipient}: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end)
+
+ if Enum.all?(results, &(&1 == :ok)) do
+ :ok
+ else
+ {:error, :partial_delivery}
+ end
+ end
+
+ defp humanize_type("uptime_summary"), do: "Uptime Summary"
+ defp humanize_type("alert_history"), do: "Alert History"
+ defp humanize_type("capacity_trends"), do: "Capacity Trends"
+ defp humanize_type("rf_link_health"), do: "RF Link Health"
+ defp humanize_type(type), do: type
+end
diff --git a/lib/towerops/reports/report.ex b/lib/towerops/reports/report.ex
new file mode 100644
index 00000000..f56edd2c
--- /dev/null
+++ b/lib/towerops/reports/report.ex
@@ -0,0 +1,91 @@
+defmodule Towerops.Reports.Report do
+ @moduledoc """
+ Schema for scheduled reports.
+
+ Supports report types: uptime_summary, alert_history, capacity_trends, rf_link_health.
+ Schedule types: one_time, daily, weekly, monthly.
+ Formats: csv (PDF support planned).
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ @valid_report_types ~w(uptime_summary alert_history capacity_trends rf_link_health)
+ @valid_formats ~w(csv)
+ @valid_schedule_types ~w(one_time daily weekly monthly)
+
+ schema "reports" do
+ field :name, :string
+ field :report_type, :string
+ field :scope, :map, default: %{}
+ field :schedule, :map, default: %{}
+ field :recipients, {:array, :string}, default: []
+ field :format, :string, default: "csv"
+ field :enabled, :boolean, default: true
+ field :last_run_at, :utc_datetime
+ field :last_run_status, :string
+
+ belongs_to :organization, Towerops.Organizations.Organization
+ belongs_to :created_by, Towerops.Accounts.User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(report, attrs) do
+ report
+ |> cast(attrs, [
+ :name,
+ :report_type,
+ :scope,
+ :schedule,
+ :recipients,
+ :format,
+ :enabled,
+ :organization_id,
+ :created_by_id,
+ :last_run_at,
+ :last_run_status
+ ])
+ |> validate_required([:name, :report_type, :schedule, :recipients, :organization_id])
+ |> validate_inclusion(:report_type, @valid_report_types)
+ |> validate_inclusion(:format, @valid_formats)
+ |> validate_schedule()
+ |> validate_recipients()
+ |> foreign_key_constraint(:organization_id)
+ |> foreign_key_constraint(:created_by_id)
+ end
+
+ defp validate_schedule(changeset) do
+ case get_field(changeset, :schedule) do
+ %{"type" => type} when type in @valid_schedule_types -> changeset
+ %{} -> add_error(changeset, :schedule, "must include a valid type")
+ _ -> changeset
+ end
+ end
+
+ defp validate_recipients(changeset) do
+ case get_field(changeset, :recipients) do
+ [] -> add_error(changeset, :recipients, "must include at least one recipient")
+ recipients when is_list(recipients) ->
+ if Enum.all?(recipients, &valid_email?/1) do
+ changeset
+ else
+ add_error(changeset, :recipients, "all recipients must be valid email addresses")
+ end
+ _ -> changeset
+ end
+ end
+
+ defp valid_email?(email) when is_binary(email) do
+ String.match?(email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
+ end
+
+ defp valid_email?(_), do: false
+
+ def valid_report_types, do: @valid_report_types
+ def valid_formats, do: @valid_formats
+ def valid_schedule_types, do: @valid_schedule_types
+end
diff --git a/lib/towerops/rf_links.ex b/lib/towerops/rf_links.ex
new file mode 100644
index 00000000..afd90ea6
--- /dev/null
+++ b/lib/towerops/rf_links.ex
@@ -0,0 +1,134 @@
+defmodule Towerops.RfLinks do
+ @moduledoc """
+ Context module for RF link health data.
+
+ Aggregates wireless client data with signal strength, SNR, and capacity
+ metrics for NOC-focused triage views.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Repo
+ alias Towerops.Snmp.WirelessClient
+ alias Towerops.Snmp.WirelessClientReading
+
+ @doc """
+ Returns all wireless clients (RF links) for an organization with their
+ latest readings and health classification.
+
+ Results are sorted by signal strength ascending (weakest first).
+ """
+ def list_rf_links(organization_id, opts \\ []) do
+ filter = Keyword.get(opts, :filter, :all)
+
+ WirelessClient
+ |> where(organization_id: ^organization_id)
+ |> preload(:device)
+ |> Repo.all()
+ |> Enum.map(&enrich_link/1)
+ |> maybe_filter(filter)
+ |> Enum.sort_by(& &1.signal_strength, fn
+ nil, nil -> true
+ nil, _ -> true
+ _, nil -> false
+ a, b -> a <= b
+ end)
+ end
+
+ @doc """
+ Returns summary counts for stat cards.
+ """
+ def get_summary(organization_id) do
+ links = list_rf_links(organization_id)
+
+ total = length(links)
+ healthy = Enum.count(links, &(&1.health == :healthy))
+ degraded = Enum.count(links, &(&1.health == :degraded))
+ critical = Enum.count(links, &(&1.health == :critical))
+
+ %{
+ total: total,
+ healthy: healthy,
+ degraded: degraded,
+ critical: critical
+ }
+ end
+
+ @doc """
+ Fetches the last 24h of readings for a specific wireless client,
+ used for sparkline rendering.
+ """
+ def get_readings_24h(wireless_client_id) do
+ cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
+
+ WirelessClientReading
+ |> where(wireless_client_id: ^wireless_client_id)
+ |> where([r], r.checked_at >= ^cutoff)
+ |> order_by(asc: :checked_at)
+ |> select([r], %{
+ checked_at: r.checked_at,
+ signal_strength: r.signal_strength,
+ snr: r.snr,
+ tx_rate: r.tx_rate,
+ rx_rate: r.rx_rate
+ })
+ |> Repo.all()
+ end
+
+ # Health classification based on issue spec thresholds
+ defp classify_health(signal, snr) do
+ cond do
+ is_nil(signal) and is_nil(snr) -> :unknown
+ signal_ok?(signal, -65) and snr_ok?(snr, 25) -> :healthy
+ signal_ok?(signal, -75) and snr_ok?(snr, 15) -> :degraded
+ true -> :critical
+ end
+ end
+
+ defp signal_ok?(nil, _threshold), do: true
+ defp signal_ok?(signal, threshold), do: signal > threshold
+
+ defp snr_ok?(nil, _threshold), do: true
+ defp snr_ok?(snr, threshold), do: snr > threshold
+
+ defp enrich_link(%WirelessClient{} = client) do
+ capacity_utilization = calculate_capacity_utilization(client)
+
+ %{
+ id: client.id,
+ device_id: client.device_id,
+ device: client.device,
+ mac_address: client.mac_address,
+ ip_address: client.ip_address,
+ hostname: client.hostname,
+ signal_strength: client.signal_strength,
+ snr: client.snr,
+ distance: client.distance,
+ tx_rate: client.tx_rate,
+ rx_rate: client.rx_rate,
+ uptime_seconds: client.uptime_seconds,
+ last_seen_at: client.last_seen_at,
+ health: classify_health(client.signal_strength, client.snr),
+ capacity_utilization: capacity_utilization,
+ metadata: client.metadata
+ }
+ end
+
+ defp calculate_capacity_utilization(%{tx_rate: tx, rx_rate: rx, metadata: metadata}) do
+ max_rate = get_in(metadata || %{}, ["max_rate"])
+
+ cond do
+ is_nil(max_rate) or max_rate == 0 -> nil
+ is_nil(tx) and is_nil(rx) -> nil
+ true ->
+ current = max(tx || 0, rx || 0)
+ Float.round(current / max_rate * 100, 1)
+ end
+ end
+
+ defp maybe_filter(links, :all), do: links
+ defp maybe_filter(links, :healthy), do: Enum.filter(links, &(&1.health == :healthy))
+ defp maybe_filter(links, :degraded), do: Enum.filter(links, &(&1.health == :degraded))
+ defp maybe_filter(links, :critical), do: Enum.filter(links, &(&1.health == :critical))
+ defp maybe_filter(links, _), do: links
+end
diff --git a/lib/towerops/status_pages.ex b/lib/towerops/status_pages.ex
new file mode 100644
index 00000000..6021e652
--- /dev/null
+++ b/lib/towerops/status_pages.ex
@@ -0,0 +1,115 @@
+defmodule Towerops.StatusPages do
+ @moduledoc """
+ Context for white-label customer status pages.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Repo
+ alias Towerops.StatusPages.StatusIncident
+ alias Towerops.StatusPages.StatusPageComponent
+ alias Towerops.StatusPages.StatusPageConfig
+
+ # -- Config --
+
+ def get_config_by_org(organization_id) do
+ StatusPageConfig
+ |> where(organization_id: ^organization_id)
+ |> Repo.one()
+ end
+
+ def get_config_by_slug(slug) do
+ StatusPageConfig
+ |> where(slug: ^slug, enabled: true)
+ |> preload([:components, :incidents])
+ |> Repo.one()
+ end
+
+ def create_config(attrs) do
+ %StatusPageConfig{}
+ |> StatusPageConfig.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_config(%StatusPageConfig{} = config, attrs) do
+ config
+ |> StatusPageConfig.changeset(attrs)
+ |> Repo.update()
+ end
+
+ # -- Components --
+
+ def list_components(config_id) do
+ StatusPageComponent
+ |> where(status_page_config_id: ^config_id)
+ |> order_by(asc: :position)
+ |> Repo.all()
+ end
+
+ def create_component(attrs) do
+ %StatusPageComponent{}
+ |> StatusPageComponent.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_component(%StatusPageComponent{} = component, attrs) do
+ component
+ |> StatusPageComponent.changeset(attrs)
+ |> Repo.update()
+ end
+
+ def delete_component(%StatusPageComponent{} = component) do
+ Repo.delete(component)
+ end
+
+ # -- Incidents --
+
+ def list_incidents(config_id, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+
+ StatusIncident
+ |> where(status_page_config_id: ^config_id)
+ |> order_by(desc: :started_at)
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ def list_active_incidents(config_id) do
+ StatusIncident
+ |> where(status_page_config_id: ^config_id)
+ |> where([i], i.status != "resolved")
+ |> order_by(desc: :started_at)
+ |> Repo.all()
+ end
+
+ def create_incident(attrs) do
+ %StatusIncident{}
+ |> StatusIncident.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ def update_incident(%StatusIncident{} = incident, attrs) do
+ incident
+ |> StatusIncident.changeset(attrs)
+ |> Repo.update()
+ end
+
+ def resolve_incident(%StatusIncident{} = incident) do
+ update_incident(incident, %{
+ status: "resolved",
+ resolved_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ end
+
+ # -- Overall Status --
+
+ def overall_status(components) when is_list(components) do
+ cond do
+ Enum.any?(components, &(&1.status == "major_outage")) -> :major_outage
+ Enum.any?(components, &(&1.status == "partial_outage")) -> :partial_outage
+ Enum.any?(components, &(&1.status == "degraded")) -> :degraded
+ Enum.any?(components, &(&1.status == "maintenance")) -> :maintenance
+ true -> :operational
+ end
+ end
+end
diff --git a/lib/towerops/status_pages/status_incident.ex b/lib/towerops/status_pages/status_incident.ex
new file mode 100644
index 00000000..d4f9f1d4
--- /dev/null
+++ b/lib/towerops/status_pages/status_incident.ex
@@ -0,0 +1,38 @@
+defmodule Towerops.StatusPages.StatusIncident do
+ @moduledoc """
+ A status page incident — created manually or auto-generated from alerts.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ @valid_severities ~w(minor major critical)
+ @valid_statuses ~w(investigating identified monitoring resolved)
+
+ schema "status_incidents" do
+ field :title, :string
+ field :body, :string
+ field :severity, :string, default: "minor"
+ field :status, :string, default: "investigating"
+ field :started_at, :utc_datetime
+ field :resolved_at, :utc_datetime
+
+ belongs_to :status_page_config, Towerops.StatusPages.StatusPageConfig
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(incident, attrs) do
+ incident
+ |> cast(attrs, [:title, :body, :severity, :status, :started_at, :resolved_at, :status_page_config_id])
+ |> validate_required([:title, :status, :started_at, :status_page_config_id])
+ |> validate_inclusion(:severity, @valid_severities)
+ |> validate_inclusion(:status, @valid_statuses)
+ |> foreign_key_constraint(:status_page_config_id)
+ end
+
+ def valid_severities, do: @valid_severities
+ def valid_statuses, do: @valid_statuses
+end
diff --git a/lib/towerops/status_pages/status_page_component.ex b/lib/towerops/status_pages/status_page_component.ex
new file mode 100644
index 00000000..34ef9e5a
--- /dev/null
+++ b/lib/towerops/status_pages/status_page_component.ex
@@ -0,0 +1,35 @@
+defmodule Towerops.StatusPages.StatusPageComponent do
+ @moduledoc """
+ A service component displayed on the public status page.
+ Maps device groups to customer-facing service names.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ @valid_statuses ~w(operational degraded partial_outage major_outage maintenance)
+
+ schema "status_page_components" do
+ field :name, :string
+ field :description, :string
+ field :position, :integer, default: 0
+ field :status, :string, default: "operational"
+ field :device_group, :map, default: %{}
+
+ belongs_to :status_page_config, Towerops.StatusPages.StatusPageConfig
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(component, attrs) do
+ component
+ |> cast(attrs, [:name, :description, :position, :status, :device_group, :status_page_config_id])
+ |> validate_required([:name, :status_page_config_id])
+ |> validate_inclusion(:status, @valid_statuses)
+ |> foreign_key_constraint(:status_page_config_id)
+ end
+
+ def valid_statuses, do: @valid_statuses
+end
diff --git a/lib/towerops/status_pages/status_page_config.ex b/lib/towerops/status_pages/status_page_config.ex
new file mode 100644
index 00000000..af7a239e
--- /dev/null
+++ b/lib/towerops/status_pages/status_page_config.ex
@@ -0,0 +1,38 @@
+defmodule Towerops.StatusPages.StatusPageConfig do
+ @moduledoc """
+ Configuration for a white-label customer status page.
+ One-to-one with organization.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ schema "status_page_configs" do
+ field :enabled, :boolean, default: false
+ field :slug, :string
+ field :company_name, :string
+ field :logo_url, :string
+ field :support_email, :string
+ field :custom_css, :string
+ field :auto_incidents, :boolean, default: true
+
+ belongs_to :organization, Towerops.Organizations.Organization
+ has_many :components, Towerops.StatusPages.StatusPageComponent
+ has_many :incidents, Towerops.StatusPages.StatusIncident
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(config, attrs) do
+ config
+ |> cast(attrs, [:enabled, :slug, :company_name, :logo_url, :support_email, :custom_css, :auto_incidents, :organization_id])
+ |> validate_required([:slug, :organization_id])
+ |> validate_format(:slug, ~r/^[a-z0-9][a-z0-9-]*[a-z0-9]$/, message: "must be lowercase alphanumeric with hyphens")
+ |> validate_length(:slug, min: 3, max: 63)
+ |> unique_constraint(:slug)
+ |> unique_constraint(:organization_id)
+ |> foreign_key_constraint(:organization_id)
+ end
+end
diff --git a/lib/towerops/uisp/client.ex b/lib/towerops/uisp/client.ex
new file mode 100644
index 00000000..b0d48e6f
--- /dev/null
+++ b/lib/towerops/uisp/client.ex
@@ -0,0 +1,133 @@
+defmodule Towerops.Uisp.Client do
+ @moduledoc """
+ HTTP client for the UISP API.
+
+ Uses Req with built-in test support via `Req.Test`.
+ Auth: x-auth-token header.
+ Base URL is configured per-integration (user provides their UISP URL).
+ """
+
+ require Logger
+
+ @doc """
+ Tests the connection to the UISP API by fetching one site.
+
+ Returns `{:ok, body}` on success, `{:error, reason}` on failure.
+ """
+ def test_connection(base_url, api_key) do
+ case request(:get, "#{base_url}/sites?count=1", api_key) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, body}
+
+ {:ok, %{status: 401}} ->
+ {:error, :unauthorized}
+
+ {:ok, %{status: 403}} ->
+ {:error, :forbidden}
+
+ {:ok, %{status: status, body: body}} ->
+ Logger.warning("UISP API unexpected status #{status}: #{inspect(body)}")
+ {:error, {:unexpected_status, status}}
+
+ {:error, reason} ->
+ Logger.error("UISP API connection error: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Lists all sites from the UISP API.
+
+ Returns `{:ok, [site]}` on success.
+ """
+ def list_sites(base_url, api_key) do
+ case request(:get, "#{base_url}/sites", api_key) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, normalize_list(body)}
+
+ {:ok, %{status: 401}} ->
+ {:error, :unauthorized}
+
+ {:ok, %{status: 403}} ->
+ {:error, :forbidden}
+
+ {:ok, %{status: status, body: body}} ->
+ {:error, {:unexpected_status, status, body}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Lists all devices from the UISP API.
+
+ Returns `{:ok, [device]}` on success.
+ """
+ def list_devices(base_url, api_key) do
+ case request(:get, "#{base_url}/devices", api_key) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, normalize_list(body)}
+
+ {:ok, %{status: 401}} ->
+ {:error, :unauthorized}
+
+ {:ok, %{status: 403}} ->
+ {:error, :forbidden}
+
+ {:ok, %{status: status, body: body}} ->
+ {:error, {:unexpected_status, status, body}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Makes a raw request to any UISP API endpoint.
+ Returns `{:ok, body}` or `{:error, reason}`.
+ """
+ def request_raw(method, url, api_key) do
+ case request(method, url, api_key) do
+ {:ok, %{status: status, body: body}} when status in 200..299 ->
+ {:ok, body}
+
+ {:ok, %{status: 401}} ->
+ {:error, :unauthorized}
+
+ {:ok, %{status: 404}} ->
+ {:error, :not_found}
+
+ {:ok, %{status: status, body: body}} ->
+ {:error, {:unexpected_status, status, body}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp normalize_list(body) when is_list(body), do: body
+ defp normalize_list(%{"items" => items}) when is_list(items), do: items
+ defp normalize_list(_), do: []
+
+ defp request(method, url, api_key) do
+ opts = [
+ method: method,
+ url: url,
+ headers: [{"x-auth-token", api_key}, {"accept", "application/json"}]
+ ]
+
+ # Only use Req.Test plug in test environment
+ opts =
+ if Application.get_env(:towerops, :env) == :test do
+ Keyword.put(opts, :plug, {Req.Test, __MODULE__})
+ else
+ opts
+ end
+
+ Req.request(opts)
+ rescue
+ exception ->
+ {:error, Exception.message(exception)}
+ end
+end
diff --git a/lib/towerops/uisp/config_snapshot.ex b/lib/towerops/uisp/config_snapshot.ex
new file mode 100644
index 00000000..26205561
--- /dev/null
+++ b/lib/towerops/uisp/config_snapshot.ex
@@ -0,0 +1,172 @@
+defmodule Towerops.Uisp.ConfigSnapshot do
+ @moduledoc """
+ Stores and diffs UISP device configuration snapshots.
+
+ Fetches device configuration from UISP API, stores compressed snapshots,
+ and provides diff functionality between versions.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Repo
+ alias Towerops.Uisp.Client
+
+ require Logger
+
+ @doc """
+ Fetches and stores configuration snapshots for all UISP devices.
+
+ Only stores a new snapshot if the config has changed (based on hash).
+ Returns `{:ok, %{stored: count, unchanged: count}}`.
+ """
+ def sync_configs(%{credentials: credentials} = _integration, device_map) do
+ base_url = credentials["url"]
+ api_key = credentials["api_key"]
+
+ result =
+ Enum.reduce(device_map, %{stored: 0, unchanged: 0}, fn {uisp_device_id, local_device}, acc ->
+ case fetch_device_config(base_url, api_key, uisp_device_id) do
+ {:ok, config_data} ->
+ store_if_changed(local_device, config_data, acc)
+
+ {:error, _reason} ->
+ acc
+ end
+ end)
+
+ {:ok, result}
+ end
+
+ defp fetch_device_config(base_url, api_key, device_id) do
+ Client.request_raw(:get, "#{base_url}/devices/#{device_id}", api_key)
+ end
+
+ defp store_if_changed(device, config_data, acc) do
+ config_json = Jason.encode!(config_data)
+ config_hash = :crypto.hash(:sha256, config_json) |> Base.encode16(case: :lower)
+
+ latest = get_latest_snapshot(device.id)
+
+ if is_nil(latest) or latest.config_hash != config_hash do
+ compressed = :zlib.compress(config_json)
+
+ attrs = %{
+ device_id: device.id,
+ config_compressed: compressed,
+ config_hash: config_hash,
+ config_size_bytes: byte_size(config_json),
+ compressed_size_bytes: byte_size(compressed),
+ source: "uisp",
+ snapshot_at: DateTime.truncate(DateTime.utc_now(), :second)
+ }
+
+ case insert_snapshot(attrs) do
+ {:ok, _} -> Map.update!(acc, :stored, &(&1 + 1))
+ {:error, _} -> acc
+ end
+ else
+ Map.update!(acc, :unchanged, &(&1 + 1))
+ end
+ end
+
+ @doc """
+ Gets the latest config snapshot for a device.
+ """
+ def get_latest_snapshot(device_id) do
+ from(s in "uisp_config_snapshots",
+ where: s.device_id == ^device_id,
+ order_by: [desc: s.snapshot_at],
+ limit: 1,
+ select: %{
+ id: s.id,
+ device_id: s.device_id,
+ config_hash: s.config_hash,
+ config_size_bytes: s.config_size_bytes,
+ snapshot_at: s.snapshot_at
+ }
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Lists config snapshots for a device, most recent first.
+ """
+ def list_snapshots(device_id, limit \\ 20) do
+ from(s in "uisp_config_snapshots",
+ where: s.device_id == ^device_id,
+ order_by: [desc: s.snapshot_at],
+ limit: ^limit,
+ select: %{
+ id: s.id,
+ config_hash: s.config_hash,
+ config_size_bytes: s.config_size_bytes,
+ compressed_size_bytes: s.compressed_size_bytes,
+ snapshot_at: s.snapshot_at
+ }
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets and decompresses a specific snapshot's config.
+ """
+ def get_config(snapshot_id) do
+ case Repo.one(
+ from(s in "uisp_config_snapshots",
+ where: s.id == ^snapshot_id,
+ select: s.config_compressed
+ )
+ ) do
+ nil ->
+ {:error, :not_found}
+
+ compressed ->
+ {:ok, :zlib.uncompress(compressed) |> Jason.decode!()}
+ end
+ end
+
+ @doc """
+ Diffs two config snapshots, returning added/removed/changed keys.
+ """
+ def diff_snapshots(snapshot_id_a, snapshot_id_b) do
+ with {:ok, config_a} <- get_config(snapshot_id_a),
+ {:ok, config_b} <- get_config(snapshot_id_b) do
+ {:ok, compute_diff(config_a, config_b)}
+ end
+ end
+
+ defp compute_diff(a, b) when is_map(a) and is_map(b) do
+ all_keys = MapSet.union(MapSet.new(Map.keys(a)), MapSet.new(Map.keys(b)))
+
+ Enum.reduce(all_keys, %{added: [], removed: [], changed: []}, fn key, acc ->
+ case {Map.get(a, key), Map.get(b, key)} do
+ {nil, new_val} ->
+ Map.update!(acc, :added, &[{key, new_val} | &1])
+
+ {old_val, nil} ->
+ Map.update!(acc, :removed, &[{key, old_val} | &1])
+
+ {same, same} ->
+ acc
+
+ {old_val, new_val} ->
+ Map.update!(acc, :changed, &[{key, old_val, new_val} | &1])
+ end
+ end)
+ end
+
+ defp compute_diff(_a, _b), do: %{added: [], removed: [], changed: []}
+
+ defp insert_snapshot(attrs) do
+ Repo.insert_all("uisp_config_snapshots", [
+ Map.merge(attrs, %{
+ id: Ecto.UUID.generate(),
+ inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ ])
+ |> case do
+ {1, _} -> {:ok, :inserted}
+ _ -> {:error, :insert_failed}
+ end
+ end
+end
diff --git a/lib/towerops/uisp/device_sync.ex b/lib/towerops/uisp/device_sync.ex
new file mode 100644
index 00000000..55096959
--- /dev/null
+++ b/lib/towerops/uisp/device_sync.ex
@@ -0,0 +1,146 @@
+defmodule Towerops.Uisp.DeviceSync do
+ @moduledoc """
+ Syncs UISP devices into the Towerops devices table.
+
+ Fetches all devices from the UISP API and attempts to match them to
+ existing Towerops devices by IP address (primary) or MAC address (fallback).
+ If matched, updates the device's site assignment. If not found, creates
+ a new device with SNMP enabled.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Devices.Device
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Snmp.Interface
+ alias Towerops.Snmp.Device, as: SnmpDevice
+ alias Towerops.Uisp.Client
+
+ require Logger
+
+ @doc """
+ Fetches devices from UISP and syncs them to local devices.
+
+ The `site_map` parameter maps UISP site IDs to local `%Site{}` structs,
+ enabling correct site assignment.
+
+ Returns `{:ok, %{matched: count, created: count}}`.
+ """
+ def sync_devices(%Integration{} = integration, site_map) do
+ base_url = integration.credentials["url"]
+ api_key = integration.credentials["api_key"]
+ org_id = integration.organization_id
+
+ case Client.list_devices(base_url, api_key) do
+ {:ok, devices} ->
+ result = process_devices(org_id, devices, site_map)
+ {:ok, result}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp process_devices(org_id, devices, site_map) do
+ Enum.reduce(devices, %{matched: 0, created: 0}, fn device_data, acc ->
+ ip = device_data["ipAddress"]
+ mac = get_in(device_data, ["identification", "mac"])
+ name = get_in(device_data, ["identification", "name"])
+ uisp_site_id = get_in(device_data, ["site", "id"])
+ local_site = site_map[uisp_site_id]
+
+ if is_nil(ip) or ip == "" do
+ acc
+ else
+ case find_existing_device(org_id, ip, mac) do
+ nil ->
+ case create_device(org_id, ip, name, local_site, device_data) do
+ {:ok, _device} -> Map.update!(acc, :created, &(&1 + 1))
+ {:error, reason} ->
+ Logger.warning("UISP device sync: failed to create device #{ip}: #{inspect(reason)}")
+ acc
+ end
+
+ device ->
+ case update_device_site(device, local_site) do
+ {:ok, _device} -> Map.update!(acc, :matched, &(&1 + 1))
+ {:error, reason} ->
+ Logger.warning("UISP device sync: failed to update device #{ip}: #{inspect(reason)}")
+ acc
+ end
+ end
+ end
+ end)
+ end
+
+ defp find_existing_device(org_id, ip, mac) do
+ find_by_ip(org_id, ip) || find_by_mac(org_id, mac)
+ end
+
+ defp find_by_ip(org_id, ip) do
+ Device
+ |> where(organization_id: ^org_id, ip_address: ^ip)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ defp find_by_mac(_org_id, nil), do: nil
+ defp find_by_mac(_org_id, ""), do: nil
+
+ defp find_by_mac(org_id, mac) do
+ normalized = normalize_mac(mac)
+
+ # MACs can be stored in various formats, so we fetch and compare in Elixir
+ Interface
+ |> join(:inner, [i], sd in SnmpDevice, on: i.snmp_device_id == sd.id)
+ |> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
+ |> where([_i, _sd, d], d.organization_id == ^org_id)
+ |> where([i, _sd, _d], not is_nil(i.if_phys_address))
+ |> select([i, _sd, d], {d, i.if_phys_address})
+ |> Repo.all()
+ |> Enum.find_value(fn {device, phys_addr} ->
+ if normalize_mac(phys_addr) == normalized, do: device
+ end)
+ end
+
+ defp create_device(org_id, ip, name, site, raw_data) do
+ site_id = if site, do: site.id, else: nil
+
+ attrs = %{
+ organization_id: org_id,
+ site_id: site_id,
+ ip_address: ip,
+ name: name || ip,
+ snmp_enabled: true,
+ monitoring_enabled: true
+ }
+
+ _ = raw_data
+
+ %Device{}
+ |> Device.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ defp update_device_site(device, nil), do: {:ok, device}
+
+ defp update_device_site(device, site) do
+ if device.site_id == site.id do
+ {:ok, device}
+ else
+ device
+ |> Device.changeset(%{site_id: site.id})
+ |> Repo.update()
+ end
+ end
+
+ defp normalize_mac(nil), do: ""
+ defp normalize_mac(""), do: ""
+
+ defp normalize_mac(mac) when is_binary(mac) do
+ mac
+ |> String.downcase()
+ |> String.replace(~r/[:\-\.]/, "")
+ end
+end
diff --git a/lib/towerops/uisp/gps_sync.ex b/lib/towerops/uisp/gps_sync.ex
new file mode 100644
index 00000000..52795afc
--- /dev/null
+++ b/lib/towerops/uisp/gps_sync.ex
@@ -0,0 +1,96 @@
+defmodule Towerops.Uisp.GpsSync do
+ @moduledoc """
+ Enriches local devices with GPS coordinates from UISP.
+
+ When UISP provides device GPS data, updates the local device
+ and its associated site with coordinates for map enrichment.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Devices.Device
+ alias Towerops.Repo
+ alias Towerops.Sites.Site
+
+ require Logger
+
+ @doc """
+ Updates GPS coordinates for devices and sites from UISP device data.
+
+ Expects a list of `{uisp_device_data, local_device}` tuples.
+ Returns `{:ok, %{devices_updated: count, sites_updated: count}}`.
+ """
+ def sync_gps(device_pairs) do
+ result =
+ Enum.reduce(device_pairs, %{devices_updated: 0, sites_updated: 0}, fn {uisp_data, local_device}, acc ->
+ lat = get_in(uisp_data, ["location", "latitude"]) || get_in(uisp_data, ["gps", "latitude"])
+ lon = get_in(uisp_data, ["location", "longitude"]) || get_in(uisp_data, ["gps", "longitude"])
+
+ if valid_coords?(lat, lon) do
+ acc
+ |> maybe_update_device_gps(local_device, lat, lon)
+ |> maybe_update_site_gps(local_device, lat, lon)
+ else
+ acc
+ end
+ end)
+
+ {:ok, result}
+ end
+
+ defp maybe_update_device_gps(acc, device, lat, lon) do
+ # Update device metadata with GPS if not already set
+ metadata = device.metadata || %{}
+
+ if is_nil(metadata["latitude"]) or is_nil(metadata["longitude"]) do
+ new_metadata = Map.merge(metadata, %{"latitude" => to_float(lat), "longitude" => to_float(lon), "gps_source" => "uisp"})
+
+ case device
+ |> Device.changeset(%{metadata: new_metadata})
+ |> Repo.update() do
+ {:ok, _} -> Map.update!(acc, :devices_updated, &(&1 + 1))
+ {:error, _} -> acc
+ end
+ else
+ acc
+ end
+ end
+
+ defp maybe_update_site_gps(acc, device, lat, lon) do
+ if device.site_id do
+ site = Repo.get(Site, device.site_id)
+
+ if site && (is_nil(site.latitude) or is_nil(site.longitude)) do
+ case site
+ |> Site.changeset(%{latitude: to_float(lat), longitude: to_float(lon)})
+ |> Repo.update() do
+ {:ok, _} -> Map.update!(acc, :sites_updated, &(&1 + 1))
+ {:error, _} -> acc
+ end
+ else
+ acc
+ end
+ else
+ acc
+ end
+ end
+
+ defp valid_coords?(lat, lon) do
+ lat = to_float(lat)
+ lon = to_float(lon)
+ is_number(lat) and is_number(lon) and lat != 0.0 and lon != 0.0
+ end
+
+ defp to_float(nil), do: nil
+ defp to_float(v) when is_float(v), do: v
+ defp to_float(v) when is_integer(v), do: v / 1
+
+ defp to_float(v) when is_binary(v) do
+ case Float.parse(v) do
+ {f, _} -> f
+ :error -> nil
+ end
+ end
+
+ defp to_float(_), do: nil
+end
diff --git a/lib/towerops/uisp/site_sync.ex b/lib/towerops/uisp/site_sync.ex
new file mode 100644
index 00000000..00e12428
--- /dev/null
+++ b/lib/towerops/uisp/site_sync.ex
@@ -0,0 +1,101 @@
+defmodule Towerops.Uisp.SiteSync do
+ @moduledoc """
+ Syncs UISP sites into the Towerops sites table.
+
+ Fetches all sites from the UISP API and upserts them by name within
+ the organization. GPS coordinates are mapped from the UISP response.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Sites.Site
+ alias Towerops.Uisp.Client
+
+ require Logger
+
+ @doc """
+ Fetches sites from UISP and upserts them into the local sites table.
+
+ Returns `{:ok, %{synced: count, site_map: map}}` where `site_map` maps
+ UISP site IDs to local Site records (used by device sync for site assignment).
+ """
+ def sync_sites(%Integration{} = integration) do
+ base_url = integration.credentials["url"]
+ api_key = integration.credentials["api_key"]
+ org_id = integration.organization_id
+
+ case Client.list_sites(base_url, api_key) do
+ {:ok, sites} ->
+ {synced, site_map} = upsert_sites(org_id, sites)
+ {:ok, %{synced: synced, site_map: site_map}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp upsert_sites(org_id, sites) do
+ Enum.reduce(sites, {0, %{}}, fn site_data, {count, acc} ->
+ uisp_id = get_in(site_data, ["identification", "id"]) || site_data["id"]
+ name = get_in(site_data, ["identification", "name"]) || site_data["name"]
+ latitude = get_in(site_data, ["description", "gps", "latitude"])
+ longitude = get_in(site_data, ["description", "gps", "longitude"])
+
+ if is_nil(name) or name == "" do
+ {count, acc}
+ else
+ attrs = %{
+ organization_id: org_id,
+ name: name,
+ latitude: to_float(latitude),
+ longitude: to_float(longitude)
+ }
+
+ case upsert_site(attrs) do
+ {:ok, site} ->
+ new_acc = if uisp_id, do: Map.put(acc, uisp_id, site), else: acc
+ {count + 1, new_acc}
+
+ {:error, changeset} ->
+ Logger.warning("UISP site sync: failed to upsert site #{name}: #{inspect(changeset.errors)}")
+ {count, acc}
+ end
+ end
+ end)
+ end
+
+ defp upsert_site(attrs) do
+ %Site{}
+ |> Site.changeset(attrs)
+ |> Repo.insert(
+ on_conflict: {:replace, [:latitude, :longitude, :updated_at]},
+ conflict_target: [:organization_id, :name],
+ returning: true
+ )
+ end
+
+ @doc """
+ Builds a site_map from existing local sites, keyed by name.
+
+ Used after sync to allow device_sync to match UISP site names to local sites.
+ """
+ def build_site_map_by_name(org_id) do
+ Site
+ |> where(organization_id: ^org_id)
+ |> Repo.all()
+ |> Map.new(fn site -> {site.name, site} end)
+ end
+
+ defp to_float(nil), do: nil
+ defp to_float(v) when is_float(v), do: v
+ defp to_float(v) when is_integer(v), do: v / 1
+
+ defp to_float(v) when is_binary(v) do
+ case Float.parse(v) do
+ {f, _} -> f
+ :error -> nil
+ end
+ end
+end
diff --git a/lib/towerops/uisp/statistics_sync.ex b/lib/towerops/uisp/statistics_sync.ex
new file mode 100644
index 00000000..47ea3241
--- /dev/null
+++ b/lib/towerops/uisp/statistics_sync.ex
@@ -0,0 +1,143 @@
+defmodule Towerops.Uisp.StatisticsSync do
+ @moduledoc """
+ Syncs UISP device statistics into Towerops interface stats.
+
+ Fetches statistics from the UISP API for each device and inserts
+ readings into the interface_stats hypertable. Handles deduplication
+ with SNMP-polled data by checking for recent readings within a
+ configurable window.
+ """
+
+ import Ecto.Query
+
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Snmp.InterfaceStat
+ alias Towerops.Uisp.Client
+
+ require Logger
+
+ @dedup_window_seconds 120
+
+ @doc """
+ Fetches and inserts statistics for all devices linked via UISP.
+
+ Returns `{:ok, %{inserted: count, skipped_dedup: count}}`.
+ """
+ def sync_statistics(%Integration{} = integration, device_map) do
+ base_url = integration.credentials["url"]
+ api_key = integration.credentials["api_key"]
+
+ result =
+ Enum.reduce(device_map, %{inserted: 0, skipped_dedup: 0}, fn {uisp_device_id, local_device}, acc ->
+ case fetch_device_stats(base_url, api_key, uisp_device_id) do
+ {:ok, stats} ->
+ process_device_stats(local_device, stats, acc)
+
+ {:error, reason} ->
+ Logger.debug("UISP stats fetch failed for #{uisp_device_id}: #{inspect(reason)}")
+ acc
+ end
+ end)
+
+ {:ok, result}
+ end
+
+ defp fetch_device_stats(base_url, api_key, device_id) do
+ # UISP API: GET /devices/{id}/statistics
+ Client.request_raw(:get, "#{base_url}/devices/#{device_id}/statistics", api_key)
+ end
+
+ defp process_device_stats(local_device, stats, acc) when is_list(stats) do
+ Enum.reduce(stats, acc, fn stat, inner_acc ->
+ interface_name = stat["interfaceName"] || stat["name"] || "unknown"
+ checked_at = parse_timestamp(stat["timestamp"])
+
+ if checked_at && !recently_polled?(local_device.id, interface_name, checked_at) do
+ case insert_stat(local_device, interface_name, stat, checked_at) do
+ {:ok, _} -> Map.update!(inner_acc, :inserted, &(&1 + 1))
+ {:error, _} -> inner_acc
+ end
+ else
+ Map.update!(inner_acc, :skipped_dedup, &(&1 + 1))
+ end
+ end)
+ end
+
+ defp process_device_stats(_device, _stats, acc), do: acc
+
+ @doc """
+ Checks if SNMP already polled this interface recently, to avoid double-counting.
+
+ Returns true if a reading exists within the dedup window.
+ """
+ def recently_polled?(snmp_device_id, _interface_name, checked_at) do
+ cutoff = DateTime.add(checked_at, -@dedup_window_seconds, :second)
+
+ InterfaceStat
+ |> join(:inner, [s], i in Towerops.Snmp.Interface, on: s.interface_id == i.id)
+ |> where([_s, i], i.snmp_device_id == ^snmp_device_id)
+ |> where([s, _i], s.checked_at >= ^cutoff and s.checked_at <= ^checked_at)
+ |> limit(1)
+ |> Repo.exists?()
+ end
+
+ defp insert_stat(local_device, _interface_name, stat, checked_at) do
+ # Find or create the interface record for this device
+ # For UISP stats, we store them as interface stats on the first matching interface
+ interface = find_primary_interface(local_device.id)
+
+ if interface do
+ %InterfaceStat{}
+ |> InterfaceStat.changeset(%{
+ interface_id: interface.id,
+ if_in_octets: stat["receive"] |> parse_counter(),
+ if_out_octets: stat["transmit"] |> parse_counter(),
+ if_in_errors: stat["errors"] |> parse_counter(),
+ if_out_errors: 0,
+ if_in_discards: 0,
+ if_out_discards: 0,
+ checked_at: checked_at
+ })
+ |> Repo.insert()
+ else
+ {:error, :no_interface}
+ end
+ end
+
+ defp find_primary_interface(snmp_device_id) do
+ Towerops.Snmp.Interface
+ |> where(snmp_device_id: ^snmp_device_id)
+ |> order_by(asc: :if_index)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ defp parse_counter(nil), do: 0
+ defp parse_counter(val) when is_integer(val), do: val
+ defp parse_counter(val) when is_float(val), do: round(val)
+
+ defp parse_counter(val) when is_binary(val) do
+ case Integer.parse(val) do
+ {n, _} -> n
+ :error -> 0
+ end
+ end
+
+ defp parse_counter(_), do: 0
+
+ defp parse_timestamp(nil), do: nil
+
+ defp parse_timestamp(ts) when is_binary(ts) do
+ case DateTime.from_iso8601(ts) do
+ {:ok, dt, _offset} -> DateTime.truncate(dt, :second)
+ _ -> nil
+ end
+ end
+
+ defp parse_timestamp(ts) when is_integer(ts) do
+ DateTime.from_unix(ts) |> elem(1) |> DateTime.truncate(:second)
+ end
+
+ defp parse_timestamp(_), do: nil
+end
diff --git a/lib/towerops/uisp/sync.ex b/lib/towerops/uisp/sync.ex
new file mode 100644
index 00000000..28fb5bc7
--- /dev/null
+++ b/lib/towerops/uisp/sync.ex
@@ -0,0 +1,116 @@
+defmodule Towerops.Uisp.Sync do
+ @moduledoc """
+ Orchestrates syncing data from the UISP API into the local database.
+
+ Runs site sync followed by device sync, logs each operation, and updates
+ the integration's sync status.
+ """
+
+ alias Towerops.Integrations
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Uisp.ConfigSnapshot
+ alias Towerops.Uisp.DeviceSync
+ alias Towerops.Uisp.GpsSync
+ alias Towerops.Uisp.SiteSync
+ alias Towerops.Uisp.StatisticsSync
+ alias Towerops.Uisp.SyncLog
+
+ require Logger
+
+ @doc """
+ Main entry point: syncs sites and devices for the given UISP integration.
+
+ Returns `{:ok, %{sites_synced: count, devices_matched: count, devices_created: count}}`
+ or `{:error, reason}`.
+ """
+ def sync_organization(%Integration{} = integration) do
+ start_time = System.monotonic_time(:millisecond)
+
+ with {:ok, site_result} <- SiteSync.sync_sites(integration),
+ site_map = build_full_site_map(integration, site_result.site_map),
+ {:ok, device_result} <- DeviceSync.sync_devices(integration, site_map) do
+ # Phase 2: GPS enrichment, statistics sync, config snapshots
+ # These are best-effort — failures don't block the main sync
+ gps_result = safe_sync(:gps, fn -> GpsSync.sync_gps(device_result.device_pairs || []) end)
+ stats_result = safe_sync(:stats, fn -> StatisticsSync.sync_statistics(integration, device_result.device_map || %{}) end)
+ config_result = safe_sync(:config, fn -> ConfigSnapshot.sync_configs(integration, device_result.device_map || %{}) end)
+
+ duration_ms = System.monotonic_time(:millisecond) - start_time
+ total_synced = site_result.synced + device_result.matched + device_result.created
+
+ message =
+ "Synced #{site_result.synced} sites, matched #{device_result.matched} devices, created #{device_result.created} devices"
+
+ log_sync(integration, "success", total_synced, %{
+ gps: inspect(gps_result),
+ stats: inspect(stats_result),
+ config: inspect(config_result)
+ }, duration_ms)
+ Integrations.update_sync_status(integration, "success", message)
+
+ {:ok,
+ %{
+ sites_synced: site_result.synced,
+ devices_matched: device_result.matched,
+ devices_created: device_result.created
+ }}
+ else
+ {:error, reason} ->
+ duration_ms = System.monotonic_time(:millisecond) - start_time
+ message = humanize_sync_error(reason)
+ log_sync(integration, "failed", 0, %{error: inspect(reason)}, duration_ms)
+ Integrations.update_sync_status(integration, "failed", message)
+ {:error, reason}
+ end
+ end
+
+ defp build_full_site_map(integration, uisp_id_map) do
+ # The uisp_id_map has UISP site IDs -> local sites.
+ # Also build a name-based map for any sites already in the DB.
+ name_map = SiteSync.build_site_map_by_name(integration.organization_id)
+ Map.merge(name_map, uisp_id_map)
+ end
+
+ defp log_sync(integration, status, records_synced, errors, duration_ms) do
+ %SyncLog{}
+ |> SyncLog.changeset(%{
+ organization_id: integration.organization_id,
+ integration_id: integration.id,
+ status: status,
+ records_synced: records_synced,
+ errors: errors,
+ duration_ms: duration_ms,
+ inserted_at: DateTime.truncate(DateTime.utc_now(), :second)
+ })
+ |> Repo.insert()
+ end
+
+ defp safe_sync(label, fun) do
+ case fun.() do
+ {:ok, result} -> result
+ {:error, reason} ->
+ Logger.warning("UISP #{label} sync failed: #{inspect(reason)}")
+ %{error: reason}
+ end
+ rescue
+ e ->
+ Logger.warning("UISP #{label} sync crashed: #{Exception.message(e)}")
+ %{error: Exception.message(e)}
+ end
+
+ defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key"
+
+ defp humanize_sync_error(:timeout), do: "Connection timed out — UISP may be temporarily unavailable"
+
+ defp humanize_sync_error(reason) when is_binary(reason) do
+ if String.contains?(reason, ["CA trust store", "cacert", "certificate"]) do
+ "SSL certificate verification failed — unable to establish a secure connection"
+ else
+ Logger.warning("UISP sync failed with raw error: #{reason}")
+ "An unexpected error occurred during sync"
+ end
+ end
+
+ defp humanize_sync_error(_reason), do: "An unexpected error occurred during sync"
+end
diff --git a/lib/towerops/uisp/sync_log.ex b/lib/towerops/uisp/sync_log.ex
new file mode 100644
index 00000000..4804b69b
--- /dev/null
+++ b/lib/towerops/uisp/sync_log.ex
@@ -0,0 +1,41 @@
+defmodule Towerops.Uisp.SyncLog do
+ @moduledoc """
+ Audit log for UISP sync operations.
+ """
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+
+ @valid_statuses ~w(success partial failed)
+
+ schema "uisp_sync_logs" do
+ field :status, :string
+ field :records_synced, :integer, default: 0
+ field :errors, :map
+ field :duration_ms, :integer
+ field :inserted_at, :utc_datetime
+
+ belongs_to :organization, Towerops.Organizations.Organization
+ belongs_to :integration, Towerops.Integrations.Integration
+ end
+
+ def changeset(sync_log, attrs) do
+ sync_log
+ |> cast(attrs, [
+ :organization_id,
+ :integration_id,
+ :status,
+ :records_synced,
+ :errors,
+ :duration_ms,
+ :inserted_at
+ ])
+ |> validate_required([:organization_id, :integration_id, :status, :inserted_at])
+ |> validate_inclusion(:status, @valid_statuses)
+ |> foreign_key_constraint(:organization_id)
+ |> foreign_key_constraint(:integration_id)
+ end
+end
diff --git a/lib/towerops/workers/cn_maestro_sync_worker.ex b/lib/towerops/workers/cn_maestro_sync_worker.ex
new file mode 100644
index 00000000..2351a16f
--- /dev/null
+++ b/lib/towerops/workers/cn_maestro_sync_worker.ex
@@ -0,0 +1,61 @@
+defmodule Towerops.Workers.CnMaestroSyncWorker do
+ @moduledoc """
+ Oban worker for scheduled cnMaestro sync.
+
+ Same pattern as UispSyncWorker: cron dispatcher + staggered individual jobs.
+ """
+ use Oban.Worker, queue: :maintenance
+
+ alias Towerops.CnMaestro.Sync
+ alias Towerops.Integrations
+ alias Towerops.Workers.PollingOffset
+
+ require Logger
+
+ @window_seconds 300
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: args}) when args == %{} do
+ integrations = Integrations.list_enabled_integrations("cn_maestro")
+
+ now = DateTime.utc_now()
+
+ enqueued =
+ Enum.count(integrations, fn integration ->
+ offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
+ scheduled_at = DateTime.add(now, offset, :second)
+
+ case %{"integration_id" => integration.id}
+ |> new(scheduled_at: scheduled_at, unique: [period: @window_seconds])
+ |> Oban.insert() do
+ {:ok, _} -> true
+ {:error, _} -> false
+ end
+ end)
+
+ if enqueued > 0 do
+ Logger.info("cnMaestro sync: dispatched #{enqueued} jobs")
+ end
+
+ :ok
+ end
+
+ def perform(%Oban.Job{args: %{"integration_id" => id}}) do
+ case Integrations.get_integration_by_id(id) do
+ {:ok, integration} ->
+ case Sync.sync_organization(integration) do
+ {:ok, result} ->
+ Logger.info("cnMaestro sync completed: #{inspect(result)}")
+ :ok
+
+ {:error, reason} ->
+ Logger.error("cnMaestro sync failed: #{inspect(reason)}")
+ :ok
+ end
+
+ {:error, :not_found} ->
+ Logger.warning("cnMaestro sync: integration #{id} not found")
+ :ok
+ end
+ end
+end
diff --git a/lib/towerops/workers/report_worker.ex b/lib/towerops/workers/report_worker.ex
new file mode 100644
index 00000000..57f3ac56
--- /dev/null
+++ b/lib/towerops/workers/report_worker.ex
@@ -0,0 +1,71 @@
+defmodule Towerops.Workers.ReportWorker do
+ @moduledoc """
+ Oban worker for scheduled report generation and delivery.
+
+ The cron dispatcher (no args) runs hourly and checks which reports are due.
+ Individual jobs receive `%{"report_id" => id}` and generate + email the report.
+ """
+ use Oban.Worker, queue: :maintenance
+
+ alias Towerops.Reports
+ alias Towerops.Reports.Notifier
+
+ require Logger
+
+ # Cron dispatcher: find due reports and enqueue individual jobs
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: args}) when args == %{} do
+ reports =
+ Reports.Report
+ |> Towerops.Repo.all()
+ |> Enum.filter(&(&1.enabled and Reports.due?(&1)))
+
+ enqueued =
+ Enum.count(reports, fn report ->
+ case %{"report_id" => report.id}
+ |> new(unique: [period: 3600])
+ |> Oban.insert() do
+ {:ok, _} -> true
+ {:error, _} -> false
+ end
+ end)
+
+ if enqueued > 0 do
+ Logger.info("Report worker: dispatched #{enqueued} report jobs")
+ end
+
+ :ok
+ end
+
+ # Individual report: generate and deliver
+ def perform(%Oban.Job{args: %{"report_id" => id}}) do
+ case Reports.get_report(id) do
+ {:ok, report} ->
+ run_report(report)
+
+ {:error, :not_found} ->
+ Logger.warning("Report #{id} not found, skipping")
+ :ok
+ end
+ end
+
+ defp run_report(report) do
+ {:ok, csv_data} = Reports.generate_csv(report)
+
+ case Notifier.deliver_report(report, csv_data) do
+ :ok ->
+ Reports.mark_run(report, "success")
+ Logger.info("Report #{report.id} (#{report.name}) delivered successfully")
+ :ok
+
+ {:error, _reason} ->
+ Reports.mark_run(report, "partial_failure")
+ :ok
+ end
+ rescue
+ exception ->
+ Reports.mark_run(report, "failed")
+ Logger.error("Report #{report.id} generation failed: #{Exception.message(exception)}")
+ :ok
+ end
+end
diff --git a/lib/towerops/workers/uisp_sync_worker.ex b/lib/towerops/workers/uisp_sync_worker.ex
new file mode 100644
index 00000000..98c8fbca
--- /dev/null
+++ b/lib/towerops/workers/uisp_sync_worker.ex
@@ -0,0 +1,87 @@
+defmodule Towerops.Workers.UispSyncWorker do
+ @moduledoc """
+ Oban worker that syncs UISP data for enabled integrations.
+
+ The cron job (no args) runs every 5 minutes and dispatches individual
+ per-integration sync jobs staggered across the 300-second window using
+ `PollingOffset.calculate_offset/2`. This prevents a thundering herd
+ against UISP APIs.
+
+ Individual jobs receive `%{"integration_id" => id}` and perform the
+ actual sync via `Uisp.Sync.sync_organization/1`.
+ """
+ use Oban.Worker, queue: :maintenance
+
+ alias Towerops.Integrations
+ alias Towerops.Uisp.Sync
+ alias Towerops.Workers.PollingOffset
+
+ require Logger
+
+ @window_seconds 300
+
+ # Cron dispatcher: enqueues staggered individual sync jobs
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: args}) when args == %{} do
+ integrations = Integrations.list_enabled_integrations("uisp")
+
+ eligible = Enum.filter(integrations, &should_sync?/1)
+
+ now = DateTime.utc_now()
+
+ enqueued =
+ Enum.count(eligible, fn integration ->
+ offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
+ scheduled_at = DateTime.add(now, offset, :second)
+
+ case %{"integration_id" => integration.id}
+ |> new(scheduled_at: scheduled_at, unique: [period: @window_seconds])
+ |> Oban.insert() do
+ {:ok, _job} -> true
+ {:error, _reason} -> false
+ end
+ end)
+
+ if enqueued > 0 do
+ Logger.info("UISP sync: dispatched #{enqueued} jobs across #{@window_seconds}s window")
+ end
+
+ :ok
+ end
+
+ # Individual sync: loads integration by ID and syncs
+ def perform(%Oban.Job{args: %{"integration_id" => id}}) do
+ case Integrations.get_integration_by_id(id) do
+ {:ok, integration} ->
+ sync_integration(integration)
+
+ {:error, :not_found} ->
+ Logger.warning("UISP sync: integration #{id} not found, skipping")
+ {:error, :not_found}
+ end
+ end
+
+ defp sync_integration(integration) do
+ case Sync.sync_organization(integration) do
+ {:ok, result} ->
+ Logger.info("UISP sync completed for org #{integration.organization_id}: #{inspect(result)}")
+ :ok
+
+ {:error, reason} ->
+ Logger.error("UISP sync failed for org #{integration.organization_id}: #{inspect(reason)}")
+ :ok
+ end
+ end
+
+ defp should_sync?(integration) do
+ case integration.last_synced_at do
+ nil ->
+ true
+
+ last_synced_at ->
+ interval_seconds = (integration.sync_interval_minutes || 10) * 60
+ elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second)
+ elapsed >= interval_seconds
+ end
+ end
+end
diff --git a/lib/towerops_web/components/layouts.ex b/lib/towerops_web/components/layouts.ex
index be01b626..5f32610e 100644
--- a/lib/towerops_web/components/layouts.ex
+++ b/lib/towerops_web/components/layouts.ex
@@ -414,6 +414,12 @@ defmodule ToweropsWeb.Layouts do
>
{t("Analyze")}
+ <.sidebar_link
+ navigate={~p"/rf-links"}
+ active={@active_page == "rf-links"}
+ icon="hero-signal"
+ label={t("RF Links")}
+ />
<.sidebar_link
navigate={~p"/insights"}
active={@active_page == "insights"}
@@ -432,6 +438,12 @@ defmodule ToweropsWeb.Layouts do
icon="hero-clock"
label={t("Activity")}
/>
+ <.sidebar_link
+ navigate={~p"/reports"}
+ active={@active_page == "reports"}
+ icon="hero-document-chart-bar"
+ label={t("Reports")}
+ />
diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex
index 382585f9..55a6ef41 100644
--- a/lib/towerops_web/live/org/integrations_live.ex
+++ b/lib/towerops_web/live/org/integrations_live.ex
@@ -8,6 +8,8 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
alias Towerops.Preseem.Client, as: PreseemClient
alias Towerops.Sonar.Client, as: SonarClient
alias Towerops.Splynx.Client, as: SplynxClient
+ alias Towerops.CnMaestro.Client, as: CnMaestroClient
+ alias Towerops.Uisp.Client, as: UispClient
alias Towerops.Visp.Client, as: VispClient
require Logger
@@ -86,6 +88,18 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
name: "NetBox",
description: "Network source of truth for IPAM, DCIM, and infrastructure documentation.",
icon: "hero-server-stack"
+ },
+ %{
+ id: "uisp",
+ name: "UISP",
+ description: "Ubiquiti UISP network management — auto-import sites and devices.",
+ icon: "hero-signal"
+ },
+ %{
+ id: "cn_maestro",
+ name: "cnMaestro",
+ description: "Cambium cnMaestro — sync devices, networks, and statistics via OAuth 2.0.",
+ icon: "hero-wifi"
}
]
}
@@ -327,6 +341,12 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
end)
end
+ defp test_provider_connection("cn_maestro", credentials),
+ do: CnMaestroClient.test_connection(credentials["url"], credentials["client_id"], credentials["client_secret"])
+
+ defp test_provider_connection("uisp", credentials),
+ do: UispClient.test_connection(credentials["url"], credentials["api_key"])
+
defp test_provider_connection("preseem", credentials), do: PreseemClient.test_connection(credentials["api_key"])
defp test_provider_connection("gaiia", credentials), do: GaiiaClient.test_connection(credentials["api_key"])
defp test_provider_connection("visp", credentials), do: VispClient.test_connection(credentials["api_key"])
@@ -406,6 +426,21 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
defp credentials_changed?(_existing, _new), do: true
+ defp build_credentials("cn_maestro", params, _existing) do
+ %{
+ "url" => Map.get(params, "url", ""),
+ "client_id" => Map.get(params, "client_id", ""),
+ "client_secret" => Map.get(params, "client_secret", "")
+ }
+ end
+
+ defp build_credentials("uisp", params, _existing) do
+ %{
+ "url" => Map.get(params, "url", ""),
+ "api_key" => Map.get(params, "api_key", "")
+ }
+ end
+
defp build_credentials("splynx", params, _existing) do
%{
"instance_url" => Map.get(params, "instance_url", ""),
@@ -463,6 +498,14 @@ defmodule ToweropsWeb.Org.IntegrationsLive do
data.credentials || %{}
end
+ defp missing_required_credentials?(provider, credentials) when provider in ["cn_maestro"] do
+ blank?(credentials["url"]) or blank?(credentials["client_id"]) or blank?(credentials["client_secret"])
+ end
+
+ defp missing_required_credentials?(provider, credentials) when provider in ["uisp"] do
+ blank?(credentials["url"]) or blank?(credentials["api_key"])
+ end
+
defp missing_required_credentials?(provider, credentials) when provider in ["splynx"] do
blank?(credentials["instance_url"]) or blank?(credentials["api_key"]) or
blank?(credentials["api_secret"])
diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex
index d31383a0..ee4336e0 100644
--- a/lib/towerops_web/live/org/integrations_live.html.heex
+++ b/lib/towerops_web/live/org/integrations_live.html.heex
@@ -629,6 +629,44 @@
>
+ <%= if provider.id == "cn_maestro" do %>
+ <.input
+ field={@form[:url]}
+ type="text"
+ label={t("cnMaestro URL")}
+ placeholder="https://cloud.cambiumnetworks.com"
+ autocomplete="off"
+ value={get_credential(@integrations[provider.id], "url")}
+ />
+ <.input
+ field={@form[:client_id]}
+ type="text"
+ label={t("Client ID")}
+ placeholder="Your OAuth Client ID"
+ autocomplete="off"
+ value={get_credential(@integrations[provider.id], "client_id")}
+ />
+ <.input
+ field={@form[:client_secret]}
+ type="password"
+ label={t("Client Secret")}
+ placeholder="Your OAuth Client Secret"
+ autocomplete="off"
+ value={get_credential(@integrations[provider.id], "client_secret")}
+ />
+ <% end %>
+
+ <%= if provider.id == "uisp" do %>
+ <.input
+ field={@form[:url]}
+ type="text"
+ label={t("UISP URL")}
+ placeholder="https://uisp.example.com/nms/api/v2.1"
+ autocomplete="off"
+ value={get_credential(@integrations[provider.id], "url")}
+ />
+ <% end %>
+
<.input
field={@form[:api_key]}
type="password"
diff --git a/lib/towerops_web/live/reports_live.ex b/lib/towerops_web/live/reports_live.ex
new file mode 100644
index 00000000..0e6437bc
--- /dev/null
+++ b/lib/towerops_web/live/reports_live.ex
@@ -0,0 +1,141 @@
+defmodule ToweropsWeb.ReportsLive do
+ @moduledoc """
+ Scheduled Reports management page.
+
+ Lists existing reports and provides a form to create new ones.
+ """
+ use ToweropsWeb, :live_view
+
+ alias Towerops.Reports
+ alias Towerops.Reports.Report
+
+ @impl true
+ def mount(_params, _session, socket) do
+ organization = socket.assigns.current_scope.organization
+ reports = Reports.list_reports(organization.id)
+
+ {:ok,
+ socket
+ |> assign(:page_title, "Reports")
+ |> assign(:organization_id, organization.id)
+ |> assign(:reports, reports)
+ |> assign(:show_form, false)
+ |> assign(:form, to_form(Report.changeset(%Report{}, %{})))}
+ end
+
+ @impl true
+ def handle_event("show_form", _, socket) do
+ {:noreply, assign(socket, show_form: true)}
+ end
+
+ @impl true
+ def handle_event("hide_form", _, socket) do
+ {:noreply, assign(socket, show_form: false)}
+ end
+
+ @impl true
+ def handle_event("save", %{"report" => params}, socket) do
+ recipients =
+ (params["recipients_text"] || "")
+ |> String.split([",", "\n", ";"])
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+
+ attrs =
+ params
+ |> Map.put("recipients", recipients)
+ |> Map.put("organization_id", socket.assigns.organization_id)
+ |> Map.put("created_by_id", socket.assigns.current_scope.user.id)
+ |> Map.put("schedule", %{"type" => params["schedule_type"] || "weekly"})
+ |> Map.put("scope", %{
+ "target" => params["scope_target"] || "all",
+ "days" => String.to_integer(params["scope_days"] || "7")
+ })
+
+ case Reports.create_report(attrs) do
+ {:ok, _report} ->
+ reports = Reports.list_reports(socket.assigns.organization_id)
+
+ {:noreply,
+ socket
+ |> put_flash(:info, "Report created successfully")
+ |> assign(:reports, reports)
+ |> assign(:show_form, false)
+ |> assign(:form, to_form(Report.changeset(%Report{}, %{})))}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, form: to_form(changeset))}
+ end
+ end
+
+ @impl true
+ def handle_event("toggle", %{"id" => id}, socket) do
+ report = Reports.get_report!(id)
+
+ case Reports.toggle_report(report) do
+ {:ok, _} ->
+ reports = Reports.list_reports(socket.assigns.organization_id)
+ {:noreply, assign(socket, reports: reports)}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to toggle report")}
+ end
+ end
+
+ @impl true
+ def handle_event("delete", %{"id" => id}, socket) do
+ report = Reports.get_report!(id)
+
+ case Reports.delete_report(report) do
+ {:ok, _} ->
+ reports = Reports.list_reports(socket.assigns.organization_id)
+
+ {:noreply,
+ socket
+ |> put_flash(:info, "Report deleted")
+ |> assign(:reports, reports)}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to delete report")}
+ end
+ end
+
+ @impl true
+ def handle_event("run_now", %{"id" => id}, socket) do
+ case %{"report_id" => id}
+ |> Towerops.Workers.ReportWorker.new()
+ |> Oban.insert() do
+ {:ok, _} ->
+ {:noreply, put_flash(socket, :info, "Report queued for delivery")}
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, "Failed to queue report")}
+ end
+ end
+
+ # -- Helpers --
+
+ defp humanize_type("uptime_summary"), do: "Uptime Summary"
+ defp humanize_type("alert_history"), do: "Alert History"
+ defp humanize_type("capacity_trends"), do: "Capacity Trends"
+ defp humanize_type("rf_link_health"), do: "RF Link Health"
+ defp humanize_type(type), do: type
+
+ defp humanize_schedule(%{"type" => type}), do: String.capitalize(type)
+ defp humanize_schedule(_), do: "-"
+
+ defp format_recipients(recipients) when is_list(recipients), do: Enum.join(recipients, ", ")
+ defp format_recipients(_), do: "-"
+
+ defp status_badge("success"),
+ do: {"Success", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
+
+ defp status_badge("failed"),
+ do: {"Failed", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
+
+ defp status_badge("partial_failure"),
+ do: {"Partial", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"}
+
+ defp status_badge(_),
+ do: {"Never Run", "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"}
+end
diff --git a/lib/towerops_web/live/reports_live.html.heex b/lib/towerops_web/live/reports_live.html.heex
new file mode 100644
index 00000000..d9eff2dd
--- /dev/null
+++ b/lib/towerops_web/live/reports_live.html.heex
@@ -0,0 +1,228 @@
+
+ <.breadcrumb items={[
+ %{label: "Dashboard", navigate: ~p"/dashboard"},
+ %{label: "Reports"}
+ ]} />
+
+
+
+
{t("Scheduled Reports")}
+
+ {t("Automated report generation and email delivery")}
+
+
+
+
+
+ <%!-- Create Report Form --%>
+ <%= if @show_form do %>
+
+
+
{t("Create Report")}
+
+
+
+ <.form for={@form} phx-submit="save" class="space-y-4">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+ <%!-- Reports List --%>
+ <%= if @reports != [] do %>
+
+
+
+
+
+ | {t("Name")} |
+ {t("Type")} |
+ {t("Schedule")} |
+ {t("Recipients")} |
+ {t("Last Run")} |
+ {t("Status")} |
+ {t("Actions")} |
+
+
+
+ <%= for report <- @reports do %>
+
+ |
+ {report.name}
+ |
+
+ {humanize_type(report.report_type)}
+ |
+
+ {humanize_schedule(report.schedule)}
+ |
+
+ {format_recipients(report.recipients)}
+ |
+
+ <%= if report.last_run_at do %>
+ {Calendar.strftime(report.last_run_at, "%b %d, %H:%M")}
+ <% else %>
+ -
+ <% end %>
+ |
+
+ <% {label, classes} = status_badge(report.last_run_status) %>
+
+ {label}
+
+ |
+
+
+
+
+
+
+ |
+
+ <% end %>
+
+
+
+
+ <% else %>
+
+ <.icon name="hero-document-chart-bar" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
+
{t("No scheduled reports")}
+
+ {t("Create a report to start receiving automated updates via email.")}
+
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/rf_link_health_live.ex b/lib/towerops_web/live/rf_link_health_live.ex
new file mode 100644
index 00000000..46011e0c
--- /dev/null
+++ b/lib/towerops_web/live/rf_link_health_live.ex
@@ -0,0 +1,203 @@
+defmodule ToweropsWeb.RfLinkHealthLive do
+ @moduledoc """
+ RF Link Health Dashboard.
+
+ Displays wireless client link quality for NOC triage:
+ - Summary stat cards (total, healthy, degraded, critical)
+ - Weakest links table sorted by signal strength
+ - Expandable detail rows with signal bars, SNR, capacity, sparklines
+ - Real-time updates via PubSub
+ """
+ use ToweropsWeb, :live_view
+
+ alias Towerops.RfLinks
+
+ @impl true
+ def mount(_params, _session, socket) do
+ organization = socket.assigns.current_scope.organization
+
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "wireless_clients:org:#{organization.id}")
+ end
+
+ summary = RfLinks.get_summary(organization.id)
+ links = RfLinks.list_rf_links(organization.id)
+
+ {:ok,
+ socket
+ |> assign(:page_title, "RF Links")
+ |> assign(:organization_id, organization.id)
+ |> assign(:summary, summary)
+ |> assign(:links, links)
+ |> assign(:filter, :all)
+ |> assign(:expanded_id, nil)
+ |> assign(:sparkline_data, %{})}
+ end
+
+ @impl true
+ def handle_params(params, _uri, socket) do
+ filter =
+ case params["filter"] do
+ "healthy" -> :healthy
+ "degraded" -> :degraded
+ "critical" -> :critical
+ _ -> :all
+ end
+
+ links = RfLinks.list_rf_links(socket.assigns.organization_id, filter: filter)
+
+ {:noreply, assign(socket, filter: filter, links: links)}
+ end
+
+ @impl true
+ def handle_event("filter", %{"filter" => filter}, socket) do
+ {:noreply, push_patch(socket, to: ~p"/rf-links?#{%{filter: filter}}")}
+ end
+
+ @impl true
+ def handle_event("toggle_expand", %{"id" => id}, socket) do
+ {expanded_id, sparkline_data} =
+ if socket.assigns.expanded_id == id do
+ {nil, socket.assigns.sparkline_data}
+ else
+ readings = RfLinks.get_readings_24h(id)
+ sparkline = build_sparkline_points(readings)
+ {id, Map.put(socket.assigns.sparkline_data, id, sparkline)}
+ end
+
+ {:noreply, assign(socket, expanded_id: expanded_id, sparkline_data: sparkline_data)}
+ end
+
+ @impl true
+ def handle_info({:wireless_client_updated, _payload}, socket) do
+ summary = RfLinks.get_summary(socket.assigns.organization_id)
+ links = RfLinks.list_rf_links(socket.assigns.organization_id, filter: socket.assigns.filter)
+ {:noreply, assign(socket, summary: summary, links: links)}
+ end
+
+ def handle_info(_, socket), do: {:noreply, socket}
+
+ # -- Helpers --
+
+ defp health_color(:healthy), do: "text-green-600 dark:text-green-400"
+ defp health_color(:degraded), do: "text-yellow-600 dark:text-yellow-400"
+ defp health_color(:critical), do: "text-red-600 dark:text-red-400"
+ defp health_color(_), do: "text-gray-400 dark:text-gray-500"
+
+ defp health_bg(:healthy), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
+ defp health_bg(:degraded), do: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"
+ defp health_bg(:critical), do: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"
+ defp health_bg(_), do: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
+
+ defp health_label(:healthy), do: "Healthy"
+ defp health_label(:degraded), do: "Degraded"
+ defp health_label(:critical), do: "Critical"
+ defp health_label(_), do: "Unknown"
+
+ defp signal_bar_width(nil), do: 0
+
+ defp signal_bar_width(signal) do
+ # Map signal from -100..-30 dBm to 0..100%
+ clamped = max(-100, min(-30, signal))
+ round((clamped + 100) / 70 * 100)
+ end
+
+ defp signal_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
+
+ defp signal_bar_color(signal) do
+ cond do
+ signal > -65 -> "bg-green-500"
+ signal > -75 -> "bg-yellow-500"
+ true -> "bg-red-500"
+ end
+ end
+
+ defp snr_bar_width(nil), do: 0
+
+ defp snr_bar_width(snr) do
+ # Map SNR from 0..40 dB to 0..100%
+ clamped = max(0, min(40, snr))
+ round(clamped / 40 * 100)
+ end
+
+ defp snr_bar_color(nil), do: "bg-gray-300 dark:bg-gray-600"
+
+ defp snr_bar_color(snr) do
+ cond do
+ snr > 25 -> "bg-green-500"
+ snr > 15 -> "bg-yellow-500"
+ true -> "bg-red-500"
+ end
+ end
+
+ defp format_signal(nil), do: "-"
+ defp format_signal(val), do: "#{val} dBm"
+
+ defp format_snr(nil), do: "-"
+ defp format_snr(val), do: "#{val} dB"
+
+ defp format_rate(nil), do: "-"
+
+ defp format_rate(rate) when rate >= 1000,
+ do: "#{Float.round(rate / 1000, 1)} Gbps"
+
+ defp format_rate(rate), do: "#{rate} Mbps"
+
+ defp format_distance(nil), do: "-"
+ defp format_distance(m) when m >= 1000, do: "#{Float.round(m / 1000, 1)} km"
+ defp format_distance(m), do: "#{m} m"
+
+ defp format_uptime(nil), do: "-"
+
+ defp format_uptime(seconds) do
+ days = div(seconds, 86400)
+ hours = div(rem(seconds, 86400), 3600)
+
+ cond do
+ days > 0 -> "#{days}d #{hours}h"
+ hours > 0 -> "#{hours}h"
+ true -> "< 1h"
+ end
+ end
+
+ defp device_name(%{device: %{name: name}}) when not is_nil(name), do: name
+ defp device_name(%{device: %{ip_address: ip}}) when not is_nil(ip), do: ip
+ defp device_name(_), do: "-"
+
+ defp link_name(link) do
+ link.hostname || link.ip_address || link.mac_address || "-"
+ end
+
+ defp stat_card_class(:all, _), do: "ring-2 ring-blue-500"
+ defp stat_card_class(filter, filter), do: "ring-2 ring-blue-500"
+ defp stat_card_class(_, _), do: ""
+
+ defp build_sparkline_points(readings) when length(readings) < 2, do: nil
+
+ defp build_sparkline_points(readings) do
+ signals = Enum.map(readings, & &1.signal_strength) |> Enum.reject(&is_nil/1)
+
+ if signals == [] do
+ nil
+ else
+ min_val = Enum.min(signals) - 5
+ max_val = Enum.max(signals) + 5
+ range = max(max_val - min_val, 1)
+
+ width = 200
+ height = 40
+
+ points =
+ signals
+ |> Enum.with_index()
+ |> Enum.map(fn {val, i} ->
+ x = round(i / max(length(signals) - 1, 1) * width)
+ y = round((1 - (val - min_val) / range) * height)
+ "#{x},#{y}"
+ end)
+ |> Enum.join(" ")
+
+ %{points: points, width: width, height: height}
+ end
+ end
+end
diff --git a/lib/towerops_web/live/rf_link_health_live.html.heex b/lib/towerops_web/live/rf_link_health_live.html.heex
new file mode 100644
index 00000000..b1112376
--- /dev/null
+++ b/lib/towerops_web/live/rf_link_health_live.html.heex
@@ -0,0 +1,299 @@
+
+ <.breadcrumb items={[
+ %{label: "Dashboard", navigate: ~p"/dashboard"},
+ %{label: "RF Links"}
+ ]} />
+
+
+
{t("RF Link Health")}
+
+ {t("Wireless link quality triage — weakest links first")}
+
+
+
+ <%!-- Summary Stat Cards --%>
+
+
+
+
+
+
+
+
+
+
+ <%!-- Weakest Links Table --%>
+ <%= if @links != [] do %>
+
+
+
+ {t("RF Links")}
+
+ ({length(@links)})
+
+
+
+
+
+
+
+ | {t("Client")} |
+ {t("AP / Device")} |
+ {t("Signal")} |
+ {t("SNR")} |
+ {t("TX / RX")} |
+ {t("Health")} |
+ {t("Last Seen")} |
+
+
+
+ <%= for link <- @links do %>
+ <%!-- Main row --%>
+
+ |
+
+ {link_name(link)}
+
+
+ {link.mac_address}
+
+ |
+
+ <%= if link.device do %>
+ <.link
+ navigate={~p"/devices/#{link.device_id}"}
+ class="text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
+ >
+ {device_name(link)}
+
+ <% else %>
+ -
+ <% end %>
+ |
+
+
+
+
+ {format_signal(link.signal_strength)}
+
+
+ |
+
+
+
+
+ {format_snr(link.snr)}
+
+
+ |
+
+ {format_rate(link.tx_rate)}
+ /
+ {format_rate(link.rx_rate)}
+ |
+
+
+ {health_label(link.health)}
+
+ |
+
+ <%= if link.last_seen_at do %>
+
+ <% else %>
+ -
+ <% end %>
+ |
+
+
+ <%!-- Expandable detail row --%>
+ <%= if @expanded_id == link.id do %>
+
+
+
+ <%!-- Signal Strength Detail --%>
+
+
+ {t("Signal Strength")}
+
+
+
+
+ {format_signal(link.signal_strength)}
+
+
+
+ -100 dBm
+ -75
+ -65
+ -30 dBm
+
+
+
+ <%!-- SNR Detail --%>
+
+
+ {t("Signal-to-Noise Ratio")}
+
+
+
+
+ {format_snr(link.snr)}
+
+
+
+ 0 dB
+ 15
+ 25
+ 40 dB
+
+
+
+ <%!-- Additional Metrics --%>
+
+
+ {t("Link Details")}
+
+
+
+ - {t("Distance")}
+ - {format_distance(link.distance)}
+
+
+ - {t("TX Rate")}
+ - {format_rate(link.tx_rate)}
+
+
+ - {t("RX Rate")}
+ - {format_rate(link.rx_rate)}
+
+
+ - {t("Uptime")}
+ - {format_uptime(link.uptime_seconds)}
+
+ <%= if link.capacity_utilization do %>
+
+ - {t("Capacity")}
+ - {link.capacity_utilization}%
+
+ <% end %>
+
+
+
+
+ <%!-- 24h Sparkline --%>
+ <%= if sparkline = @sparkline_data[link.id] do %>
+
+
+ {t("Signal Strength — Last 24 Hours")}
+
+
+
+ <% end %>
+ |
+
+ <% end %>
+ <% end %>
+
+
+
+
+ <% else %>
+
+ <.icon name="hero-signal" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
+
{t("No RF links found")}
+
+ {t("RF links will appear here when wireless clients are discovered via SNMP polling.")}
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/status_page_live.ex b/lib/towerops_web/live/status_page_live.ex
new file mode 100644
index 00000000..18e934a4
--- /dev/null
+++ b/lib/towerops_web/live/status_page_live.ex
@@ -0,0 +1,101 @@
+defmodule ToweropsWeb.StatusPageLive do
+ @moduledoc """
+ Public unauthenticated status page.
+
+ Renders at /status/:slug with a standalone layout (no sidebar).
+ Mobile-responsive for customers checking during outages.
+ """
+ use ToweropsWeb, :live_view
+
+ alias Towerops.StatusPages
+
+ @impl true
+ def mount(%{"slug" => slug}, _session, socket) do
+ case StatusPages.get_config_by_slug(slug) do
+ nil ->
+ {:ok,
+ socket
+ |> put_flash(:error, "Status page not found")
+ |> assign(:not_found, true)
+ |> assign(:page_title, "Not Found")}
+
+ config ->
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "status_page:#{config.id}")
+ end
+
+ components = StatusPages.list_components(config.id)
+ active_incidents = StatusPages.list_active_incidents(config.id)
+ recent_incidents = StatusPages.list_incidents(config.id, limit: 10)
+ overall = StatusPages.overall_status(components)
+
+ {:ok,
+ socket
+ |> assign(:config, config)
+ |> assign(:components, components)
+ |> assign(:active_incidents, active_incidents)
+ |> assign(:recent_incidents, recent_incidents)
+ |> assign(:overall_status, overall)
+ |> assign(:not_found, false)
+ |> assign(:page_title, "#{config.company_name || "Status"} — System Status"),
+ layout: false}
+ end
+ end
+
+ @impl true
+ def handle_info({:status_updated, _}, socket) do
+ config = socket.assigns.config
+ components = StatusPages.list_components(config.id)
+ active_incidents = StatusPages.list_active_incidents(config.id)
+ overall = StatusPages.overall_status(components)
+
+ {:noreply,
+ assign(socket,
+ components: components,
+ active_incidents: active_incidents,
+ overall_status: overall
+ )}
+ end
+
+ def handle_info(_, socket), do: {:noreply, socket}
+
+ # -- Helpers --
+
+ defp overall_color(:operational), do: "bg-green-500"
+ defp overall_color(:degraded), do: "bg-yellow-500"
+ defp overall_color(:partial_outage), do: "bg-orange-500"
+ defp overall_color(:major_outage), do: "bg-red-500"
+ defp overall_color(:maintenance), do: "bg-blue-500"
+ defp overall_color(_), do: "bg-gray-500"
+
+ defp overall_text(:operational), do: "All Systems Operational"
+ defp overall_text(:degraded), do: "Degraded Performance"
+ defp overall_text(:partial_outage), do: "Partial System Outage"
+ defp overall_text(:major_outage), do: "Major System Outage"
+ defp overall_text(:maintenance), do: "Scheduled Maintenance"
+ defp overall_text(_), do: "Unknown"
+
+ defp component_color("operational"), do: "bg-green-500"
+ defp component_color("degraded"), do: "bg-yellow-500"
+ defp component_color("partial_outage"), do: "bg-orange-500"
+ defp component_color("major_outage"), do: "bg-red-500"
+ defp component_color("maintenance"), do: "bg-blue-500"
+ defp component_color(_), do: "bg-gray-400"
+
+ defp component_label("operational"), do: "Operational"
+ defp component_label("degraded"), do: "Degraded"
+ defp component_label("partial_outage"), do: "Partial Outage"
+ defp component_label("major_outage"), do: "Major Outage"
+ defp component_label("maintenance"), do: "Maintenance"
+ defp component_label(s), do: s
+
+ defp severity_color("critical"), do: "text-red-600"
+ defp severity_color("major"), do: "text-orange-600"
+ defp severity_color(_), do: "text-yellow-600"
+
+ defp incident_status_label("investigating"), do: "Investigating"
+ defp incident_status_label("identified"), do: "Identified"
+ defp incident_status_label("monitoring"), do: "Monitoring"
+ defp incident_status_label("resolved"), do: "Resolved"
+ defp incident_status_label(s), do: s
+end
diff --git a/lib/towerops_web/live/status_page_live.html.heex b/lib/towerops_web/live/status_page_live.html.heex
new file mode 100644
index 00000000..952fe7f5
--- /dev/null
+++ b/lib/towerops_web/live/status_page_live.html.heex
@@ -0,0 +1,146 @@
+<%= if @not_found do %>
+
+
+
+
+
Status Page Not Found
+
+
+
+
Status Page Not Found
+
The requested status page does not exist or is not enabled.
+
+
+
+<% else %>
+
+
+
+
+
{@page_title}
+
+
+ <%= if @config.custom_css do %>
+
+ <% end %>
+
+
+
+ <%!-- Header --%>
+
+ <%= if @config.logo_url do %>
+
+ <% end %>
+
+ {@config.company_name || "System Status"}
+
+
+
+ <%!-- Overall Status Banner --%>
+
+
{overall_text(@overall_status)}
+
+ Last updated: {Calendar.strftime(DateTime.utc_now(), "%B %d, %Y at %H:%M UTC")}
+
+
+
+ <%!-- Active Incidents --%>
+ <%= if @active_incidents != [] do %>
+
+
Active Incidents
+
+ <%= for incident <- @active_incidents do %>
+
+
+
+
+ {incident.title}
+
+ <%= if incident.body do %>
+
{incident.body}
+ <% end %>
+
+
+ {incident_status_label(incident.status)}
+
+
+
+ Started: {Calendar.strftime(incident.started_at, "%b %d, %H:%M UTC")}
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%!-- Components --%>
+
+
Components
+
+ <%= if @components == [] do %>
+
+ No components configured
+
+ <% else %>
+ <%= for component <- @components do %>
+
+
+
{component.name}
+ <%= if component.description do %>
+
{component.description}
+ <% end %>
+
+
+
+
+ {component_label(component.status)}
+
+
+
+ <% end %>
+ <% end %>
+
+
+
+ <%!-- Recent Incidents --%>
+
+
Recent Incidents
+ <%= if @recent_incidents == [] do %>
+
+ No recent incidents — looking good!
+
+ <% else %>
+
+ <%= for incident <- @recent_incidents do %>
+
+
+
{incident.title}
+ if(incident.status == "resolved", do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300", else: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300")}>
+ {incident_status_label(incident.status)}
+
+
+
+ {Calendar.strftime(incident.started_at, "%b %d, %Y")}
+ <%= if incident.resolved_at do %>
+ — Resolved {Calendar.strftime(incident.resolved_at, "%b %d, %H:%M UTC")}
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%!-- Footer --%>
+
+
+
+
+<% end %>
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 24bc6377..b0fb4e39 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -85,6 +85,9 @@ defmodule ToweropsWeb.Router do
get "/docs/graphql", GraphQLDocsController, :index
get "/invitations/:token", InvitationController, :show
live "/help", HelpLive.Index, :index
+
+ # Public status page (unauthenticated)
+ live "/status/:slug", StatusPageLive, :index
end
# Account API routes (requires browser authentication)
@@ -448,6 +451,12 @@ defmodule ToweropsWeb.Router do
# Insights route
live "/insights", InsightsLive.Index, :index
+ # RF Link Health
+ live "/rf-links", RfLinkHealthLive, :index
+
+ # Reports
+ live "/reports", ReportsLive, :index
+
# Subscriber trace
live "/trace", TraceLive.Index, :index
diff --git a/priv/repo/migrations/20260315120000_create_uisp_sync_logs.exs b/priv/repo/migrations/20260315120000_create_uisp_sync_logs.exs
new file mode 100644
index 00000000..e97d2d67
--- /dev/null
+++ b/priv/repo/migrations/20260315120000_create_uisp_sync_logs.exs
@@ -0,0 +1,26 @@
+defmodule Towerops.Repo.Migrations.CreateUispSyncLogs do
+ use Ecto.Migration
+
+ def change do
+ create table(:uisp_sync_logs, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :integration_id, references(:integrations, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :status, :string, null: false
+ add :records_synced, :integer, default: 0
+ add :errors, :map
+ add :duration_ms, :integer
+
+ add :inserted_at, :utc_datetime, null: false
+ end
+
+ create index(:uisp_sync_logs, [:organization_id])
+ create index(:uisp_sync_logs, [:integration_id])
+ create index(:uisp_sync_logs, [:inserted_at])
+ end
+end
diff --git a/priv/repo/migrations/20260315150000_create_reports.exs b/priv/repo/migrations/20260315150000_create_reports.exs
new file mode 100644
index 00000000..d7494844
--- /dev/null
+++ b/priv/repo/migrations/20260315150000_create_reports.exs
@@ -0,0 +1,29 @@
+defmodule Towerops.Repo.Migrations.CreateReports do
+ use Ecto.Migration
+
+ def change do
+ create table(:reports, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :report_type, :string, null: false
+ add :scope, :map, null: false, default: %{}
+ add :schedule, :map, null: false, default: %{}
+ add :recipients, {:array, :string}, null: false, default: []
+ add :format, :string, null: false, default: "csv"
+ add :enabled, :boolean, null: false, default: true
+ add :last_run_at, :utc_datetime
+ add :last_run_status, :string
+
+ add :organization_id, references(:organizations, on_delete: :delete_all, type: :uuid),
+ null: false
+
+ add :created_by_id, references(:users, on_delete: :nilify_all, type: :uuid)
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:reports, [:organization_id])
+ create index(:reports, [:organization_id, :enabled])
+ create index(:reports, [:report_type])
+ end
+end
diff --git a/priv/repo/migrations/20260315160000_create_uisp_config_snapshots.exs b/priv/repo/migrations/20260315160000_create_uisp_config_snapshots.exs
new file mode 100644
index 00000000..4d4737fc
--- /dev/null
+++ b/priv/repo/migrations/20260315160000_create_uisp_config_snapshots.exs
@@ -0,0 +1,22 @@
+defmodule Towerops.Repo.Migrations.CreateUispConfigSnapshots do
+ use Ecto.Migration
+
+ def change do
+ create table(:uisp_config_snapshots, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :config_compressed, :binary, null: false
+ add :config_hash, :string, null: false
+ add :config_size_bytes, :integer
+ add :compressed_size_bytes, :integer
+ add :source, :string, default: "uisp"
+ add :snapshot_at, :utc_datetime, null: false
+
+ add :device_id, references(:devices, on_delete: :delete_all, type: :uuid), null: false
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ create index(:uisp_config_snapshots, [:device_id, :snapshot_at])
+ create index(:uisp_config_snapshots, [:device_id, :config_hash])
+ end
+end
diff --git a/priv/repo/migrations/20260315170000_create_status_pages.exs b/priv/repo/migrations/20260315170000_create_status_pages.exs
new file mode 100644
index 00000000..d6a48272
--- /dev/null
+++ b/priv/repo/migrations/20260315170000_create_status_pages.exs
@@ -0,0 +1,59 @@
+defmodule Towerops.Repo.Migrations.CreateStatusPages do
+ use Ecto.Migration
+
+ def change do
+ create table(:status_page_configs, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :enabled, :boolean, null: false, default: false
+ add :slug, :string, null: false
+ add :company_name, :string
+ add :logo_url, :string
+ add :support_email, :string
+ add :custom_css, :text
+ add :auto_incidents, :boolean, null: false, default: true
+
+ add :organization_id, references(:organizations, on_delete: :delete_all, type: :uuid),
+ null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create unique_index(:status_page_configs, [:slug])
+ create unique_index(:status_page_configs, [:organization_id])
+
+ create table(:status_page_components, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :description, :string
+ add :position, :integer, null: false, default: 0
+ add :status, :string, null: false, default: "operational"
+ add :device_group, :map, default: %{}
+
+ add :status_page_config_id,
+ references(:status_page_configs, on_delete: :delete_all, type: :uuid),
+ null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:status_page_components, [:status_page_config_id, :position])
+
+ create table(:status_incidents, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :title, :string, null: false
+ add :body, :text
+ add :severity, :string, null: false, default: "minor"
+ add :status, :string, null: false, default: "investigating"
+ add :started_at, :utc_datetime, null: false
+ add :resolved_at, :utc_datetime
+
+ add :status_page_config_id,
+ references(:status_page_configs, on_delete: :delete_all, type: :uuid),
+ null: false
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:status_incidents, [:status_page_config_id, :started_at])
+ end
+end
diff --git a/test/towerops/cn_maestro/client_test.exs b/test/towerops/cn_maestro/client_test.exs
new file mode 100644
index 00000000..7e3ce597
--- /dev/null
+++ b/test/towerops/cn_maestro/client_test.exs
@@ -0,0 +1,96 @@
+defmodule Towerops.CnMaestro.ClientTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.CnMaestro.Client
+
+ setup do
+ Req.Test.stub(Client, fn conn ->
+ case {conn.method, conn.request_path} do
+ {"POST", "/api/v1/access/token"} ->
+ Req.Test.json(conn, %{"access_token" => "test-token-123", "token_type" => "bearer"})
+
+ {"GET", "/api/v1/devices"} ->
+ case Plug.Conn.get_req_header(conn, "authorization") do
+ ["Bearer test-token-123"] ->
+ Req.Test.json(conn, %{
+ "data" => [
+ %{"mac" => "AA:BB:CC:DD:EE:01", "name" => "AP-Tower1", "ip" => "10.0.0.1", "network" => "Tower1"},
+ %{"mac" => "AA:BB:CC:DD:EE:02", "name" => "AP-Tower2", "ip" => "10.0.0.2", "network" => "Tower2"}
+ ]
+ })
+
+ _ ->
+ conn |> Plug.Conn.send_resp(401, "Unauthorized")
+ end
+
+ {"GET", "/api/v1/networks"} ->
+ Req.Test.json(conn, %{
+ "data" => [
+ %{"id" => "net-1", "name" => "Tower1"},
+ %{"id" => "net-2", "name" => "Tower2"}
+ ]
+ })
+
+ {"GET", "/api/v1/devices/AA:BB:CC:DD:EE:01/statistics"} ->
+ Req.Test.json(conn, %{
+ "data" => [%{"radio" => %{"tx_bytes" => 1000, "rx_bytes" => 2000}}]
+ })
+
+ {"GET", "/api/v1/devices/AA:BB:CC:DD:EE:01/performance"} ->
+ Req.Test.json(conn, %{
+ "data" => [%{"throughput" => 50.5, "latency" => 12}]
+ })
+
+ _ ->
+ conn |> Plug.Conn.send_resp(404, "Not Found")
+ end
+ end)
+
+ :ok
+ end
+
+ describe "get_token/3" do
+ test "returns token on success" do
+ assert {:ok, "test-token-123"} = Client.get_token("http://test", "id", "secret")
+ end
+ end
+
+ describe "test_connection/3" do
+ test "returns :connected when auth and device fetch succeed" do
+ assert {:ok, :connected} = Client.test_connection("http://test", "id", "secret")
+ end
+ end
+
+ describe "list_devices/2" do
+ test "returns list of devices" do
+ {:ok, token} = Client.get_token("http://test", "id", "secret")
+ assert {:ok, devices} = Client.list_devices("http://test", token)
+ assert length(devices) == 2
+ assert hd(devices)["mac"] == "AA:BB:CC:DD:EE:01"
+ end
+ end
+
+ describe "list_networks/2" do
+ test "returns list of networks" do
+ {:ok, token} = Client.get_token("http://test", "id", "secret")
+ assert {:ok, networks} = Client.list_networks("http://test", token)
+ assert length(networks) == 2
+ end
+ end
+
+ describe "get_device_statistics/3" do
+ test "returns stats for a device" do
+ {:ok, token} = Client.get_token("http://test", "id", "secret")
+ assert {:ok, stats} = Client.get_device_statistics("http://test", token, "AA:BB:CC:DD:EE:01")
+ assert length(stats) == 1
+ end
+ end
+
+ describe "get_device_performance/3" do
+ test "returns performance data for a device" do
+ {:ok, token} = Client.get_token("http://test", "id", "secret")
+ assert {:ok, perf} = Client.get_device_performance("http://test", token, "AA:BB:CC:DD:EE:01")
+ assert length(perf) == 1
+ end
+ end
+end
diff --git a/test/towerops/reports_test.exs b/test/towerops/reports_test.exs
new file mode 100644
index 00000000..86cb4f31
--- /dev/null
+++ b/test/towerops/reports_test.exs
@@ -0,0 +1,167 @@
+defmodule Towerops.ReportsTest do
+ use Towerops.DataCase, async: false
+
+ alias Towerops.Reports
+ alias Towerops.Reports.Report
+
+ describe "CRUD" do
+ test "create_report/1 with valid attrs" do
+ org = insert_org()
+
+ assert {:ok, report} =
+ Reports.create_report(%{
+ name: "Weekly Uptime",
+ report_type: "uptime_summary",
+ schedule: %{"type" => "weekly"},
+ recipients: ["admin@example.com"],
+ organization_id: org.id
+ })
+
+ assert report.name == "Weekly Uptime"
+ assert report.report_type == "uptime_summary"
+ assert report.enabled == true
+ end
+
+ test "create_report/1 rejects invalid report_type" do
+ org = insert_org()
+
+ assert {:error, changeset} =
+ Reports.create_report(%{
+ name: "Bad Report",
+ report_type: "invalid_type",
+ schedule: %{"type" => "weekly"},
+ recipients: ["admin@example.com"],
+ organization_id: org.id
+ })
+
+ assert errors_on(changeset).report_type
+ end
+
+ test "create_report/1 rejects empty recipients" do
+ org = insert_org()
+
+ assert {:error, changeset} =
+ Reports.create_report(%{
+ name: "No Recipients",
+ report_type: "uptime_summary",
+ schedule: %{"type" => "weekly"},
+ recipients: [],
+ organization_id: org.id
+ })
+
+ assert errors_on(changeset).recipients
+ end
+
+ test "list_reports/1 returns reports for org" do
+ org = insert_org()
+
+ {:ok, _} =
+ Reports.create_report(%{
+ name: "Report 1",
+ report_type: "uptime_summary",
+ schedule: %{"type" => "daily"},
+ recipients: ["a@b.com"],
+ organization_id: org.id
+ })
+
+ reports = Reports.list_reports(org.id)
+ assert length(reports) == 1
+ end
+
+ test "toggle_report/1 flips enabled" do
+ org = insert_org()
+
+ {:ok, report} =
+ Reports.create_report(%{
+ name: "Toggle Me",
+ report_type: "alert_history",
+ schedule: %{"type" => "weekly"},
+ recipients: ["a@b.com"],
+ organization_id: org.id
+ })
+
+ assert report.enabled == true
+ {:ok, toggled} = Reports.toggle_report(report)
+ assert toggled.enabled == false
+ end
+
+ test "delete_report/1 removes report" do
+ org = insert_org()
+
+ {:ok, report} =
+ Reports.create_report(%{
+ name: "Delete Me",
+ report_type: "capacity_trends",
+ schedule: %{"type" => "monthly"},
+ recipients: ["a@b.com"],
+ organization_id: org.id
+ })
+
+ assert {:ok, _} = Reports.delete_report(report)
+ assert Reports.list_reports(org.id) == []
+ end
+ end
+
+ describe "due?/1" do
+ test "one_time report is due if never run" do
+ report = %Report{schedule: %{"type" => "one_time"}, last_run_at: nil}
+ assert Reports.due?(report)
+ end
+
+ test "one_time report is not due if already run" do
+ report = %Report{schedule: %{"type" => "one_time"}, last_run_at: DateTime.utc_now()}
+ refute Reports.due?(report)
+ end
+
+ test "daily report is due after 24h" do
+ old = DateTime.add(DateTime.utc_now(), -25, :hour)
+ report = %Report{schedule: %{"type" => "daily"}, last_run_at: old}
+ assert Reports.due?(report)
+ end
+
+ test "daily report is not due within 24h" do
+ recent = DateTime.add(DateTime.utc_now(), -1, :hour)
+ report = %Report{schedule: %{"type" => "daily"}, last_run_at: recent}
+ refute Reports.due?(report)
+ end
+
+ test "weekly report never run is due" do
+ report = %Report{schedule: %{"type" => "weekly"}, last_run_at: nil}
+ assert Reports.due?(report)
+ end
+ end
+
+ describe "mark_run/2" do
+ test "updates last_run_at and status" do
+ org = insert_org()
+
+ {:ok, report} =
+ Reports.create_report(%{
+ name: "Run Me",
+ report_type: "rf_link_health",
+ schedule: %{"type" => "weekly"},
+ recipients: ["a@b.com"],
+ organization_id: org.id
+ })
+
+ assert is_nil(report.last_run_at)
+ {:ok, updated} = Reports.mark_run(report, "success")
+ assert updated.last_run_status == "success"
+ assert not is_nil(updated.last_run_at)
+ end
+ end
+
+ # -- Helpers --
+
+ defp insert_org do
+ {:ok, org} =
+ %Towerops.Organizations.Organization{}
+ |> Towerops.Organizations.Organization.changeset(%{
+ name: "Test Org #{System.unique_integer([:positive])}",
+ slug: "test-org-#{System.unique_integer([:positive])}"
+ })
+ |> Towerops.Repo.insert()
+
+ org
+ end
+end
diff --git a/test/towerops/rf_links_test.exs b/test/towerops/rf_links_test.exs
new file mode 100644
index 00000000..60304a7b
--- /dev/null
+++ b/test/towerops/rf_links_test.exs
@@ -0,0 +1,127 @@
+defmodule Towerops.RfLinksTest do
+ use Towerops.DataCase, async: false
+
+ alias Towerops.RfLinks
+
+ describe "list_rf_links/2" do
+ test "returns empty list for org with no wireless clients", %{} do
+ org = insert_org()
+ assert RfLinks.list_rf_links(org.id) == []
+ end
+
+ test "returns enriched links sorted by signal strength ascending" do
+ org = insert_org()
+ device = insert_device(org)
+
+ _strong = insert_wireless_client(device, org, %{signal_strength: -55, snr: 30, mac_address: "AA:BB:CC:DD:EE:01"})
+ _weak = insert_wireless_client(device, org, %{signal_strength: -80, snr: 10, mac_address: "AA:BB:CC:DD:EE:02"})
+ _mid = insert_wireless_client(device, org, %{signal_strength: -70, snr: 20, mac_address: "AA:BB:CC:DD:EE:03"})
+
+ links = RfLinks.list_rf_links(org.id)
+ assert length(links) == 3
+ signals = Enum.map(links, & &1.signal_strength)
+ assert signals == [-80, -70, -55]
+ end
+
+ test "filters by health status" do
+ org = insert_org()
+ device = insert_device(org)
+
+ _healthy = insert_wireless_client(device, org, %{signal_strength: -55, snr: 30, mac_address: "AA:BB:CC:DD:EE:01"})
+ _critical = insert_wireless_client(device, org, %{signal_strength: -80, snr: 10, mac_address: "AA:BB:CC:DD:EE:02"})
+
+ healthy_links = RfLinks.list_rf_links(org.id, filter: :healthy)
+ assert length(healthy_links) == 1
+ assert hd(healthy_links).health == :healthy
+
+ critical_links = RfLinks.list_rf_links(org.id, filter: :critical)
+ assert length(critical_links) == 1
+ assert hd(critical_links).health == :critical
+ end
+ end
+
+ describe "get_summary/1" do
+ test "returns zero counts for empty org" do
+ org = insert_org()
+ summary = RfLinks.get_summary(org.id)
+
+ assert summary == %{total: 0, healthy: 0, degraded: 0, critical: 0}
+ end
+
+ test "classifies links correctly" do
+ org = insert_org()
+ device = insert_device(org)
+
+ # Healthy: signal > -65, SNR > 25
+ insert_wireless_client(device, org, %{signal_strength: -55, snr: 30, mac_address: "AA:BB:CC:DD:EE:01"})
+ # Degraded: signal > -75, SNR > 15
+ insert_wireless_client(device, org, %{signal_strength: -70, snr: 20, mac_address: "AA:BB:CC:DD:EE:02"})
+ # Critical: below thresholds
+ insert_wireless_client(device, org, %{signal_strength: -80, snr: 10, mac_address: "AA:BB:CC:DD:EE:03"})
+
+ summary = RfLinks.get_summary(org.id)
+
+ assert summary.total == 3
+ assert summary.healthy == 1
+ assert summary.degraded == 1
+ assert summary.critical == 1
+ end
+ end
+
+ describe "get_readings_24h/1" do
+ test "returns empty list for client with no readings" do
+ org = insert_org()
+ device = insert_device(org)
+ client = insert_wireless_client(device, org, %{signal_strength: -60, snr: 25, mac_address: "AA:BB:CC:DD:EE:01"})
+
+ readings = RfLinks.get_readings_24h(client.id)
+ assert readings == []
+ end
+ end
+
+ # -- Test helpers --
+
+ defp insert_org do
+ {:ok, org} =
+ %Towerops.Organizations.Organization{}
+ |> Towerops.Organizations.Organization.changeset(%{
+ name: "Test Org #{System.unique_integer([:positive])}",
+ slug: "test-org-#{System.unique_integer([:positive])}"
+ })
+ |> Towerops.Repo.insert()
+
+ org
+ end
+
+ defp insert_device(org) do
+ {:ok, device} =
+ %Towerops.Devices.Device{}
+ |> Towerops.Devices.Device.changeset(%{
+ organization_id: org.id,
+ name: "Test AP",
+ ip_address: "10.10.10.#{System.unique_integer([:positive]) |> rem(255)}",
+ snmp_enabled: true,
+ monitoring_enabled: true
+ })
+ |> Towerops.Repo.insert()
+
+ device
+ end
+
+ defp insert_wireless_client(device, org, attrs) do
+ base = %{
+ device_id: device.id,
+ organization_id: org.id,
+ last_seen_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ }
+
+ {:ok, client} =
+ %Towerops.Snmp.WirelessClient{}
+ |> Towerops.Snmp.WirelessClient.changeset(Map.merge(base, attrs))
+ |> Towerops.Repo.insert()
+
+ client
+ end
+
+
+end
diff --git a/test/towerops/status_pages_test.exs b/test/towerops/status_pages_test.exs
new file mode 100644
index 00000000..2368d631
--- /dev/null
+++ b/test/towerops/status_pages_test.exs
@@ -0,0 +1,142 @@
+defmodule Towerops.StatusPagesTest do
+ use Towerops.DataCase, async: false
+
+ alias Towerops.StatusPages
+
+ describe "config" do
+ test "create and fetch by slug" do
+ org = insert_org()
+
+ {:ok, config} =
+ StatusPages.create_config(%{
+ slug: "test-isp",
+ company_name: "Test ISP",
+ organization_id: org.id,
+ enabled: true
+ })
+
+ assert config.slug == "test-isp"
+ assert config.company_name == "Test ISP"
+
+ found = StatusPages.get_config_by_slug("test-isp")
+ assert found.id == config.id
+ end
+
+ test "disabled config not returned by slug" do
+ org = insert_org()
+
+ {:ok, _} =
+ StatusPages.create_config(%{
+ slug: "disabled-isp",
+ organization_id: org.id,
+ enabled: false
+ })
+
+ assert StatusPages.get_config_by_slug("disabled-isp") == nil
+ end
+
+ test "slug uniqueness enforced" do
+ org1 = insert_org()
+ org2 = insert_org()
+
+ {:ok, _} =
+ StatusPages.create_config(%{slug: "unique-slug", organization_id: org1.id})
+
+ {:error, changeset} =
+ StatusPages.create_config(%{slug: "unique-slug", organization_id: org2.id})
+
+ assert errors_on(changeset).slug
+ end
+ end
+
+ describe "components" do
+ test "CRUD operations" do
+ org = insert_org()
+ {:ok, config} = StatusPages.create_config(%{slug: "comp-test", organization_id: org.id})
+
+ {:ok, component} =
+ StatusPages.create_component(%{
+ name: "Internet Service",
+ status: "operational",
+ status_page_config_id: config.id
+ })
+
+ assert component.name == "Internet Service"
+
+ {:ok, updated} = StatusPages.update_component(component, %{status: "degraded"})
+ assert updated.status == "degraded"
+
+ components = StatusPages.list_components(config.id)
+ assert length(components) == 1
+
+ {:ok, _} = StatusPages.delete_component(component)
+ assert StatusPages.list_components(config.id) == []
+ end
+ end
+
+ describe "incidents" do
+ test "create and list" do
+ org = insert_org()
+ {:ok, config} = StatusPages.create_config(%{slug: "inc-test", organization_id: org.id})
+
+ {:ok, incident} =
+ StatusPages.create_incident(%{
+ title: "Network Outage",
+ severity: "major",
+ status: "investigating",
+ started_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ status_page_config_id: config.id
+ })
+
+ assert incident.title == "Network Outage"
+
+ active = StatusPages.list_active_incidents(config.id)
+ assert length(active) == 1
+
+ {:ok, resolved} = StatusPages.resolve_incident(incident)
+ assert resolved.status == "resolved"
+ assert not is_nil(resolved.resolved_at)
+
+ active_after = StatusPages.list_active_incidents(config.id)
+ assert active_after == []
+ end
+ end
+
+ describe "overall_status/1" do
+ test "operational when all components operational" do
+ components = [
+ %{status: "operational"},
+ %{status: "operational"}
+ ]
+ assert StatusPages.overall_status(components) == :operational
+ end
+
+ test "major_outage takes priority" do
+ components = [
+ %{status: "operational"},
+ %{status: "major_outage"}
+ ]
+ assert StatusPages.overall_status(components) == :major_outage
+ end
+
+ test "degraded when no outages" do
+ components = [
+ %{status: "operational"},
+ %{status: "degraded"}
+ ]
+ assert StatusPages.overall_status(components) == :degraded
+ end
+ end
+
+ defp insert_org do
+ {:ok, org} =
+ %Towerops.Organizations.Organization{}
+ |> Towerops.Organizations.Organization.changeset(%{
+ name: "Test Org #{System.unique_integer([:positive])}",
+ slug: "test-org-#{System.unique_integer([:positive])}"
+ })
+ |> Towerops.Repo.insert()
+
+ org
+ end
+end
diff --git a/test/towerops/uisp/client_test.exs b/test/towerops/uisp/client_test.exs
new file mode 100644
index 00000000..8c36b168
--- /dev/null
+++ b/test/towerops/uisp/client_test.exs
@@ -0,0 +1,155 @@
+defmodule Towerops.Uisp.ClientTest do
+ use ExUnit.Case, async: true
+
+ alias Towerops.Uisp.Client
+
+ @base_url "https://uisp.example.com/nms/api/v2.1"
+ @api_key "test-api-key"
+
+ describe "test_connection/2" do
+ test "returns ok when API responds with 200" do
+ Req.Test.stub(Client, fn conn ->
+ assert conn.request_path == "/nms/api/v2.1/sites"
+ assert conn.query_string =~ "count=1"
+ assert Plug.Conn.get_req_header(conn, "x-auth-token") == [@api_key]
+
+ Req.Test.json(conn, [%{"id" => "site-1", "identification" => %{"name" => "Tower 1"}}])
+ end)
+
+ assert {:ok, _body} = Client.test_connection(@base_url, @api_key)
+ end
+
+ test "returns error for 401 unauthorized" do
+ Req.Test.stub(Client, fn conn ->
+ conn
+ |> Plug.Conn.put_status(401)
+ |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = Client.test_connection(@base_url, "bad-key")
+ end
+
+ test "returns error for 403 forbidden" do
+ Req.Test.stub(Client, fn conn ->
+ conn
+ |> Plug.Conn.put_status(403)
+ |> Req.Test.json(%{"error" => "forbidden"})
+ end)
+
+ assert {:error, :forbidden} = Client.test_connection(@base_url, "restricted-key")
+ end
+
+ test "returns unexpected_status for other error codes" do
+ Req.Test.stub(Client, fn conn ->
+ conn
+ |> Plug.Conn.put_status(500)
+ |> Req.Test.json(%{"error" => "internal server error"})
+ end)
+
+ assert {:error, {:unexpected_status, 500}} = Client.test_connection(@base_url, @api_key)
+ end
+ end
+
+ describe "list_sites/2" do
+ test "returns list of sites on success" do
+ Req.Test.stub(Client, fn conn ->
+ assert conn.request_path == "/nms/api/v2.1/sites"
+
+ Req.Test.json(conn, [
+ %{
+ "id" => "site-1",
+ "identification" => %{"id" => "site-1", "name" => "Tower A"},
+ "description" => %{"gps" => %{"latitude" => 40.7128, "longitude" => -74.006}}
+ },
+ %{
+ "id" => "site-2",
+ "identification" => %{"id" => "site-2", "name" => "Tower B"},
+ "description" => %{"gps" => %{"latitude" => 34.0522, "longitude" => -118.2437}}
+ }
+ ])
+ end)
+
+ assert {:ok, [site1, site2]} = Client.list_sites(@base_url, @api_key)
+ assert get_in(site1, ["identification", "name"]) == "Tower A"
+ assert get_in(site2, ["identification", "name"]) == "Tower B"
+ end
+
+ test "returns empty list for empty response" do
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [])
+ end)
+
+ assert {:ok, []} = Client.list_sites(@base_url, @api_key)
+ end
+
+ test "returns error for 401" do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = Client.list_sites(@base_url, "bad-key")
+ end
+
+ test "returns error for 403" do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{"error" => "forbidden"})
+ end)
+
+ assert {:error, :forbidden} = Client.list_sites(@base_url, "bad-key")
+ end
+ end
+
+ describe "list_devices/2" do
+ test "returns list of devices on success" do
+ Req.Test.stub(Client, fn conn ->
+ assert conn.request_path == "/nms/api/v2.1/devices"
+
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{
+ "id" => "dev-1",
+ "name" => "AP-Tower-A",
+ "mac" => "AA:BB:CC:DD:EE:FF",
+ "type" => "airMax"
+ },
+ "ipAddress" => "10.0.0.1",
+ "overview" => %{"status" => "active"},
+ "site" => %{"id" => "site-1"}
+ },
+ %{
+ "identification" => %{
+ "id" => "dev-2",
+ "name" => "AP-Tower-B",
+ "mac" => "11:22:33:44:55:66",
+ "type" => "airMax"
+ },
+ "ipAddress" => "10.0.0.2",
+ "overview" => %{"status" => "active"},
+ "site" => %{"id" => "site-2"}
+ }
+ ])
+ end)
+
+ assert {:ok, [dev1, dev2]} = Client.list_devices(@base_url, @api_key)
+ assert get_in(dev1, ["identification", "name"]) == "AP-Tower-A"
+ assert dev1["ipAddress"] == "10.0.0.1"
+ assert get_in(dev2, ["identification", "name"]) == "AP-Tower-B"
+ end
+
+ test "returns error for 401" do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = Client.list_devices(@base_url, "bad-key")
+ end
+
+ test "normalizes non-list response to empty list" do
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, %{"unexpected" => "format"})
+ end)
+
+ assert {:ok, []} = Client.list_devices(@base_url, @api_key)
+ end
+ end
+end
diff --git a/test/towerops/uisp/device_sync_test.exs b/test/towerops/uisp/device_sync_test.exs
new file mode 100644
index 00000000..d9da3cf4
--- /dev/null
+++ b/test/towerops/uisp/device_sync_test.exs
@@ -0,0 +1,158 @@
+defmodule Towerops.Uisp.DeviceSyncTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Devices.Device
+ alias Towerops.Integrations
+ alias Towerops.Repo
+ alias Towerops.Sites.Site
+ alias Towerops.Uisp.Client
+ alias Towerops.Uisp.DeviceSync
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+
+ {:ok, site} =
+ %Site{}
+ |> Site.changeset(%{organization_id: org.id, name: "Tower Alpha"})
+ |> Repo.insert()
+
+ {:ok, integration} =
+ Integrations.create_integration(org.id, %{
+ provider: "uisp",
+ enabled: true,
+ credentials: %{
+ "url" => "https://uisp.example.com/nms/api/v2.1",
+ "api_key" => "test-key"
+ }
+ })
+
+ %{org: org, site: site, integration: integration}
+ end
+
+ describe "sync_devices/2" do
+ test "creates a new device when no match found", %{org: org, site: site, integration: integration} do
+ site_map = %{"uisp-site-1" => site}
+
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{
+ "id" => "dev-1",
+ "name" => "AP-New",
+ "mac" => "AA:BB:CC:DD:EE:FF"
+ },
+ "ipAddress" => "10.0.1.5",
+ "site" => %{"id" => "uisp-site-1"}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = DeviceSync.sync_devices(integration, site_map)
+ assert result.created == 1
+ assert result.matched == 0
+
+ [device] = Repo.all(from(d in Device, where: d.organization_id == ^org.id))
+ assert device.ip_address == "10.0.1.5"
+ assert device.name == "AP-New"
+ assert device.site_id == site.id
+ assert device.snmp_enabled == true
+ end
+
+ test "matches existing device by IP and updates site", %{org: org, site: site, integration: integration} do
+ {:ok, other_site} =
+ %Site{}
+ |> Site.changeset(%{organization_id: org.id, name: "Old Site"})
+ |> Repo.insert()
+
+ {:ok, existing} =
+ %Device{}
+ |> Device.changeset(%{
+ organization_id: org.id,
+ site_id: other_site.id,
+ ip_address: "10.0.1.10",
+ name: "Existing Device"
+ })
+ |> Repo.insert()
+
+ site_map = %{"uisp-site-1" => site}
+
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "dev-x", "name" => "AP-Match", "mac" => nil},
+ "ipAddress" => "10.0.1.10",
+ "site" => %{"id" => "uisp-site-1"}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = DeviceSync.sync_devices(integration, site_map)
+ assert result.matched == 1
+ assert result.created == 0
+
+ updated = Repo.get!(Device, existing.id)
+ assert updated.site_id == site.id
+ end
+
+ test "skips devices with no IP address", %{org: org, integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "dev-no-ip", "name" => "No IP", "mac" => nil},
+ "ipAddress" => nil,
+ "site" => %{"id" => "uisp-site-1"}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = DeviceSync.sync_devices(integration, %{})
+ assert result.created == 0
+ assert result.matched == 0
+
+ assert [] == Repo.all(from(d in Device, where: d.organization_id == ^org.id))
+ end
+
+ test "does not update site when already correct", %{org: org, site: site, integration: integration} do
+ {:ok, existing} =
+ %Device{}
+ |> Device.changeset(%{
+ organization_id: org.id,
+ site_id: site.id,
+ ip_address: "10.0.1.20",
+ name: "Already Correct"
+ })
+ |> Repo.insert()
+
+ site_map = %{"uisp-site-1" => site}
+
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "dev-y", "name" => "Already Correct", "mac" => nil},
+ "ipAddress" => "10.0.1.20",
+ "site" => %{"id" => "uisp-site-1"}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = DeviceSync.sync_devices(integration, site_map)
+ assert result.matched == 1
+
+ # Device should still exist and be unchanged
+ unchanged = Repo.get!(Device, existing.id)
+ assert unchanged.site_id == site.id
+ end
+
+ test "handles API error", %{integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = DeviceSync.sync_devices(integration, %{})
+ end
+ end
+end
diff --git a/test/towerops/uisp/site_sync_test.exs b/test/towerops/uisp/site_sync_test.exs
new file mode 100644
index 00000000..3bd0d028
--- /dev/null
+++ b/test/towerops/uisp/site_sync_test.exs
@@ -0,0 +1,125 @@
+defmodule Towerops.Uisp.SiteSyncTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Integrations
+ alias Towerops.Repo
+ alias Towerops.Sites.Site
+ alias Towerops.Uisp.Client
+ alias Towerops.Uisp.SiteSync
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+
+ {:ok, integration} =
+ Integrations.create_integration(org.id, %{
+ provider: "uisp",
+ enabled: true,
+ credentials: %{
+ "url" => "https://uisp.example.com/nms/api/v2.1",
+ "api_key" => "test-key"
+ }
+ })
+
+ %{org: org, integration: integration}
+ end
+
+ describe "sync_sites/1" do
+ test "creates new sites from UISP response", %{org: org, integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "uisp-site-1", "name" => "Tower Alpha"},
+ "description" => %{"gps" => %{"latitude" => 40.7128, "longitude" => -74.006}}
+ },
+ %{
+ "identification" => %{"id" => "uisp-site-2", "name" => "Tower Beta"},
+ "description" => %{"gps" => %{"latitude" => 34.0522, "longitude" => -118.2437}}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = SiteSync.sync_sites(integration)
+ assert result.synced == 2
+ assert map_size(result.site_map) == 2
+
+ sites = Repo.all(from(s in Site, where: s.organization_id == ^org.id, order_by: s.name))
+ assert length(sites) == 2
+ assert Enum.any?(sites, &(&1.name == "Tower Alpha"))
+ assert Enum.any?(sites, &(&1.latitude == 40.7128))
+
+ alpha = Enum.find(sites, &(&1.name == "Tower Alpha"))
+ assert alpha.latitude == 40.7128
+ assert alpha.longitude == -74.006
+ end
+
+ test "upserts existing site by name", %{org: org, integration: integration} do
+ # Create a site with the same name first
+ {:ok, existing} =
+ %Site{}
+ |> Site.changeset(%{organization_id: org.id, name: "Tower Alpha", latitude: 0.0, longitude: 0.0})
+ |> Repo.insert()
+
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "uisp-site-1", "name" => "Tower Alpha"},
+ "description" => %{"gps" => %{"latitude" => 40.7128, "longitude" => -74.006}}
+ }
+ ])
+ end)
+
+ assert {:ok, result} = SiteSync.sync_sites(integration)
+ assert result.synced == 1
+
+ # Should update coordinates, not create a new site
+ sites = Repo.all(from(s in Site, where: s.organization_id == ^org.id))
+ assert length(sites) == 1
+ [updated] = sites
+ assert updated.id == existing.id
+ assert updated.latitude == 40.7128
+ end
+
+ test "skips sites with no name", %{integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ Req.Test.json(conn, [
+ %{"identification" => %{"id" => "uisp-site-x", "name" => nil}},
+ %{"identification" => %{"id" => "uisp-site-y", "name" => "Valid Site"}}
+ ])
+ end)
+
+ assert {:ok, result} = SiteSync.sync_sites(integration)
+ assert result.synced == 1
+ end
+
+ test "handles API error", %{integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = SiteSync.sync_sites(integration)
+ end
+ end
+
+ describe "build_site_map_by_name/1" do
+ test "returns a map of site name to site struct", %{org: org} do
+ {:ok, _} =
+ %Site{}
+ |> Site.changeset(%{organization_id: org.id, name: "Site One"})
+ |> Repo.insert()
+
+ {:ok, _} =
+ %Site{}
+ |> Site.changeset(%{organization_id: org.id, name: "Site Two"})
+ |> Repo.insert()
+
+ map = SiteSync.build_site_map_by_name(org.id)
+ assert map_size(map) == 2
+ assert %Site{name: "Site One"} = map["Site One"]
+ assert %Site{name: "Site Two"} = map["Site Two"]
+ end
+ end
+end
diff --git a/test/towerops/uisp/sync_test.exs b/test/towerops/uisp/sync_test.exs
new file mode 100644
index 00000000..3c53d6e3
--- /dev/null
+++ b/test/towerops/uisp/sync_test.exs
@@ -0,0 +1,85 @@
+defmodule Towerops.Uisp.SyncTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.AccountsFixtures
+ import Towerops.OrganizationsFixtures
+
+ alias Towerops.Integrations
+ alias Towerops.Integrations.Integration
+ alias Towerops.Repo
+ alias Towerops.Uisp.Client
+ alias Towerops.Uisp.Sync
+ alias Towerops.Uisp.SyncLog
+
+ setup do
+ user = user_fixture()
+ org = organization_fixture(user.id)
+
+ {:ok, integration} =
+ Integrations.create_integration(org.id, %{
+ provider: "uisp",
+ enabled: true,
+ credentials: %{
+ "url" => "https://uisp.example.com/nms/api/v2.1",
+ "api_key" => "test-key"
+ }
+ })
+
+ %{org: org, integration: integration}
+ end
+
+ describe "sync_organization/1" do
+ test "syncs sites and devices, writes sync log", %{org: org, integration: integration} do
+ call_count = :counters.new(1, [])
+
+ Req.Test.stub(Client, fn conn ->
+ :counters.add(call_count, 1, 1)
+
+ cond do
+ String.ends_with?(conn.request_path, "/sites") ->
+ Req.Test.json(conn, [
+ %{
+ "identification" => %{"id" => "s1", "name" => "Site One"},
+ "description" => %{"gps" => %{"latitude" => 40.0, "longitude" => -74.0}}
+ }
+ ])
+
+ String.ends_with?(conn.request_path, "/devices") ->
+ Req.Test.json(conn, [])
+
+ true ->
+ Req.Test.json(conn, [])
+ end
+ end)
+
+ assert {:ok, result} = Sync.sync_organization(integration)
+ assert result.sites_synced == 1
+ assert result.devices_matched == 0
+ assert result.devices_created == 0
+
+ [log] = Repo.all(from(sl in SyncLog, where: sl.integration_id == ^integration.id))
+ assert log.status == "success"
+ assert log.organization_id == org.id
+ assert log.duration_ms >= 0
+
+ updated = Repo.get!(Integration, integration.id)
+ assert updated.last_sync_status == "success"
+ assert updated.last_synced_at != nil
+ end
+
+ test "handles API errors and writes failed sync log", %{integration: integration} do
+ Req.Test.stub(Client, fn conn ->
+ conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"})
+ end)
+
+ assert {:error, :unauthorized} = Sync.sync_organization(integration)
+
+ [log] = Repo.all(from(sl in SyncLog, where: sl.integration_id == ^integration.id))
+ assert log.status == "failed"
+ assert log.records_synced == 0
+
+ updated = Repo.get!(Integration, integration.id)
+ assert updated.last_sync_status == "failed"
+ end
+ end
+end