paperclip/TOW-21-uisp-integration-phase1 (#31)
Reviewed-on: graham/towerops-web#31
This commit is contained in:
parent
e947e7b2d5
commit
42bfc8b9d2
45 changed files with 4638 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
175
lib/towerops/cn_maestro/client.ex
Normal file
175
lib/towerops/cn_maestro/client.ex
Normal 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
|
||||
169
lib/towerops/cn_maestro/sync.ex
Normal file
169
lib/towerops/cn_maestro/sync.ex
Normal 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
|
||||
|
|
@ -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
187
lib/towerops/reports.ex
Normal 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
|
||||
71
lib/towerops/reports/notifier.ex
Normal file
71
lib/towerops/reports/notifier.ex
Normal 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
|
||||
91
lib/towerops/reports/report.ex
Normal file
91
lib/towerops/reports/report.ex
Normal 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
134
lib/towerops/rf_links.ex
Normal 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
|
||||
115
lib/towerops/status_pages.ex
Normal file
115
lib/towerops/status_pages.ex
Normal 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
|
||||
38
lib/towerops/status_pages/status_incident.ex
Normal file
38
lib/towerops/status_pages/status_incident.ex
Normal 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
|
||||
35
lib/towerops/status_pages/status_page_component.ex
Normal file
35
lib/towerops/status_pages/status_page_component.ex
Normal 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
|
||||
38
lib/towerops/status_pages/status_page_config.ex
Normal file
38
lib/towerops/status_pages/status_page_config.ex
Normal 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
133
lib/towerops/uisp/client.ex
Normal 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
|
||||
172
lib/towerops/uisp/config_snapshot.ex
Normal file
172
lib/towerops/uisp/config_snapshot.ex
Normal 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
|
||||
146
lib/towerops/uisp/device_sync.ex
Normal file
146
lib/towerops/uisp/device_sync.ex
Normal 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
|
||||
96
lib/towerops/uisp/gps_sync.ex
Normal file
96
lib/towerops/uisp/gps_sync.ex
Normal 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
|
||||
101
lib/towerops/uisp/site_sync.ex
Normal file
101
lib/towerops/uisp/site_sync.ex
Normal 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
|
||||
143
lib/towerops/uisp/statistics_sync.ex
Normal file
143
lib/towerops/uisp/statistics_sync.ex
Normal 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
116
lib/towerops/uisp/sync.ex
Normal 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
|
||||
41
lib/towerops/uisp/sync_log.ex
Normal file
41
lib/towerops/uisp/sync_log.ex
Normal 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
|
||||
61
lib/towerops/workers/cn_maestro_sync_worker.ex
Normal file
61
lib/towerops/workers/cn_maestro_sync_worker.ex
Normal 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
|
||||
71
lib/towerops/workers/report_worker.ex
Normal file
71
lib/towerops/workers/report_worker.ex
Normal 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
|
||||
87
lib/towerops/workers/uisp_sync_worker.ex
Normal file
87
lib/towerops/workers/uisp_sync_worker.ex
Normal 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
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
141
lib/towerops_web/live/reports_live.ex
Normal file
141
lib/towerops_web/live/reports_live.ex
Normal 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
|
||||
228
lib/towerops_web/live/reports_live.html.heex
Normal file
228
lib/towerops_web/live/reports_live.html.heex
Normal 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>
|
||||
203
lib/towerops_web/live/rf_link_health_live.ex
Normal file
203
lib/towerops_web/live/rf_link_health_live.ex
Normal 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
|
||||
299
lib/towerops_web/live/rf_link_health_live.html.heex
Normal file
299
lib/towerops_web/live/rf_link_health_live.html.heex
Normal 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>
|
||||
101
lib/towerops_web/live/status_page_live.ex
Normal file
101
lib/towerops_web/live/status_page_live.ex
Normal 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
|
||||
146
lib/towerops_web/live/status_page_live.html.heex
Normal file
146
lib/towerops_web/live/status_page_live.html.heex
Normal 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 %>
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
29
priv/repo/migrations/20260315150000_create_reports.exs
Normal file
29
priv/repo/migrations/20260315150000_create_reports.exs
Normal 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
|
||||
|
|
@ -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
|
||||
59
priv/repo/migrations/20260315170000_create_status_pages.exs
Normal file
59
priv/repo/migrations/20260315170000_create_status_pages.exs
Normal 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
|
||||
96
test/towerops/cn_maestro/client_test.exs
Normal file
96
test/towerops/cn_maestro/client_test.exs
Normal 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
|
||||
167
test/towerops/reports_test.exs
Normal file
167
test/towerops/reports_test.exs
Normal 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
|
||||
127
test/towerops/rf_links_test.exs
Normal file
127
test/towerops/rf_links_test.exs
Normal 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
|
||||
142
test/towerops/status_pages_test.exs
Normal file
142
test/towerops/status_pages_test.exs
Normal 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
|
||||
155
test/towerops/uisp/client_test.exs
Normal file
155
test/towerops/uisp/client_test.exs
Normal 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
|
||||
158
test/towerops/uisp/device_sync_test.exs
Normal file
158
test/towerops/uisp/device_sync_test.exs
Normal 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
|
||||
125
test/towerops/uisp/site_sync_test.exs
Normal file
125
test/towerops/uisp/site_sync_test.exs
Normal 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
|
||||
85
test/towerops/uisp/sync_test.exs
Normal file
85
test/towerops/uisp/sync_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue