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 %> +
+
+ + + + + + + + + + + + + + <%= for report <- @reports do %> + + + + + + + + + + <% end %> + +
{t("Name")}{t("Type")}{t("Schedule")}{t("Recipients")}{t("Last Run")}{t("Status")}{t("Actions")}
+ {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} + + +
+ + + +
+
+
+
+ <% 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)}) + +

+
+
+ + + + + + + + + + + + + + <%= for link <- @links do %> + <%!-- Main row --%> + + + + + + + + + + + <%!-- Expandable detail row --%> + <%= if @expanded_id == link.id do %> + + + + <% end %> + <% end %> + +
{t("Client")}{t("AP / Device")}{t("Signal")}{t("SNR")}{t("TX / RX")}{t("Health")}{t("Last Seen")}
+
+ {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 %> +
+
+ <%!-- 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 %> +
+
+
+ <% 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 %> + {@config.company_name} + <% 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