paperclip/TOW-21-uisp-integration-phase1 (#31)

Reviewed-on: graham/towerops-web#31
This commit is contained in:
Graham McIntire 2026-03-15 17:57:36 -05:00
parent e947e7b2d5
commit 42bfc8b9d2
45 changed files with 4638 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

187
lib/towerops/reports.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

134
lib/towerops/rf_links.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

133
lib/towerops/uisp/client.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

116
lib/towerops/uisp/sync.ex Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -414,6 +414,12 @@ defmodule ToweropsWeb.Layouts do
>
{t("Analyze")}
</p>
<.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")}
/>
</nav>
<!-- Bottom pinned: org switcher + settings + collapse toggle -->

View file

@ -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"])

View file

@ -629,6 +629,44 @@
>
<input type="hidden" name="integration[provider]" value={provider.id} />
<div class="space-y-4">
<%= 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"

View file

@ -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

View file

@ -0,0 +1,228 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="reports"
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Reports"}
]} />
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("Scheduled Reports")}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{t("Automated report generation and email delivery")}
</p>
</div>
<button
:if={!@show_form}
phx-click="show_form"
class="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<.icon name="hero-plus" class="w-4 h-4" />
{t("New Report")}
</button>
</div>
<%!-- Create Report Form --%>
<%= if @show_form do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{t("Create Report")}</h2>
<button phx-click="hide_form" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<.form for={@form} phx-submit="save" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Report Name")}
</label>
<input
type="text"
name="report[name]"
placeholder="Weekly Uptime Report"
required
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Report Type")}
</label>
<select
name="report[report_type]"
required
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="uptime_summary">{t("Uptime Summary")}</option>
<option value="alert_history">{t("Alert History")}</option>
<option value="capacity_trends">{t("Capacity Trends")}</option>
<option value="rf_link_health">{t("RF Link Health")}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Schedule")}
</label>
<select
name="report[schedule_type]"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="daily">{t("Daily")}</option>
<option value="weekly" selected>{t("Weekly")}</option>
<option value="monthly">{t("Monthly")}</option>
<option value="one_time">{t("One Time")}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Time Range (days)")}
</label>
<select
name="report[scope_days]"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="1">{t("Last 24 hours")}</option>
<option value="7" selected>{t("Last 7 days")}</option>
<option value="30">{t("Last 30 days")}</option>
<option value="90">{t("Last 90 days")}</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t("Recipients")}
<span class="font-normal text-gray-400">{t("(comma-separated email addresses)")}</span>
</label>
<textarea
name="report[recipients_text]"
rows="2"
required
placeholder="admin@example.com, noc@example.com"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
></textarea>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
phx-click="hide_form"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t("Cancel")}
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
{t("Create Report")}
</button>
</div>
</.form>
</div>
<% end %>
<%!-- Reports List --%>
<%= if @reports != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-2 text-left font-medium">{t("Name")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Type")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Schedule")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Recipients")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Last Run")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Status")}</th>
<th class="px-4 py-2 text-right font-medium">{t("Actions")}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for report <- @reports do %>
<tr class={"text-sm #{if !report.enabled, do: "opacity-50"}"}>
<td class="px-4 py-3">
<span class="font-medium text-gray-900 dark:text-white">{report.name}</span>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{humanize_type(report.report_type)}
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{humanize_schedule(report.schedule)}
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 max-w-48 truncate">
{format_recipients(report.recipients)}
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
<%= if report.last_run_at do %>
{Calendar.strftime(report.last_run_at, "%b %d, %H:%M")}
<% else %>
-
<% end %>
</td>
<td class="px-4 py-3">
<% {label, classes} = status_badge(report.last_run_status) %>
<span class={"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium #{classes}"}>
{label}
</span>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-2">
<button
phx-click="run_now"
phx-value-id={report.id}
title="Run now"
class="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
>
<.icon name="hero-play" class="w-4 h-4" />
</button>
<button
phx-click="toggle"
phx-value-id={report.id}
title={if report.enabled, do: "Disable", else: "Enable"}
class="text-gray-400 hover:text-yellow-600 dark:hover:text-yellow-400"
>
<.icon name={if report.enabled, do: "hero-pause", else: "hero-play"} class="w-4 h-4" />
</button>
<button
phx-click="delete"
phx-value-id={report.id}
data-confirm="Delete this report?"
title="Delete"
class="text-gray-400 hover:text-red-600 dark:hover:text-red-400"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12 text-center">
<.icon name="hero-document-chart-bar" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">{t("No scheduled reports")}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("Create a report to start receiving automated updates via email.")}
</p>
<button
phx-click="show_form"
class="mt-4 inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
>
<.icon name="hero-plus" class="w-4 h-4" />
{t("Create First Report")}
</button>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -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

View file

@ -0,0 +1,299 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="rf-links"
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "RF Links"}
]} />
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("RF Link Health")}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{t("Wireless link quality triage — weakest links first")}
</p>
</div>
<%!-- Summary Stat Cards --%>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<button
phx-click="filter"
phx-value-filter="all"
class={"bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 text-left cursor-pointer hover:border-blue-300 dark:hover:border-blue-700 transition-colors #{stat_card_class(@filter, :all)}"}
>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Total Links")}
</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{@summary.total}
</div>
</button>
<button
phx-click="filter"
phx-value-filter="healthy"
class={"bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 text-left cursor-pointer hover:border-green-300 dark:hover:border-green-700 transition-colors #{stat_card_class(@filter, :healthy)}"}
>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Healthy")}
</div>
<div class="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">
{@summary.healthy}
</div>
</button>
<button
phx-click="filter"
phx-value-filter="degraded"
class={"bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 text-left cursor-pointer hover:border-yellow-300 dark:hover:border-yellow-700 transition-colors #{stat_card_class(@filter, :degraded)}"}
>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Degraded")}
</div>
<div class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{@summary.degraded}
</div>
</button>
<button
phx-click="filter"
phx-value-filter="critical"
class={"bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 text-left cursor-pointer hover:border-red-300 dark:hover:border-red-700 transition-colors #{stat_card_class(@filter, :critical)}"}
>
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Critical")}
</div>
<div class="mt-1 text-2xl font-bold text-red-600 dark:text-red-400">
{@summary.critical}
</div>
</button>
</div>
<%!-- Weakest Links Table --%>
<%= if @links != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("RF Links")}
<span class="text-gray-400 font-normal ml-1">
({length(@links)})
</span>
</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-2 text-left font-medium">{t("Client")}</th>
<th class="px-4 py-2 text-left font-medium">{t("AP / Device")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Signal")}</th>
<th class="px-4 py-2 text-left font-medium">{t("SNR")}</th>
<th class="px-4 py-2 text-left font-medium">{t("TX / RX")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Health")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Last Seen")}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for link <- @links do %>
<%!-- Main row --%>
<tr
phx-click="toggle_expand"
phx-value-id={link.id}
class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
>
<td class="px-4 py-3">
<div class="font-medium text-gray-900 dark:text-white">
{link_name(link)}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{link.mac_address}
</div>
</td>
<td class="px-4 py-3">
<%= 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)}
</.link>
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-16 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
class={"h-full rounded-full #{signal_bar_color(link.signal_strength)}"}
style={"width: #{signal_bar_width(link.signal_strength)}%"}
>
</div>
</div>
<span class={"text-xs font-medium #{health_color(link.health)}"}>
{format_signal(link.signal_strength)}
</span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<div class="w-12 h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
class={"h-full rounded-full #{snr_bar_color(link.snr)}"}
style={"width: #{snr_bar_width(link.snr)}%"}
>
</div>
</div>
<span class="text-xs text-gray-600 dark:text-gray-400">
{format_snr(link.snr)}
</span>
</div>
</td>
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">
<span>{format_rate(link.tx_rate)}</span>
<span class="text-gray-300 dark:text-gray-600 mx-1">/</span>
<span>{format_rate(link.rx_rate)}</span>
</td>
<td class="px-4 py-3">
<span class={"inline-flex items-center px-2 py-0.5 rounded text-xs font-medium #{health_bg(link.health)}"}>
{health_label(link.health)}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
<%= if link.last_seen_at do %>
<time datetime={DateTime.to_iso8601(link.last_seen_at)}>
{Calendar.strftime(link.last_seen_at, "%H:%M")}
</time>
<% else %>
-
<% end %>
</td>
</tr>
<%!-- Expandable detail row --%>
<%= if @expanded_id == link.id do %>
<tr class="bg-gray-50/50 dark:bg-gray-800/30">
<td colspan="7" class="px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<%!-- Signal Strength Detail --%>
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{t("Signal Strength")}
</h4>
<div class="flex items-center gap-3">
<div class="flex-1 h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
class={"h-full rounded-full transition-all #{signal_bar_color(link.signal_strength)}"}
style={"width: #{signal_bar_width(link.signal_strength)}%"}
>
</div>
</div>
<span class={"text-sm font-bold #{health_color(link.health)}"}>
{format_signal(link.signal_strength)}
</span>
</div>
<div class="mt-2 flex justify-between text-[10px] text-gray-400">
<span>-100 dBm</span>
<span class="text-yellow-500">-75</span>
<span class="text-green-500">-65</span>
<span>-30 dBm</span>
</div>
</div>
<%!-- SNR Detail --%>
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{t("Signal-to-Noise Ratio")}
</h4>
<div class="flex items-center gap-3">
<div class="flex-1 h-3 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div
class={"h-full rounded-full transition-all #{snr_bar_color(link.snr)}"}
style={"width: #{snr_bar_width(link.snr)}%"}
>
</div>
</div>
<span class="text-sm font-bold text-gray-900 dark:text-white">
{format_snr(link.snr)}
</span>
</div>
<div class="mt-2 flex justify-between text-[10px] text-gray-400">
<span>0 dB</span>
<span class="text-yellow-500">15</span>
<span class="text-green-500">25</span>
<span>40 dB</span>
</div>
</div>
<%!-- Additional Metrics --%>
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{t("Link Details")}
</h4>
<dl class="space-y-1 text-sm">
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{t("Distance")}</dt>
<dd class="text-gray-900 dark:text-white font-medium">{format_distance(link.distance)}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{t("TX Rate")}</dt>
<dd class="text-gray-900 dark:text-white font-medium">{format_rate(link.tx_rate)}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{t("RX Rate")}</dt>
<dd class="text-gray-900 dark:text-white font-medium">{format_rate(link.rx_rate)}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{t("Uptime")}</dt>
<dd class="text-gray-900 dark:text-white font-medium">{format_uptime(link.uptime_seconds)}</dd>
</div>
<%= if link.capacity_utilization do %>
<div class="flex justify-between">
<dt class="text-gray-500 dark:text-gray-400">{t("Capacity")}</dt>
<dd class="text-gray-900 dark:text-white font-medium">{link.capacity_utilization}%</dd>
</div>
<% end %>
</dl>
</div>
</div>
<%!-- 24h Sparkline --%>
<%= if sparkline = @sparkline_data[link.id] do %>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-white/10">
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
{t("Signal Strength — Last 24 Hours")}
</h4>
<svg
viewBox={"0 0 #{sparkline.width} #{sparkline.height}"}
class="w-full h-10"
preserveAspectRatio="none"
>
<polyline
points={sparkline.points}
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="text-blue-500"
/>
</svg>
</div>
<% end %>
</td>
</tr>
<% end %>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-12 text-center">
<.icon name="hero-signal" class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600" />
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">{t("No RF links found")}</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("RF links will appear here when wireless clients are discovered via SNMP polling.")}
</p>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -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

View file

@ -0,0 +1,146 @@
<%= if @not_found do %>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Status Page Not Found</title>
</head>
<body class="bg-gray-50 flex items-center justify-center min-h-screen">
<div class="text-center">
<h1 class="text-2xl font-bold text-gray-900">Status Page Not Found</h1>
<p class="mt-2 text-gray-600">The requested status page does not exist or is not enabled.</p>
</div>
</body>
</html>
<% else %>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@page_title}</title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static src={~p"/assets/app.js"}></script>
<%= if @config.custom_css do %>
<style>{@config.custom_css}</style>
<% end %>
</head>
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen">
<div class="max-w-3xl mx-auto px-4 py-8">
<%!-- Header --%>
<header class="text-center mb-8">
<%= if @config.logo_url do %>
<img src={@config.logo_url} alt={@config.company_name} class="h-10 mx-auto mb-4" />
<% end %>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
{@config.company_name || "System Status"}
</h1>
</header>
<%!-- Overall Status Banner --%>
<div class={"rounded-lg p-4 mb-8 text-white text-center #{overall_color(@overall_status)}"}>
<p class="text-lg font-semibold">{overall_text(@overall_status)}</p>
<p class="text-sm opacity-90 mt-1">
Last updated: {Calendar.strftime(DateTime.utc_now(), "%B %d, %Y at %H:%M UTC")}
</p>
</div>
<%!-- Active Incidents --%>
<%= if @active_incidents != [] do %>
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Active Incidents</h2>
<div class="space-y-4">
<%= for incident <- @active_incidents do %>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-start justify-between">
<div>
<h3 class={"font-semibold #{severity_color(incident.severity)}"}>
{incident.title}
</h3>
<%= if incident.body do %>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{incident.body}</p>
<% end %>
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">
{incident_status_label(incident.status)}
</span>
</div>
<p class="mt-2 text-xs text-gray-500">
Started: {Calendar.strftime(incident.started_at, "%b %d, %H:%M UTC")}
</p>
</div>
<% end %>
</div>
</div>
<% end %>
<%!-- Components --%>
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Components</h2>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
<%= if @components == [] do %>
<div class="p-4 text-center text-gray-500 dark:text-gray-400">
No components configured
</div>
<% else %>
<%= for component <- @components do %>
<div class="px-4 py-3 flex items-center justify-between">
<div>
<span class="font-medium text-gray-900 dark:text-white">{component.name}</span>
<%= if component.description do %>
<p class="text-xs text-gray-500 dark:text-gray-400">{component.description}</p>
<% end %>
</div>
<div class="flex items-center gap-2">
<div class={"w-2.5 h-2.5 rounded-full #{component_color(component.status)}"}></div>
<span class="text-sm text-gray-600 dark:text-gray-400">
{component_label(component.status)}
</span>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
<%!-- Recent Incidents --%>
<div class="mb-8">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Recent Incidents</h2>
<%= if @recent_incidents == [] do %>
<p class="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
No recent incidents — looking good!
</p>
<% else %>
<div class="space-y-3">
<%= for incident <- @recent_incidents do %>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">{incident.title}</h3>
<span class={"text-xs px-2 py-0.5 rounded font-medium " <> 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)}
</span>
</div>
<p class="mt-1 text-xs text-gray-500">
{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 %>
</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%!-- Footer --%>
<footer class="text-center text-xs text-gray-400 dark:text-gray-600 mt-12">
<%= if @config.support_email do %>
<p>
Need help? Contact <a href={"mailto:#{@config.support_email}"} class="underline hover:text-gray-600 dark:hover:text-gray-400">{@config.support_email}</a>
</p>
<% end %>
<p class="mt-1">Powered by Towerops</p>
</footer>
</div>
</body>
</html>
<% end %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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