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 @@ >+ {t("Automated report generation and email delivery")} +
+| {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} + + | +
+
+
+
+
+
+ |
+
+ {t("Create a report to start receiving automated updates via email.")} +
+ ++ {t("Wireless link quality triage — weakest links first")} +
+| {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 --%>
+
+
+ <%!-- 24h Sparkline --%>
+ <%= if sparkline = @sparkline_data[link.id] do %>
+
+
+
+ <%!-- SNR Detail --%>
+ + {t("Signal Strength")} ++
+
+
+
+
+ {format_signal(link.signal_strength)}
+
+
+
+
+ -100 dBm
+ -75
+ -65
+ -30 dBm
+
+
+
+
+ <%!-- Additional Metrics --%>
+ + {t("Signal-to-Noise Ratio")} ++
+
+
+
+
+ {format_snr(link.snr)}
+
+
+
+
+ 0 dB
+ 15
+ 25
+ 40 dB
+
+
+
+ + {t("Link Details")} ++
+
+
+
+
+
+
+
+ <%= if link.capacity_utilization do %>
+
+
+ <% end %>
+
+
+ <% end %>
+ + {t("Signal Strength — Last 24 Hours")} ++ + |
+ ||||||
+ {t("RF links will appear here when wireless clients are discovered via SNMP polling.")} +
+The requested status page does not exist or is not enabled.
+{overall_text(@overall_status)}
++ Last updated: {Calendar.strftime(DateTime.utc_now(), "%B %d, %Y at %H:%M UTC")} +
+{incident.body}
+ <% end %> ++ Started: {Calendar.strftime(incident.started_at, "%b %d, %H:%M UTC")} +
+{component.description}
+ <% end %> ++ No recent incidents — looking good! +
+ <% else %> ++ {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 %> +
+